2022-06-16 11:39:05 +02:00
|
|
|
|
import _ from "lodash";
|
|
|
|
|
|
2022-11-02 01:10:11 +01:00
|
|
|
|
import * as blueslip from "./blueslip";
|
2021-09-22 09:47:09 +02:00
|
|
|
|
import {$t} from "./i18n";
|
2022-08-26 21:12:21 +02:00
|
|
|
|
import type {MatchedMessage, Message, RawMessage, UpdateMessageEvent} from "./types";
|
2021-09-22 09:47:09 +02:00
|
|
|
|
|
2012-11-30 18:33:32 +01:00
|
|
|
|
// From MDN: https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Math/random
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function random_int(min: number, max: number): number {
|
2012-11-30 18:33:32 +01:00
|
|
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2012-11-30 18:33:32 +01:00
|
|
|
|
|
2013-02-20 00:49:21 +01:00
|
|
|
|
// Like C++'s std::lower_bound. Returns the first index at which
|
|
|
|
|
// `value` could be inserted without changing the ordering. Assumes
|
|
|
|
|
// the array is sorted.
|
|
|
|
|
//
|
|
|
|
|
// `first` and `last` are indices and `less` is an optionally-specified
|
|
|
|
|
// function that returns true if
|
|
|
|
|
// array[i] < value
|
|
|
|
|
// for some i and false otherwise.
|
|
|
|
|
//
|
2021-07-15 21:02:18 +02:00
|
|
|
|
// Usage: lower_bound(array, value, less)
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function lower_bound<T>(
|
|
|
|
|
array: T[],
|
|
|
|
|
value: T,
|
|
|
|
|
less: (item: T, value: T, middle: number) => boolean,
|
|
|
|
|
): number {
|
2021-07-02 19:50:45 +02:00
|
|
|
|
let first = 0;
|
|
|
|
|
const last = array.length;
|
2013-02-20 00:49:21 +01:00
|
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
|
let len = last - first;
|
|
|
|
|
let middle;
|
|
|
|
|
let step;
|
2013-02-20 00:49:21 +01:00
|
|
|
|
while (len > 0) {
|
|
|
|
|
step = Math.floor(len / 2);
|
|
|
|
|
middle = first + step;
|
2014-03-11 20:17:33 +01:00
|
|
|
|
if (less(array[middle], value, middle)) {
|
2013-02-20 00:49:21 +01:00
|
|
|
|
first = middle;
|
2016-11-30 19:05:04 +01:00
|
|
|
|
first += 1;
|
2013-02-20 00:49:21 +01:00
|
|
|
|
len = len - step - 1;
|
2016-06-09 23:02:49 +02:00
|
|
|
|
} else {
|
2013-02-20 00:49:21 +01:00
|
|
|
|
len = step;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return first;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2013-02-20 00:49:21 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export const lower_same = function lower_same(a?: string, b?: string): boolean {
|
2022-11-02 01:10:11 +01:00
|
|
|
|
if (a === undefined || b === undefined) {
|
2023-04-24 15:57:45 +02:00
|
|
|
|
blueslip.error("Cannot compare strings; at least one value is undefined", {a, b});
|
2022-11-02 01:10:11 +01:00
|
|
|
|
return false;
|
|
|
|
|
}
|
2018-12-22 16:10:16 +01:00
|
|
|
|
return a.toLowerCase() === b.toLowerCase();
|
2022-03-17 16:58:10 +01:00
|
|
|
|
};
|
2018-12-22 16:10:16 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export type StreamTopic = {
|
|
|
|
|
stream_id: number;
|
|
|
|
|
topic: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const same_stream_and_topic = function util_same_stream_and_topic(
|
|
|
|
|
a: StreamTopic,
|
|
|
|
|
b: StreamTopic,
|
|
|
|
|
): boolean {
|
2016-08-27 04:13:35 +02:00
|
|
|
|
// Streams and topics are case-insensitive.
|
2020-07-15 00:34:28 +02:00
|
|
|
|
return a.stream_id === b.stream_id && lower_same(a.topic, b.topic);
|
2013-02-26 23:09:15 +01:00
|
|
|
|
};
|
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function extract_pm_recipients(recipients: string): string[] {
|
2020-07-02 01:39:34 +02:00
|
|
|
|
return recipients.split(/\s*[,;]\s*/).filter((recipient) => recipient.trim() !== "");
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2016-06-10 20:15:08 +02:00
|
|
|
|
|
2023-06-16 13:23:39 +02:00
|
|
|
|
// When the type is "private", properties from to_user_ids might be undefined.
|
|
|
|
|
// See https://github.com/zulip/zulip/pull/23032#discussion_r1038480596.
|
2023-09-22 08:37:37 +02:00
|
|
|
|
export type Recipient =
|
|
|
|
|
| {type: "private"; to_user_ids?: string; reply_to: string}
|
|
|
|
|
| ({type: "stream"} & StreamTopic);
|
2022-08-26 21:12:21 +02:00
|
|
|
|
|
|
|
|
|
export const same_recipient = function util_same_recipient(a?: Recipient, b?: Recipient): boolean {
|
2018-06-06 18:50:09 +02:00
|
|
|
|
if (a === undefined || b === undefined) {
|
2013-02-26 23:09:15 +01:00
|
|
|
|
return false;
|
2013-08-01 17:47:48 +02:00
|
|
|
|
}
|
2013-02-26 23:09:15 +01:00
|
|
|
|
|
2021-07-15 21:18:48 +02:00
|
|
|
|
if (a.type === "private" && b.type === "private") {
|
|
|
|
|
if (a.to_user_ids === undefined) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return a.to_user_ids === b.to_user_ids;
|
|
|
|
|
} else if (a.type === "stream" && b.type === "stream") {
|
|
|
|
|
return same_stream_and_topic(a, b);
|
2013-02-26 23:09:15 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export const same_sender = function util_same_sender(a: RawMessage, b: RawMessage): boolean {
|
2020-07-15 00:34:28 +02:00
|
|
|
|
return (
|
|
|
|
|
a !== undefined &&
|
|
|
|
|
b !== undefined &&
|
|
|
|
|
a.sender_email.toLowerCase() === b.sender_email.toLowerCase()
|
|
|
|
|
);
|
2013-02-26 23:09:15 +01:00
|
|
|
|
};
|
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function normalize_recipients(recipients: string): string {
|
2013-07-26 20:27:06 +02:00
|
|
|
|
// Converts a string listing emails of message recipients
|
|
|
|
|
// into a canonical formatting: emails sorted ASCIIbetically
|
|
|
|
|
// with exactly one comma and no spaces between each.
|
2021-07-13 19:34:38 +02:00
|
|
|
|
return recipients
|
|
|
|
|
.split(",")
|
|
|
|
|
.map((s) => s.trim().toLowerCase())
|
|
|
|
|
.filter((s) => s.length > 0)
|
|
|
|
|
.sort()
|
|
|
|
|
.join(",");
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2013-06-17 20:31:31 +02:00
|
|
|
|
|
2013-03-28 22:32:17 +01:00
|
|
|
|
// Avoid URI decode errors by removing characters from the end
|
|
|
|
|
// one by one until the decode succeeds. This makes sense if
|
|
|
|
|
// we are decoding input that the user is in the middle of
|
|
|
|
|
// typing.
|
2023-04-09 05:22:23 +02:00
|
|
|
|
export function robust_url_decode(str: string): string {
|
2019-11-02 00:06:25 +01:00
|
|
|
|
let end = str.length;
|
2013-03-28 22:32:17 +01:00
|
|
|
|
while (end > 0) {
|
|
|
|
|
try {
|
2020-10-07 09:41:22 +02:00
|
|
|
|
return decodeURIComponent(str.slice(0, end));
|
2020-10-07 10:20:41 +02:00
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!(error instanceof URIError)) {
|
|
|
|
|
throw error;
|
2013-08-01 17:47:48 +02:00
|
|
|
|
}
|
2016-11-30 19:05:04 +01:00
|
|
|
|
end -= 1;
|
2013-03-28 22:32:17 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
2020-07-15 01:29:15 +02:00
|
|
|
|
return "";
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2013-03-28 22:32:17 +01:00
|
|
|
|
|
2013-05-03 19:16:50 +02:00
|
|
|
|
// If we can, use a locale-aware sorter. However, if the browser
|
|
|
|
|
// doesn't support the ECMAScript Internationalization API
|
|
|
|
|
// Specification, do a dumb string comparison because
|
|
|
|
|
// String.localeCompare is really slow.
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function make_strcmp(): (x: string, y: string) => number {
|
2013-05-03 19:16:50 +02:00
|
|
|
|
try {
|
2019-11-02 00:06:25 +01:00
|
|
|
|
const collator = new Intl.Collator();
|
2022-08-26 21:12:21 +02:00
|
|
|
|
return collator.compare.bind(collator);
|
2020-10-07 10:18:48 +02:00
|
|
|
|
} catch {
|
2016-12-05 02:35:14 +01:00
|
|
|
|
// continue regardless of error
|
2013-05-03 19:16:50 +02:00
|
|
|
|
}
|
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
return function util_strcmp(a: string, b: string): number {
|
2018-06-06 18:19:09 +02:00
|
|
|
|
return a < b ? -1 : a > b ? 1 : 0;
|
2013-05-03 19:16:50 +02:00
|
|
|
|
};
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2013-05-03 19:16:50 +02:00
|
|
|
|
|
2021-02-10 17:14:50 +01:00
|
|
|
|
export const strcmp = make_strcmp();
|
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export const array_compare = function util_array_compare<T>(a: T[], b: T[]): boolean {
|
2013-05-07 19:07:05 +02:00
|
|
|
|
if (a.length !== b.length) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2019-11-02 00:06:25 +01:00
|
|
|
|
let i;
|
2016-11-30 19:05:04 +01:00
|
|
|
|
for (i = 0; i < a.length; i += 1) {
|
2013-05-07 19:07:05 +02:00
|
|
|
|
if (a[i] !== b[i]) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
2013-08-06 21:34:43 +02:00
|
|
|
|
/* Represents a value that is expensive to compute and should be
|
|
|
|
|
* computed on demand and then cached. The value can be forcefully
|
|
|
|
|
* recalculated on the next call to get() by calling reset().
|
|
|
|
|
*
|
|
|
|
|
* You must supply a option to the constructor called compute_value
|
|
|
|
|
* which should be a function that computes the uncached value.
|
|
|
|
|
*/
|
2022-08-26 21:12:21 +02:00
|
|
|
|
const unassigned_value_sentinel: unique symbol = Symbol("unassigned_value_sentinel");
|
|
|
|
|
export class CachedValue<T> {
|
|
|
|
|
_value: T | typeof unassigned_value_sentinel = unassigned_value_sentinel;
|
2020-07-23 00:29:05 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
private compute_value: () => T;
|
|
|
|
|
|
|
|
|
|
constructor(opts: {compute_value: () => T}) {
|
2021-07-13 19:30:49 +02:00
|
|
|
|
this.compute_value = opts.compute_value;
|
2020-07-23 00:29:05 +02:00
|
|
|
|
}
|
2013-08-06 21:34:43 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
get(): T {
|
2013-08-06 21:34:43 +02:00
|
|
|
|
if (this._value === unassigned_value_sentinel) {
|
|
|
|
|
this._value = this.compute_value();
|
|
|
|
|
}
|
|
|
|
|
return this._value;
|
2020-07-23 00:29:05 +02:00
|
|
|
|
}
|
2013-08-06 21:34:43 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
reset(): void {
|
2013-08-06 21:34:43 +02:00
|
|
|
|
this._value = unassigned_value_sentinel;
|
2020-07-23 00:29:05 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
2013-07-11 23:42:44 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function find_wildcard_mentions(message_content: string): string | null {
|
2020-01-23 07:22:26 +01:00
|
|
|
|
const mention = message_content.match(/(^|\s)(@\*{2}(all|everyone|stream)\*{2})($|\s)/);
|
|
|
|
|
if (mention === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return mention[3];
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2016-08-09 22:38:45 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export const move_array_elements_to_front = function util_move_array_elements_to_front<T>(
|
|
|
|
|
array: T[],
|
|
|
|
|
selected: T[],
|
|
|
|
|
): T[] {
|
2020-02-12 07:55:57 +01:00
|
|
|
|
const selected_hash = new Set(selected);
|
2022-08-26 21:12:21 +02:00
|
|
|
|
const selected_elements: T[] = [];
|
|
|
|
|
const unselected_elements: T[] = [];
|
2020-02-12 07:55:57 +01:00
|
|
|
|
for (const element of array) {
|
|
|
|
|
(selected_hash.has(element) ? selected_elements : unselected_elements).push(element);
|
2016-12-13 20:03:23 +01:00
|
|
|
|
}
|
2020-02-12 07:55:57 +01:00
|
|
|
|
return [...selected_elements, ...unselected_elements];
|
2016-12-13 20:03:23 +01:00
|
|
|
|
};
|
|
|
|
|
|
2017-03-09 02:34:45 +01:00
|
|
|
|
// check by the userAgent string if a user's client is likely mobile.
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function is_mobile(): boolean {
|
2023-09-26 21:33:07 +02:00
|
|
|
|
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
|
|
|
|
window.navigator.userAgent,
|
|
|
|
|
);
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2017-03-09 02:34:45 +01:00
|
|
|
|
|
2023-02-28 07:39:07 +01:00
|
|
|
|
export function sorted_ids(ids: number[]): number[] {
|
|
|
|
|
// This makes sure we don't mutate the list.
|
|
|
|
|
const id_list = [...new Set(ids)];
|
2020-07-02 01:45:54 +02:00
|
|
|
|
id_list.sort((a, b) => a - b);
|
2018-04-25 22:55:32 +02:00
|
|
|
|
|
|
|
|
|
return id_list;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2018-04-25 22:55:32 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function set_match_data(target: Message, source: MatchedMessage): void {
|
2018-11-15 16:59:41 +01:00
|
|
|
|
target.match_subject = source.match_subject;
|
|
|
|
|
target.match_content = source.match_content;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2018-11-15 16:59:41 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function get_match_topic(obj: Message): string | undefined {
|
2018-11-15 16:59:41 +01:00
|
|
|
|
return obj.match_subject;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2018-11-15 16:59:41 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function get_edit_event_topic(obj: UpdateMessageEvent): string | undefined {
|
2018-12-23 18:08:37 +01:00
|
|
|
|
if (obj.topic === undefined) {
|
|
|
|
|
return obj.subject;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// This code won't be reachable till we fix the
|
|
|
|
|
// server, but we use it now in tests.
|
|
|
|
|
return obj.topic;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2018-12-22 17:45:18 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function get_edit_event_orig_topic(obj: UpdateMessageEvent): string | undefined {
|
2018-12-22 17:39:37 +01:00
|
|
|
|
return obj.orig_subject;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2018-12-22 17:39:37 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function is_topic_synonym(operator: string): boolean {
|
2020-07-15 01:29:15 +02:00
|
|
|
|
return operator === "subject";
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2018-12-22 15:32:14 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function convert_message_topic(message: Message): void {
|
2023-09-22 03:35:52 +02:00
|
|
|
|
if (message.type === "stream" && message.topic === undefined) {
|
2018-12-23 16:49:14 +01:00
|
|
|
|
message.topic = message.subject;
|
|
|
|
|
}
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2020-02-28 23:59:07 +01:00
|
|
|
|
|
2023-05-02 02:59:30 +02:00
|
|
|
|
let inertDocument: Document | undefined;
|
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function clean_user_content_links(html: string): string {
|
2023-05-02 02:59:30 +02:00
|
|
|
|
if (inertDocument === undefined) {
|
|
|
|
|
inertDocument = new DOMParser().parseFromString("", "text/html");
|
|
|
|
|
}
|
|
|
|
|
const template = inertDocument.createElement("template");
|
|
|
|
|
template.innerHTML = html;
|
|
|
|
|
|
|
|
|
|
for (const elt of template.content.querySelectorAll("a")) {
|
2020-02-28 23:59:07 +01:00
|
|
|
|
// Ensure that all external links have target="_blank"
|
|
|
|
|
// rel="opener noreferrer". This ensures that external links
|
2021-05-14 00:16:30 +02:00
|
|
|
|
// never replace the Zulip web app while also protecting
|
2020-02-28 23:59:07 +01:00
|
|
|
|
// against reverse tabnapping attacks, without relying on the
|
2020-08-11 01:47:49 +02:00
|
|
|
|
// correctness of how Zulip's Markdown processor generates links.
|
2020-02-28 23:59:07 +01:00
|
|
|
|
//
|
|
|
|
|
// Fragment links, which we intend to only open within the
|
2021-05-14 00:16:30 +02:00
|
|
|
|
// Zulip web app using our hashchange system, do not require
|
2020-02-28 23:59:07 +01:00
|
|
|
|
// these attributes.
|
2020-05-09 01:52:39 +02:00
|
|
|
|
const href = elt.getAttribute("href");
|
2022-08-26 21:12:21 +02:00
|
|
|
|
if (href === null) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2020-02-28 23:59:07 +01:00
|
|
|
|
let url;
|
|
|
|
|
try {
|
2020-05-09 01:52:39 +02:00
|
|
|
|
url = new URL(href, window.location.href);
|
2020-02-28 23:59:07 +01:00
|
|
|
|
} catch {
|
|
|
|
|
elt.removeAttribute("href");
|
2020-05-09 01:52:39 +02:00
|
|
|
|
elt.removeAttribute("title");
|
2020-02-28 23:59:07 +01:00
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2020-05-09 01:52:39 +02:00
|
|
|
|
// eslint-disable-next-line no-script-url
|
|
|
|
|
if (["data:", "javascript:", "vbscript:"].includes(url.protocol)) {
|
2020-03-24 03:53:15 +01:00
|
|
|
|
// Remove unsafe links completely.
|
|
|
|
|
elt.removeAttribute("href");
|
2020-05-09 01:52:39 +02:00
|
|
|
|
elt.removeAttribute("title");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We detect URLs that are just fragments by comparing the URL
|
|
|
|
|
// against a new URL generated using only the hash.
|
|
|
|
|
if (url.hash === "" || url.href !== new URL(url.hash, window.location.href).href) {
|
2020-02-28 23:59:07 +01:00
|
|
|
|
elt.setAttribute("target", "_blank");
|
|
|
|
|
elt.setAttribute("rel", "noopener noreferrer");
|
|
|
|
|
} else {
|
|
|
|
|
elt.removeAttribute("target");
|
|
|
|
|
}
|
2020-05-09 01:52:39 +02:00
|
|
|
|
|
2023-04-20 00:44:42 +02:00
|
|
|
|
if (elt.parentElement?.classList.contains("message_inline_image")) {
|
2022-03-17 23:35:27 +01:00
|
|
|
|
// For inline images we want to handle the tooltips explicitly, and disable
|
|
|
|
|
// the browser's built in handling of the title attribute.
|
2022-08-26 21:12:21 +02:00
|
|
|
|
const title = elt.getAttribute("title");
|
|
|
|
|
if (title !== null) {
|
|
|
|
|
elt.setAttribute("aria-label", title);
|
2022-03-17 23:35:27 +01:00
|
|
|
|
elt.removeAttribute("title");
|
|
|
|
|
}
|
2020-05-09 01:52:39 +02:00
|
|
|
|
} else {
|
2022-03-17 23:35:27 +01:00
|
|
|
|
// For non-image user uploads, the following block ensures that the title
|
|
|
|
|
// attribute always displays the filename as a security measure.
|
2022-08-26 21:12:21 +02:00
|
|
|
|
let title: string;
|
|
|
|
|
let legacy_title: string;
|
2022-03-17 23:35:27 +01:00
|
|
|
|
if (
|
|
|
|
|
url.origin === window.location.origin &&
|
|
|
|
|
url.pathname.startsWith("/user_uploads/")
|
|
|
|
|
) {
|
|
|
|
|
// We add the word "download" to make clear what will
|
|
|
|
|
// happen when clicking the file. This is particularly
|
|
|
|
|
// important in the desktop app, where hovering a URL does
|
|
|
|
|
// not display the URL like it does in the web app.
|
|
|
|
|
title = legacy_title = $t(
|
|
|
|
|
{defaultMessage: "Download {filename}"},
|
|
|
|
|
{filename: url.pathname.slice(url.pathname.lastIndexOf("/") + 1)},
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2022-08-26 21:12:21 +02:00
|
|
|
|
title = url.toString();
|
2022-03-17 23:35:27 +01:00
|
|
|
|
legacy_title = href;
|
|
|
|
|
}
|
|
|
|
|
elt.setAttribute(
|
|
|
|
|
"title",
|
|
|
|
|
["", legacy_title].includes(elt.title) ? title : `${title}\n${elt.title}`,
|
|
|
|
|
);
|
2020-05-09 01:52:39 +02:00
|
|
|
|
}
|
2020-02-28 23:59:07 +01:00
|
|
|
|
}
|
2023-05-02 02:59:30 +02:00
|
|
|
|
return template.innerHTML;
|
2021-02-10 17:14:50 +01:00
|
|
|
|
}
|
2021-06-08 18:39:36 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function filter_by_word_prefix_match<T>(
|
|
|
|
|
items: T[],
|
|
|
|
|
search_term: string,
|
|
|
|
|
item_to_text: (item: T) => string,
|
2023-03-16 19:19:53 +01:00
|
|
|
|
word_separator_regex = /\s/,
|
2022-08-26 21:12:21 +02:00
|
|
|
|
): T[] {
|
2021-06-08 18:39:36 +02:00
|
|
|
|
if (search_term === "") {
|
|
|
|
|
return items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let search_terms = search_term.toLowerCase().split(",");
|
|
|
|
|
search_terms = search_terms.map((s) => s.trim());
|
|
|
|
|
|
|
|
|
|
const filtered_items = items.filter((item) =>
|
|
|
|
|
search_terms.some((search_term) => {
|
|
|
|
|
const lower_name = item_to_text(item).toLowerCase();
|
2022-06-16 11:39:05 +02:00
|
|
|
|
// returns true if the item starts with the search term or if the
|
|
|
|
|
// search term with a word separator right before it appears in the item
|
|
|
|
|
return (
|
|
|
|
|
lower_name.startsWith(search_term) ||
|
|
|
|
|
new RegExp(word_separator_regex.source + _.escapeRegExp(search_term)).test(
|
|
|
|
|
lower_name,
|
|
|
|
|
)
|
|
|
|
|
);
|
2021-06-08 18:39:36 +02:00
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return filtered_items;
|
|
|
|
|
}
|
2021-06-28 20:09:50 +02:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function get_time_from_date_muted(date_muted: number | undefined): number {
|
2021-06-28 20:09:50 +02:00
|
|
|
|
if (date_muted === undefined) {
|
|
|
|
|
return Date.now();
|
|
|
|
|
}
|
|
|
|
|
return date_muted * 1000;
|
|
|
|
|
}
|
2022-11-15 15:41:56 +01:00
|
|
|
|
|
2022-08-26 21:12:21 +02:00
|
|
|
|
export function call_function_periodically(callback: () => void, delay: number): void {
|
2022-11-15 15:41:56 +01:00
|
|
|
|
// We previously used setInterval for this purpose, but
|
|
|
|
|
// empirically observed that after unsuspend, Chrome can end
|
|
|
|
|
// up trying to "catch up" by doing dozens of these requests
|
|
|
|
|
// at once, wasting resources as well as hitting rate limits
|
|
|
|
|
// on the server. We have not been able to reproduce this
|
|
|
|
|
// reliably enough to be certain whether the setInterval
|
|
|
|
|
// requests are those that would have happened while the
|
|
|
|
|
// laptop was suspended or during a window after unsuspend
|
|
|
|
|
// before the user focuses the browser tab.
|
|
|
|
|
|
|
|
|
|
// But using setTimeout this instead ensures that we're only
|
|
|
|
|
// scheduling a next call if the browser will actually be
|
|
|
|
|
// calling "callback".
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
call_function_periodically(callback, delay);
|
|
|
|
|
}, delay);
|
|
|
|
|
|
|
|
|
|
callback();
|
|
|
|
|
}
|
2022-12-19 22:23:38 +01:00
|
|
|
|
|
|
|
|
|
export function get_string_diff(string1: string, string2: string): [number, number, number] {
|
|
|
|
|
// This function specifies the single minimal diff between 2 strings. For
|
|
|
|
|
// example, the diff between "#ann is for updates" and "#**announce** is
|
|
|
|
|
// for updates" is from index 1, till 4 in the 1st string and 13 in the
|
|
|
|
|
// 2nd string;
|
|
|
|
|
|
|
|
|
|
let diff_start_index = -1;
|
|
|
|
|
for (let i = 0; i < Math.min(string1.length, string2.length); i += 1) {
|
|
|
|
|
if (string1.charAt(i) === string2.charAt(i)) {
|
|
|
|
|
diff_start_index = i;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
diff_start_index += 1;
|
|
|
|
|
|
|
|
|
|
if (string1.length === string2.length && string1.length === diff_start_index) {
|
|
|
|
|
// if the 2 strings are identical
|
|
|
|
|
return [0, 0, 0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let diff_end_1_index = string1.length;
|
|
|
|
|
let diff_end_2_index = string2.length;
|
|
|
|
|
for (
|
|
|
|
|
let i = string1.length - 1, j = string2.length - 1;
|
|
|
|
|
i >= diff_start_index && j >= diff_start_index;
|
|
|
|
|
i -= 1, j -= 1
|
|
|
|
|
) {
|
|
|
|
|
if (string1.charAt(i) === string2.charAt(j)) {
|
|
|
|
|
diff_end_1_index = i;
|
|
|
|
|
diff_end_2_index = j;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [diff_start_index, diff_end_1_index, diff_end_2_index];
|
|
|
|
|
}
|
2023-03-12 00:22:19 +01:00
|
|
|
|
|
|
|
|
|
export function try_parse_as_truthy<T>(val: (T | undefined)[]): T[] | undefined {
|
|
|
|
|
// This is a typesafe helper to narrow an array from containing
|
|
|
|
|
// possibly falsy values into an array containing non-undefined
|
|
|
|
|
// items or undefined when any of the items is falsy.
|
|
|
|
|
|
|
|
|
|
// While this eliminates the possibility of returning an array
|
|
|
|
|
// with falsy values, the type annotation does not provide that
|
|
|
|
|
// guarantee. Ruling out undefined values is sufficient for the
|
|
|
|
|
// helper's usecases.
|
|
|
|
|
const result: T[] = [];
|
|
|
|
|
for (const x of val) {
|
|
|
|
|
if (!x) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
result.push(x);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
2023-09-28 08:53:49 +02:00
|
|
|
|
|
2023-09-28 08:54:36 +02:00
|
|
|
|
export function is_valid_url(url: string, require_absolute: boolean = false): boolean {
|
|
|
|
|
try {
|
|
|
|
|
let base_url;
|
|
|
|
|
if (!require_absolute) {
|
|
|
|
|
base_url = window.location.origin;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// JavaScript only requires the base element if we provide a relative URL.
|
|
|
|
|
// If we don’t provide one, it defaults to undefined. Alternatively, if we
|
|
|
|
|
// provide a base element with an absolute URL, JavaScript ignores the base element.
|
|
|
|
|
new URL(url, base_url);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
blueslip.log(`Invalid URL: ${url}.`, error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
2023-09-28 08:53:49 +02:00
|
|
|
|
}
|