mirror of https://github.com/zulip/zulip.git
504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
import _ from "lodash";
|
||
|
||
import * as blueslip from "./blueslip.ts";
|
||
import type {MatchedMessage, Message, RawMessage} from "./message_store.ts";
|
||
import type {UpdateMessageEvent} from "./types.ts";
|
||
import {user_settings} from "./user_settings.ts";
|
||
|
||
// From MDN: https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Math/random
|
||
export function random_int(min: number, max: number): number {
|
||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Usage: lower_bound(array, value, less)
|
||
export function lower_bound<T1, T2>(
|
||
array: T1[],
|
||
value: T2,
|
||
less: (item: T1, value: T2, middle: number) => boolean,
|
||
): number {
|
||
let first = 0;
|
||
const last = array.length;
|
||
|
||
let len = last - first;
|
||
let middle;
|
||
let step;
|
||
while (len > 0) {
|
||
step = Math.floor(len / 2);
|
||
middle = first + step;
|
||
if (less(array[middle]!, value, middle)) {
|
||
first = middle;
|
||
first += 1;
|
||
len = len - step - 1;
|
||
} else {
|
||
len = step;
|
||
}
|
||
}
|
||
return first;
|
||
}
|
||
|
||
export const lower_same = function lower_same(a?: string, b?: string): boolean {
|
||
if (a === undefined || b === undefined) {
|
||
blueslip.error("Cannot compare strings; at least one value is undefined", {a, b});
|
||
return false;
|
||
}
|
||
return a.toLowerCase() === b.toLowerCase();
|
||
};
|
||
|
||
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 {
|
||
// Streams and topics are case-insensitive.
|
||
return a.stream_id === b.stream_id && lower_same(a.topic, b.topic);
|
||
};
|
||
|
||
export function extract_pm_recipients(recipients: string): string[] {
|
||
return recipients.split(/\s*[,;]\s*/).filter((recipient) => recipient.trim() !== "");
|
||
}
|
||
|
||
// When the type is "private", properties from to_user_ids might be undefined.
|
||
// See https://github.com/zulip/zulip/pull/23032#discussion_r1038480596.
|
||
export type Recipient =
|
||
| {type: "private"; to_user_ids?: string | undefined; reply_to: string}
|
||
| ({type: "stream"} & StreamTopic);
|
||
|
||
export const same_recipient = function util_same_recipient(a?: Recipient, b?: Recipient): boolean {
|
||
if (a === undefined || b === undefined) {
|
||
return false;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
export function normalize_recipients(recipients: string): string {
|
||
// Converts a string listing emails of message recipients
|
||
// into a canonical formatting: emails sorted ASCIIbetically
|
||
// with exactly one comma and no spaces between each.
|
||
return recipients
|
||
.split(",")
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter((s) => s.length > 0)
|
||
.sort()
|
||
.join(",");
|
||
}
|
||
|
||
// 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.
|
||
export function robust_url_decode(str: string): string {
|
||
let end = str.length;
|
||
while (end > 0) {
|
||
try {
|
||
return decodeURIComponent(str.slice(0, end));
|
||
} catch (error) {
|
||
if (!(error instanceof URIError)) {
|
||
throw error;
|
||
}
|
||
end -= 1;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
// 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.
|
||
export function make_strcmp(): (x: string, y: string) => number {
|
||
try {
|
||
const collator = new Intl.Collator();
|
||
return collator.compare.bind(collator);
|
||
} catch {
|
||
// continue regardless of error
|
||
}
|
||
|
||
return function util_strcmp(a: string, b: string): number {
|
||
return a < b ? -1 : a > b ? 1 : 0;
|
||
};
|
||
}
|
||
|
||
export const strcmp = make_strcmp();
|
||
|
||
export const array_compare = function util_array_compare<T>(a: T[], b: T[]): boolean {
|
||
if (a.length !== b.length) {
|
||
return false;
|
||
}
|
||
let i;
|
||
for (i = 0; i < a.length; i += 1) {
|
||
if (a[i] !== b[i]) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/* 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.
|
||
*/
|
||
const unassigned_value_sentinel: unique symbol = Symbol("unassigned_value_sentinel");
|
||
export class CachedValue<T> {
|
||
_value: T | typeof unassigned_value_sentinel = unassigned_value_sentinel;
|
||
|
||
private compute_value: () => T;
|
||
|
||
constructor(opts: {compute_value: () => T}) {
|
||
this.compute_value = opts.compute_value;
|
||
}
|
||
|
||
get(): T {
|
||
if (this._value === unassigned_value_sentinel) {
|
||
this._value = this.compute_value();
|
||
}
|
||
return this._value;
|
||
}
|
||
|
||
reset(): void {
|
||
this._value = unassigned_value_sentinel;
|
||
}
|
||
}
|
||
|
||
export function find_stream_wildcard_mentions(message_content: string): string | null {
|
||
// We cannot use the exact same regex as the server side uses (in zerver/lib/mention.py)
|
||
// because Safari < 16.4 does not support look-behind assertions. Reframe the lookbehind of a
|
||
// negative character class as a start-of-string or positive character class.
|
||
const mention = /(?:^|[\s"'(/<[{])(@\*{2}(all|everyone|stream|channel)\*{2})/.exec(
|
||
message_content,
|
||
);
|
||
if (mention === null) {
|
||
return null;
|
||
}
|
||
return mention[2]!;
|
||
}
|
||
|
||
export const move_array_elements_to_front = function util_move_array_elements_to_front<T>(
|
||
array: T[],
|
||
selected: T[],
|
||
): T[] {
|
||
const selected_hash = new Set(selected);
|
||
const selected_elements: T[] = [];
|
||
const unselected_elements: T[] = [];
|
||
for (const element of array) {
|
||
(selected_hash.has(element) ? selected_elements : unselected_elements).push(element);
|
||
}
|
||
return [...selected_elements, ...unselected_elements];
|
||
};
|
||
|
||
// check by the userAgent string if a user's client is likely mobile.
|
||
export function is_mobile(): boolean {
|
||
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
||
window.navigator.userAgent,
|
||
);
|
||
}
|
||
|
||
export function is_client_safari(): boolean {
|
||
// Since GestureEvent is only supported on Safari, we can use it
|
||
// to detect if the browser is Safari including Safari on iOS.
|
||
// https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent
|
||
return "GestureEvent" in window;
|
||
}
|
||
|
||
export function sorted_ids(ids: number[]): number[] {
|
||
// This makes sure we don't mutate the list.
|
||
const id_list = [...new Set(ids)];
|
||
id_list.sort((a, b) => a - b);
|
||
|
||
return id_list;
|
||
}
|
||
|
||
export function set_match_data(target: Message, source: MatchedMessage): void {
|
||
target.match_subject = source.match_subject;
|
||
target.match_content = source.match_content;
|
||
}
|
||
|
||
export function get_match_topic(obj: Message | RawMessage): string | undefined {
|
||
return obj.match_subject;
|
||
}
|
||
|
||
export function get_edit_event_topic(obj: UpdateMessageEvent): string | undefined {
|
||
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;
|
||
}
|
||
|
||
export function get_edit_event_orig_topic(obj: UpdateMessageEvent): string | undefined {
|
||
return obj.orig_subject;
|
||
}
|
||
|
||
export function is_topic_synonym(operator: string): boolean {
|
||
return operator === "subject";
|
||
}
|
||
|
||
export function is_channel_synonym(text: string): boolean {
|
||
return text === "stream";
|
||
}
|
||
|
||
export function is_channels_synonym(text: string): boolean {
|
||
return text === "streams";
|
||
}
|
||
|
||
export function canonicalize_channel_synonyms(text: string): string {
|
||
if (is_channel_synonym(text.toLowerCase())) {
|
||
return "channel";
|
||
}
|
||
if (is_channels_synonym(text.toLowerCase())) {
|
||
return "channels";
|
||
}
|
||
return text;
|
||
}
|
||
|
||
export function filter_by_word_prefix_match<T>(
|
||
items: T[],
|
||
search_term: string,
|
||
item_to_text: (item: T) => string,
|
||
word_separator_regex = /\s/,
|
||
): T[] {
|
||
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();
|
||
// 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,
|
||
)
|
||
);
|
||
}),
|
||
);
|
||
|
||
return filtered_items;
|
||
}
|
||
|
||
export function get_time_from_date_muted(date_muted: number | undefined): number {
|
||
if (date_muted === undefined) {
|
||
return Date.now();
|
||
}
|
||
return date_muted * 1000;
|
||
}
|
||
|
||
export let call_function_periodically = (callback: () => void, delay: number): void => {
|
||
// 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);
|
||
|
||
// Do the callback after scheduling the next call, so that we
|
||
// are certain to call it again even if the callback throws an
|
||
// exception.
|
||
callback();
|
||
}, delay);
|
||
};
|
||
|
||
export function rewire_call_function_periodically(value: typeof call_function_periodically): void {
|
||
call_function_periodically = value;
|
||
}
|
||
|
||
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];
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export function is_valid_url(url: string, require_absolute = 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;
|
||
}
|
||
|
||
// Formats an array of strings as a Internationalized list using the specified language.
|
||
export function format_array_as_list(
|
||
array: string[],
|
||
style: Intl.ListFormatStyle,
|
||
type: Intl.ListFormatType,
|
||
): string {
|
||
// If Intl.ListFormat is not supported
|
||
if (Intl.ListFormat === undefined) {
|
||
return array.join(", ");
|
||
}
|
||
|
||
// Use Intl.ListFormat to format the array as a Internationalized list.
|
||
const list_formatter = new Intl.ListFormat(user_settings.default_language, {style, type});
|
||
|
||
// Return the formatted string.
|
||
return list_formatter.format(array);
|
||
}
|
||
|
||
// Returns the remaining time in milliseconds from the start_time and duration.
|
||
export function get_remaining_time(start_time: number, duration: number): number {
|
||
return Math.max(0, start_time + duration - Date.now());
|
||
}
|
||
|
||
export function get_custom_time_in_minutes(time_unit: string, time_input: number): number {
|
||
switch (time_unit) {
|
||
case "minutes":
|
||
return time_input;
|
||
case "hours":
|
||
return time_input * 60;
|
||
case "days":
|
||
return time_input * 24 * 60;
|
||
case "weeks":
|
||
return time_input * 7 * 24 * 60;
|
||
}
|
||
blueslip.error(`Unexpected custom time unit: ${time_unit}`);
|
||
return time_input;
|
||
}
|
||
|
||
export function check_time_input(input_value: string, keep_number_as_float = false): number {
|
||
// This check is important to make sure that inputs like "24a" are
|
||
// considered invalid and this function returns NaN for such inputs.
|
||
// Number.parseInt and Number.parseFloat will convert strings like
|
||
// "24a" to 24.
|
||
if (Number.isNaN(Number(input_value))) {
|
||
return Number.NaN;
|
||
}
|
||
|
||
if (keep_number_as_float) {
|
||
return Number.parseFloat(Number.parseFloat(input_value).toFixed(1));
|
||
}
|
||
|
||
return Number.parseInt(input_value, 10);
|
||
}
|
||
|
||
export function validate_custom_time_input(time_input: number): boolean {
|
||
if (Number.isNaN(time_input) || time_input < 0) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Helper for shorthand for Typescript to get an item from a list with
|
||
// exactly one item.
|
||
export function the<T>(items: T[] | JQuery<T>): T {
|
||
if (items.length === 0) {
|
||
blueslip.error("the: expected only 1 item, got none");
|
||
} else if (items.length > 1) {
|
||
blueslip.error("the: expected only 1 item, got more", {
|
||
num_items: items.length,
|
||
});
|
||
}
|
||
return items[0]!;
|
||
}
|
||
|
||
export function compare_a_b<T>(a: T, b: T): number {
|
||
if (a > b) {
|
||
return 1;
|
||
} else if (a === b) {
|
||
return 0;
|
||
}
|
||
return -1;
|
||
}
|