summaryrefslogtreecommitdiff
path: root/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts
blob: 0c3aea773cd6fd582baeeb8ffe1c8a0410dee884 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
/*
Copyright 2015, 2016, 2019, 2023 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

/**
 * This is an internal module.
 */

import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import { Optional } from "matrix-events-sdk";

import { IEvent, MatrixEvent } from "./models/event";
import { M_TIMESTAMP } from "./@types/location";
import { ReceiptType } from "./@types/read_receipts";

const interns = new Map<string, string>();

/**
 * Internalises a string, reusing a known pointer or storing the pointer
 * if needed for future strings.
 * @param str - The string to internalise.
 * @returns The internalised string.
 */
export function internaliseString(str: string): string {
    // Unwrap strings before entering the map, if we somehow got a wrapped
    // string as our input. This should only happen from tests.
    if ((str as unknown) instanceof String) {
        str = str.toString();
    }

    // Check the map to see if we can store the value
    if (!interns.has(str)) {
        interns.set(str, str);
    }

    // Return any cached string reference
    return interns.get(str)!;
}

/**
 * Encode a dictionary of query parameters.
 * Omits any undefined/null values.
 * @param params - A dict of key/values to encode e.g.
 * `{"foo": "bar", "baz": "taz"}`
 * @returns The encoded string e.g. foo=bar&baz=taz
 */
export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParams): URLSearchParams {
    const searchParams = urlSearchParams ?? new URLSearchParams();
    for (const [key, val] of Object.entries(params)) {
        if (val !== undefined && val !== null) {
            if (Array.isArray(val)) {
                val.forEach((v) => {
                    searchParams.append(key, String(v));
                });
            } else {
                searchParams.append(key, String(val));
            }
        }
    }
    return searchParams;
}

export type QueryDict = Record<string, string[] | string | number | boolean | undefined>;

/**
 * Replace a stable parameter with the unstable naming for params
 */
export function replaceParam(stable: string, unstable: string, dict: QueryDict): QueryDict {
    const result = {
        ...dict,
        [unstable]: dict[stable],
    };
    delete result[stable];
    return result;
}

/**
 * Decode a query string in `application/x-www-form-urlencoded` format.
 * @param query - A query string to decode e.g.
 * foo=bar&via=server1&server2
 * @returns The decoded object, if any keys occurred multiple times
 * then the value will be an array of strings, else it will be an array.
 * This behaviour matches Node's qs.parse but is built on URLSearchParams
 * for native web compatibility
 */
export function decodeParams(query: string): Record<string, string | string[]> {
    const o: Record<string, string | string[]> = {};
    const params = new URLSearchParams(query);
    for (const key of params.keys()) {
        const val = params.getAll(key);
        o[key] = val.length === 1 ? val[0] : val;
    }
    return o;
}

/**
 * Encodes a URI according to a set of template variables. Variables will be
 * passed through encodeURIComponent.
 * @param pathTemplate - The path with template variables e.g. '/foo/$bar'.
 * @param variables - The key/value pairs to replace the template
 * variables with. E.g. `{ "$bar": "baz" }`.
 * @returns The result of replacing all template variables e.g. '/foo/baz'.
 */
export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string {
    for (const key in variables) {
        if (!variables.hasOwnProperty(key)) {
            continue;
        }
        const value = variables[key];
        if (value === undefined || value === null) {
            continue;
        }
        pathTemplate = pathTemplate.replace(key, encodeURIComponent(value));
    }
    return pathTemplate;
}

/**
 * The removeElement() method removes the first element in the array that
 * satisfies (returns true) the provided testing function.
 * @param array - The array.
 * @param fn - Function to execute on each value in the array, with the
 * function signature `fn(element, index, array)`. Return true to
 * remove this element and break.
 * @param reverse - True to search in reverse order.
 * @returns True if an element was removed.
 */
export function removeElement<T>(array: T[], fn: (t: T, i?: number, a?: T[]) => boolean, reverse?: boolean): boolean {
    let i: number;
    if (reverse) {
        for (i = array.length - 1; i >= 0; i--) {
            if (fn(array[i], i, array)) {
                array.splice(i, 1);
                return true;
            }
        }
    } else {
        for (i = 0; i < array.length; i++) {
            if (fn(array[i], i, array)) {
                array.splice(i, 1);
                return true;
            }
        }
    }
    return false;
}

/**
 * Checks if the given thing is a function.
 * @param value - The thing to check.
 * @returns True if it is a function.
 */
export function isFunction(value: any): boolean {
    return Object.prototype.toString.call(value) === "[object Function]";
}

/**
 * Checks that the given object has the specified keys.
 * @param obj - The object to check.
 * @param keys - The list of keys that 'obj' must have.
 * @throws If the object is missing keys.
 */
// note using 'keys' here would shadow the 'keys' function defined above
export function checkObjectHasKeys(obj: object, keys: string[]): void {
    for (const key of keys) {
        if (!obj.hasOwnProperty(key)) {
            throw new Error("Missing required key: " + key);
        }
    }
}

/**
 * Deep copy the given object. The object MUST NOT have circular references and
 * MUST NOT have functions.
 * @param obj - The object to deep copy.
 * @returns A copy of the object without any references to the original.
 */
export function deepCopy<T>(obj: T): T {
    return JSON.parse(JSON.stringify(obj));
}

/**
 * Compare two objects for equality. The objects MUST NOT have circular references.
 *
 * @param x - The first object to compare.
 * @param y - The second object to compare.
 *
 * @returns true if the two objects are equal
 */
export function deepCompare(x: any, y: any): boolean {
    // Inspired by
    // http://stackoverflow.com/questions/1068834/object-comparison-in-javascript#1144249

    // Compare primitives and functions.
    // Also check if both arguments link to the same object.
    if (x === y) {
        return true;
    }

    if (typeof x !== typeof y) {
        return false;
    }

    // special-case NaN (since NaN !== NaN)
    if (typeof x === "number" && isNaN(x) && isNaN(y)) {
        return true;
    }

    // special-case null (since typeof null == 'object', but null.constructor
    // throws)
    if (x === null || y === null) {
        return x === y;
    }

    // everything else is either an unequal primitive, or an object
    if (!(x instanceof Object)) {
        return false;
    }

    // check they are the same type of object
    if (x.constructor !== y.constructor || x.prototype !== y.prototype) {
        return false;
    }

    // special-casing for some special types of object
    if (x instanceof RegExp || x instanceof Date) {
        return x.toString() === y.toString();
    }

    // the object algorithm works for Array, but it's sub-optimal.
    if (Array.isArray(x)) {
        if (x.length !== y.length) {
            return false;
        }

        for (let i = 0; i < x.length; i++) {
            if (!deepCompare(x[i], y[i])) {
                return false;
            }
        }
    } else {
        // check that all of y's direct keys are in x
        for (const p in y) {
            if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
                return false;
            }
        }

        // finally, compare each of x's keys with y
        for (const p in x) {
            if (y.hasOwnProperty(p) !== x.hasOwnProperty(p) || !deepCompare(x[p], y[p])) {
                return false;
            }
        }
    }
    return true;
}

// Dev note: This returns an array of tuples, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703
/**
 * Creates an array of object properties/values (entries) then
 * sorts the result by key, recursively. The input object must
 * ensure it does not have loops. If the input is not an object
 * then it will be returned as-is.
 * @param obj - The object to get entries of
 * @returns The entries, sorted by key.
 */
export function deepSortedObjectEntries(obj: any): [string, any][] {
    if (typeof obj !== "object") return obj;

    // Apparently these are object types...
    if (obj === null || obj === undefined || Array.isArray(obj)) return obj;

    const pairs: [string, any][] = [];
    for (const [k, v] of Object.entries(obj)) {
        pairs.push([k, deepSortedObjectEntries(v)]);
    }

    // lexicographicCompare is faster than localeCompare, so let's use that.
    pairs.sort((a, b) => lexicographicCompare(a[0], b[0]));

    return pairs;
}

/**
 * Returns whether the given value is a finite number without type-coercion
 *
 * @param value - the value to test
 * @returns whether or not value is a finite number without type-coercion
 */
export function isNumber(value: any): value is number {
    return typeof value === "number" && isFinite(value);
}

/**
 * Removes zero width chars, diacritics and whitespace from the string
 * Also applies an unhomoglyph on the string, to prevent similar looking chars
 * @param str - the string to remove hidden characters from
 * @returns a string with the hidden characters removed
 */
export function removeHiddenChars(str: string): string {
    if (typeof str === "string") {
        return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, ""));
    }
    return "";
}

/**
 * Removes the direction override characters from a string
 * @returns string with chars removed
 */
export function removeDirectionOverrideChars(str: string): string {
    if (typeof str === "string") {
        return str.replace(/[\u202d-\u202e]/g, "");
    }
    return "";
}

export function normalize(str: string): string {
    // Note: we have to match the filter with the removeHiddenChars() because the
    // function strips spaces and other characters (M becomes RN for example, in lowercase).
    return (
        removeHiddenChars(str.toLowerCase())
            // Strip all punctuation
            .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "")
            // We also doubly convert to lowercase to work around oddities of the library.
            .toLowerCase()
    );
}

// Regex matching bunch of unicode control characters and otherwise misleading/invisible characters.
// Includes:
// various width spaces U+2000 - U+200D
// LTR and RTL marks U+200E and U+200F
// LTR/RTL and other directional formatting marks U+202A - U+202F
// Arabic Letter RTL mark U+061C
// Combining characters U+0300 - U+036F
// Zero width no-break space (BOM) U+FEFF
// Blank/invisible characters (U2800, U2062-U2063)
// eslint-disable-next-line no-misleading-character-class
const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g;

export function escapeRegExp(string: string): string {
    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export function globToRegexp(glob: string, extended = false): string {
    // From
    // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
    // Because micromatch is about 130KB with dependencies,
    // and minimatch is not much better.
    const replacements: [RegExp, string | ((substring: string, ...args: any[]) => string)][] = [
        [/\\\*/g, ".*"],
        [/\?/g, "."],
    ];
    if (!extended) {
        replacements.push([
            /\\\[(!|)(.*)\\]/g,
            (_match: string, neg: string, pat: string): string =>
                ["[", neg ? "^" : "", pat.replace(/\\-/, "-"), "]"].join(""),
        ]);
    }
    return replacements.reduce(
        // https://github.com/microsoft/TypeScript/issues/30134
        (pat, args) => (args ? pat.replace(args[0], args[1] as any) : pat),
        escapeRegExp(glob),
    );
}

export function ensureNoTrailingSlash(url: string): string;
export function ensureNoTrailingSlash(url: undefined): undefined;
export function ensureNoTrailingSlash(url?: string): string | undefined;
export function ensureNoTrailingSlash(url?: string): string | undefined {
    if (url?.endsWith("/")) {
        return url.slice(0, -1);
    } else {
        return url;
    }
}

/**
 * Returns a promise which resolves with a given value after the given number of ms
 */
export function sleep<T>(ms: number, value?: T): Promise<T> {
    return new Promise((resolve) => {
        setTimeout(resolve, ms, value);
    });
}

/**
 * Promise/async version of {@link setImmediate}.
 */
export function immediate(): Promise<void> {
    return new Promise(setImmediate);
}

export function isNullOrUndefined(val: any): boolean {
    return val === null || val === undefined;
}

export interface IDeferred<T> {
    resolve: (value: T | Promise<T>) => void;
    reject: (reason?: any) => void;
    promise: Promise<T>;
}

// Returns a Deferred
export function defer<T = void>(): IDeferred<T> {
    let resolve!: IDeferred<T>["resolve"];
    let reject!: IDeferred<T>["reject"];

    const promise = new Promise<T>((_resolve, _reject) => {
        resolve = _resolve;
        reject = _reject;
    });

    return { resolve, reject, promise };
}

export async function promiseMapSeries<T>(
    promises: Array<T | Promise<T>>,
    fn: (t: T) => Promise<unknown> | undefined, // if async we don't care about the type as we only await resolution
): Promise<void> {
    for (const o of promises) {
        await fn(await o);
    }
}

export function promiseTry<T>(fn: () => T | Promise<T>): Promise<T> {
    return Promise.resolve(fn());
}

// Creates and awaits all promises, running no more than `chunkSize` at the same time
export async function chunkPromises<T>(fns: (() => Promise<T>)[], chunkSize: number): Promise<T[]> {
    const results: T[] = [];
    for (let i = 0; i < fns.length; i += chunkSize) {
        results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map((fn) => fn()))));
    }
    return results;
}

/**
 * Retries the function until it succeeds or is interrupted. The given function must return
 * a promise which throws/rejects on error, otherwise the retry will assume the request
 * succeeded. The promise chain returned will contain the successful promise. The given function
 * should always return a new promise.
 * @param promiseFn - The function to call to get a fresh promise instance. Takes an
 * attempt count as an argument, for logging/debugging purposes.
 * @returns The promise for the retried operation.
 */
export function simpleRetryOperation<T>(promiseFn: (attempt: number) => Promise<T>): Promise<T> {
    return promiseRetry(
        (attempt: number) => {
            return promiseFn(attempt);
        },
        {
            forever: true,
            factor: 2,
            minTimeout: 3000, // ms
            maxTimeout: 15000, // ms
        },
    );
}

// String averaging inspired by https://stackoverflow.com/a/2510816
// Dev note: We make the alphabet a string because it's easier to write syntactically
// than arrays. Thankfully, strings implement the useful parts of the Array interface
// anyhow.

/**
 * The default alphabet used by string averaging in this SDK. This matches
 * all usefully printable ASCII characters (0x20-0x7E, inclusive).
 */
export const DEFAULT_ALPHABET = ((): string => {
    let str = "";
    for (let c = 0x20; c <= 0x7e; c++) {
        str += String.fromCharCode(c);
    }
    return str;
})();

/**
 * Pads a string using the given alphabet as a base. The returned string will be
 * padded at the end with the first character in the alphabet.
 *
 * This is intended for use with string averaging.
 * @param s - The string to pad.
 * @param n - The length to pad to.
 * @param alphabet - The alphabet to use as a single string.
 * @returns The padded string.
 */
export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string {
    return s.padEnd(n, alphabet[0]);
}

/**
 * Converts a baseN number to a string, where N is the alphabet's length.
 *
 * This is intended for use with string averaging.
 * @param n - The baseN number.
 * @param alphabet - The alphabet to use as a single string.
 * @returns The baseN number encoded as a string from the alphabet.
 */
export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string {
    // Developer note: the stringToBase() function offsets the character set by 1 so that repeated
    // characters (ie: "aaaaaa" in a..z) don't come out as zero. We have to reverse this here as
    // otherwise we'll be wrong in our conversion. Undoing a +1 before an exponent isn't very fun
    // though, so we rely on a lengthy amount of `x - 1` and integer division rules to reach a
    // sane state. This also means we have to do rollover detection: see below.

    const len = BigInt(alphabet.length);
    if (n <= len) {
        return alphabet[Number(n) - 1] ?? "";
    }

    let d = n / len;
    let r = Number(n % len) - 1;

    // Rollover detection: if the remainder is negative, it means that the string needs
    // to roll over by 1 character downwards (ie: in a..z, the previous to "aaa" would be
    // "zz").
    if (r < 0) {
        d -= BigInt(Math.abs(r)); // abs() is just to be clear what we're doing. Could also `+= r`.
        r = Number(len) - 1;
    }

    return baseToString(d, alphabet) + alphabet[r];
}

/**
 * Converts a string to a baseN number, where N is the alphabet's length.
 *
 * This is intended for use with string averaging.
 * @param s - The string to convert to a number.
 * @param alphabet - The alphabet to use as a single string.
 * @returns The baseN number.
 */
export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint {
    const len = BigInt(alphabet.length);

    // In our conversion to baseN we do a couple performance optimizations to avoid using
    // excess CPU and such. To create baseN numbers, the input string needs to be reversed
    // so the exponents stack up appropriately, as the last character in the unreversed
    // string has less impact than the first character (in "abc" the A is a lot more important
    // for lexicographic sorts). We also do a trick with the character codes to optimize the
    // alphabet lookup, avoiding an index scan of `alphabet.indexOf(reversedStr[i])` - we know
    // that the alphabet and (theoretically) the input string are constrained on character sets
    // and thus can do simple subtraction to end up with the same result.

    // Developer caution: we carefully cast to BigInt here to avoid losing precision. We cannot
    // rely on Math.pow() (for example) to be capable of handling our insane numbers.

    let result = BigInt(0);
    for (let i = s.length - 1, j = BigInt(0); i >= 0; i--, j++) {
        const charIndex = s.charCodeAt(i) - alphabet.charCodeAt(0);

        // We add 1 to the char index to offset the whole numbering scheme. We unpack this in
        // the baseToString() function.
        result += BigInt(1 + charIndex) * len ** j;
    }
    return result;
}

/**
 * Averages two strings, returning the midpoint between them. This is accomplished by
 * converting both to baseN numbers (where N is the alphabet's length) then averaging
 * those before re-encoding as a string.
 * @param a - The first string.
 * @param b - The second string.
 * @param alphabet - The alphabet to use as a single string.
 * @returns The midpoint between the strings, as a string.
 */
export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string {
    const padN = Math.max(a.length, b.length);
    const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet);
    const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet);
    const avg = (baseA + baseB) / BigInt(2);

    // Detect integer division conflicts. This happens when two numbers are divided too close so
    // we lose a .5 precision. We need to add a padding character in these cases.
    if (avg === baseA || avg == baseB) {
        return baseToString(avg, alphabet) + alphabet[0];
    }

    return baseToString(avg, alphabet);
}

/**
 * Finds the next string using the alphabet provided. This is done by converting the
 * string to a baseN number, where N is the alphabet's length, then adding 1 before
 * converting back to a string.
 * @param s - The string to start at.
 * @param alphabet - The alphabet to use as a single string.
 * @returns The string which follows the input string.
 */
export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string {
    return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet);
}

/**
 * Finds the previous string using the alphabet provided. This is done by converting the
 * string to a baseN number, where N is the alphabet's length, then subtracting 1 before
 * converting back to a string.
 * @param s - The string to start at.
 * @param alphabet - The alphabet to use as a single string.
 * @returns The string which precedes the input string.
 */
export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string {
    return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet);
}

/**
 * Compares strings lexicographically as a sort-safe function.
 * @param a - The first (reference) string.
 * @param b - The second (compare) string.
 * @returns Negative if the reference string is before the compare string;
 * positive if the reference string is after; and zero if equal.
 */
export function lexicographicCompare(a: string, b: string): number {
    // Dev note: this exists because I'm sad that you can use math operators on strings, so I've
    // hidden the operation in this function.
    if (a < b) {
        return -1;
    } else if (a > b) {
        return 1;
    } else {
        return 0;
    }
}

const collator = new Intl.Collator();
/**
 * Performant language-sensitive string comparison
 * @param a - the first string to compare
 * @param b - the second string to compare
 */
export function compare(a: string, b: string): number {
    return collator.compare(a, b);
}

/**
 * This function is similar to Object.assign() but it assigns recursively and
 * allows you to ignore nullish values from the source
 *
 * @returns the target object
 */
export function recursivelyAssign<T1 extends T2, T2 extends Record<string, any>>(
    target: T1,
    source: T2,
    ignoreNullish = false,
): T1 & T2 {
    for (const [sourceKey, sourceValue] of Object.entries(source)) {
        if (target[sourceKey] instanceof Object && sourceValue) {
            recursivelyAssign(target[sourceKey], sourceValue);
            continue;
        }
        if ((sourceValue !== null && sourceValue !== undefined) || !ignoreNullish) {
            target[sourceKey as keyof T1] = sourceValue;
            continue;
        }
    }
    return target as T1 & T2;
}

function getContentTimestampWithFallback(event: MatrixEvent): number {
    return M_TIMESTAMP.findIn<number>(event.getContent()) ?? -1;
}

/**
 * Sort events by their content m.ts property
 * Latest timestamp first
 */
export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: MatrixEvent): number {
    return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left);
}

export function isSupportedReceiptType(receiptType: string): boolean {
    return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType);
}

/**
 * Determines whether two maps are equal.
 * @param eq - The equivalence relation to compare values by. Defaults to strict equality.
 */
export function mapsEqual<K, V>(x: Map<K, V>, y: Map<K, V>, eq = (v1: V, v2: V): boolean => v1 === v2): boolean {
    if (x.size !== y.size) return false;
    for (const [k, v1] of x) {
        const v2 = y.get(k);
        if (v2 === undefined || !eq(v1, v2)) return false;
    }
    return true;
}

function processMapToObjectValue(value: any): any {
    if (value instanceof Map) {
        // Value is a Map. Recursively map it to an object.
        return recursiveMapToObject(value);
    } else if (Array.isArray(value)) {
        // Value is an Array. Recursively map the value (e.g. to cover Array of Arrays).
        return value.map((v) => processMapToObjectValue(v));
    } else {
        return value;
    }
}

/**
 * Recursively converts Maps to plain objects.
 * Also supports sub-lists of Maps.
 */
export function recursiveMapToObject(map: Map<any, any>): any {
    const targetMap = new Map();

    for (const [key, value] of map) {
        targetMap.set(key, processMapToObjectValue(value));
    }

    return Object.fromEntries(targetMap.entries());
}

export function unsafeProp<K extends keyof any | undefined>(prop: K): boolean {
    return prop === "__proto__" || prop === "prototype" || prop === "constructor";
}

export function safeSet<K extends keyof any>(obj: Record<any, any>, prop: K, value: any): void {
    if (unsafeProp(prop)) {
        throw new Error("Trying to modify prototype or constructor");
    }

    obj[prop] = value;
}

export function noUnsafeEventProps(event: Partial<IEvent>): boolean {
    return !(
        unsafeProp(event.room_id) ||
        unsafeProp(event.sender) ||
        unsafeProp(event.user_id) ||
        unsafeProp(event.event_id)
    );
}

export class MapWithDefault<K, V> extends Map<K, V> {
    public constructor(private createDefault: () => V) {
        super();
    }

    /**
     * Returns the value if the key already exists.
     * If not, it creates a new value under that key using the ctor callback and returns it.
     */
    public getOrCreate(key: K): V {
        if (!this.has(key)) {
            this.set(key, this.createDefault());
        }

        return this.get(key)!;
    }
}