diff --git a/ts/Intl.d.ts b/ts/Intl.d.ts index 0461dfc02..dd1bccf1a 100644 --- a/ts/Intl.d.ts +++ b/ts/Intl.d.ts @@ -10,6 +10,12 @@ declare namespace Intl { index: number; input: string; segment: string; + // According to [the proposal][0], `isWordLike` is a boolean when `granularity` is + // "word" and undefined otherwise. There may be a more rigid way to enforce this + // with TypeScript, but an optional property is okay for now. + // + // [0]: https://github.com/tc39/proposal-intl-segmenter/blob/e5f982f51cef810111dfeab835d6a934a7cae045/README.md + isWordLike?: boolean; }; interface Segments { diff --git a/ts/quill/memberRepository.ts b/ts/quill/memberRepository.ts index 09f0f1c6e..13c49a925 100644 --- a/ts/quill/memberRepository.ts +++ b/ts/quill/memberRepository.ts @@ -1,9 +1,11 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import Fuse from 'fuse.js'; import { ConversationType } from '../state/ducks/conversations'; +import { getOwn } from '../util/getOwn'; +import { filter, map } from '../util/iterables'; const FUSE_OPTIONS = { location: 0, @@ -11,23 +13,43 @@ const FUSE_OPTIONS = { threshold: 0, maxPatternLength: 32, minMatchCharLength: 1, - tokenize: true, keys: ['name', 'firstName', 'profileName', 'title'], + getFn( + conversation: Readonly, + path: string + ): ReadonlyArray | string { + // It'd be nice to avoid this cast, but Fuse's types don't allow it. + const rawValue = getOwn(conversation as Record, path); + + if (typeof rawValue !== 'string') { + // It might make more sense to return `undefined` here, but [Fuse's types don't + // allow it in newer versions][0] so we just return the empty string. + // + // [0]: https://github.com/krisk/Fuse/blob/e5e3abb44e004662c98750d0964d2d9a73b87848/src/index.d.ts#L117 + return ''; + } + + const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); + const segments = segmenter.segment(rawValue); + const wordlikeSegments = filter(segments, segment => segment.isWordLike); + const wordlikes = map(wordlikeSegments, segment => segment.segment); + return Array.from(wordlikes); + }, }; export class MemberRepository { - private members: Array; + private isFuseReady = false; - private fuse: Fuse; + private fuse: Fuse = new Fuse( + [], + FUSE_OPTIONS + ); - constructor(members: Array = []) { - this.members = members; - this.fuse = new Fuse(this.members, FUSE_OPTIONS); - } + constructor(private members: Array = []) {} updateMembers(members: Array): void { this.members = members; - this.fuse = new Fuse(members, FUSE_OPTIONS); + this.isFuseReady = false; } getMembers(omit?: ConversationType): Array { @@ -51,6 +73,11 @@ export class MemberRepository { } search(pattern: string, omit?: ConversationType): Array { + if (!this.isFuseReady) { + this.fuse.setCollection(this.members); + this.isFuseReady = true; + } + const results = this.fuse.search(`${pattern}`); if (omit) { diff --git a/ts/test-node/quill/memberRepository_test.ts b/ts/test-node/quill/memberRepository_test.ts index 23671a79d..616772b08 100644 --- a/ts/test-node/quill/memberRepository_test.ts +++ b/ts/test-node/quill/memberRepository_test.ts @@ -119,12 +119,33 @@ describe('MemberRepository', () => { }); }); + describe('given a prefix-matching string on name', () => { + it('returns the match', () => { + const memberRepository = new MemberRepository(members); + const results = memberRepository.search('dude'); + assert.deepEqual(results, [memberShia]); + }); + }); + describe('given a prefix-matching string on title', () => { it('returns the match', () => { const memberRepository = new MemberRepository(members); - const results = memberRepository.search('d'); + const results = memberRepository.search('bud'); assert.deepEqual(results, [memberShia]); }); + + it('handles titles with Unicode bidi characters, which some contacts have', () => { + const memberShiaBidi: ConversationType = { + ...memberShia, + title: '\u2086Buddyo\u2069', + }; + const memberRepository = new MemberRepository([ + memberMahershala, + memberShiaBidi, + ]); + const results = memberRepository.search('bud'); + assert.deepEqual(results, [memberShiaBidi]); + }); }); describe('given a match in the middle of a name', () => {