diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts index 6f2610d177..cd003f5aec 100644 --- a/src/components/Autocomplete/types.ts +++ b/src/components/Autocomplete/types.ts @@ -47,3 +47,14 @@ export type AutocompleteApi = { items: AutocompleteItem[] isFetching: boolean } + +/** + * A locally cached dataset injected into autocomplete results. Items are + * pre-hydrated and ordered most-relevant-first; array order encodes + * recency/priority and is used as the tie-break when ranking matches. + */ +export type LocalSource = { + /** stable id, e.g. 'recents', 'convo-members', 'follows' */ + key: string + items: AutocompleteItem[] +} diff --git a/src/components/Autocomplete/useAutocomplete/__tests__/mergeAutocompleteResults.test.ts b/src/components/Autocomplete/useAutocomplete/__tests__/mergeAutocompleteResults.test.ts new file mode 100644 index 0000000000..f4d2d52bce --- /dev/null +++ b/src/components/Autocomplete/useAutocomplete/__tests__/mergeAutocompleteResults.test.ts @@ -0,0 +1,95 @@ +import {describe, expect, it} from '@jest/globals' + +import { + type AutocompleteProfile, + type AutocompleteSearch, +} from '#/components/Autocomplete/types' +import {mergeAutocompleteResults} from '../mergeAutocompleteResults' + +function profileItem( + did: string, + handle: string, + displayName?: string, +): AutocompleteProfile { + return { + key: did, + type: 'profile', + value: '@' + handle, + profile: {did, handle, displayName}, + } +} + +function searchItem(q: string): AutocompleteSearch { + return {key: `recent-${q}`, type: 'search', value: q} +} + +const alice = profileItem('did:1', 'alice.test', 'Alice') +const bob = profileItem('did:2', 'bob.test', 'Bob') +const carol = profileItem('did:3', 'carol.test', 'Carol') + +describe('mergeAutocompleteResults', () => { + it('returns remote items untouched when no sources', () => { + const result = mergeAutocompleteResults({ + query: 'al', + remoteItems: [alice, bob], + }) + expect(result).toEqual([alice, bob]) + }) + + it('empty query returns local items in source order, deduped', () => { + const result = mergeAutocompleteResults({ + query: '', + sources: [ + {key: 'a', items: [alice, searchItem('cats')]}, + {key: 'b', items: [alice, bob]}, + ], + remoteItems: [], + }) + expect(result).toEqual([alice, searchItem('cats'), bob]) + }) + + it('typed query pins fuse matches above remote', () => { + const result = mergeAutocompleteResults({ + query: 'alice', + sources: [{key: 'recents', items: [bob, alice]}], + remoteItems: [carol], + }) + expect(result[0]).toEqual(alice) + expect(result).toContainEqual(carol) + expect(result).not.toContainEqual(bob) + }) + + it('pins at most 3 local matches', () => { + const many = Array.from({length: 6}, (_, i) => + profileItem(`did:m${i}`, `alice${i}.test`, `Alice ${i}`), + ) + const result = mergeAutocompleteResults({ + query: 'alice', + sources: [{key: 'recents', items: many}], + remoteItems: [carol], + }) + expect(result).toHaveLength(4) + expect(result[3]).toEqual(carol) + }) + + it('dedupes by key, keeping local position with remote profile data', () => { + const remoteAlice = profileItem('did:1', 'alice.test', 'Alice (new name)') + const result = mergeAutocompleteResults({ + query: 'alice', + sources: [{key: 'recents', items: [alice]}], + remoteItems: [carol, remoteAlice], + }) + expect(result[0]).toEqual({...alice, profile: remoteAlice.profile}) + expect(result).toHaveLength(2) + }) + + it('matches search-term items by value', () => { + const cats = searchItem('cats') + const result = mergeAutocompleteResults({ + query: 'cat', + sources: [{key: 'recents', items: [bob, cats]}], + remoteItems: [], + }) + expect(result).toEqual([cats]) + }) +}) diff --git a/src/components/Autocomplete/useAutocomplete/mergeAutocompleteResults.ts b/src/components/Autocomplete/useAutocomplete/mergeAutocompleteResults.ts new file mode 100644 index 0000000000..3788ae6746 --- /dev/null +++ b/src/components/Autocomplete/useAutocomplete/mergeAutocompleteResults.ts @@ -0,0 +1,62 @@ +import Fuse from 'fuse.js' + +import { + type AutocompleteItem, + type LocalSource, +} from '#/components/Autocomplete/types' + +/** Max local matches pinned above remote results while typing. */ +const MAX_PINNED = 3 + +/** + * Merges local source items with remote typeahead results. An empty query + * returns local items only, in source order. A typed query fuse-matches over + * local items and pins the top matches above remote results (fuse score, + * tie-broken by insertion order, i.e. source order then recency). Items are + * deduped by key; a pinned local profile adopts the fresher remote profile + * data when the same account also appears in remote results. + */ +export function mergeAutocompleteResults({ + query, + sources = [], + remoteItems = [], +}: { + query: string + sources?: LocalSource[] + remoteItems?: AutocompleteItem[] +}): AutocompleteItem[] { + const seen = new Set() + const localItems: AutocompleteItem[] = [] + for (const source of sources) { + for (const item of source.items) { + if (seen.has(item.key)) continue + seen.add(item.key) + localItems.push(item) + } + } + + let localMatches = localItems + if (query) { + const fuse = new Fuse(localItems, { + keys: ['value', 'profile.handle', 'profile.displayName'], + threshold: 0.3, + }) + localMatches = fuse + .search(query, {limit: MAX_PINNED}) + .map(result => result.item) + } + + const remoteByKey = new Map(remoteItems.map(item => [item.key, item])) + const localKeys = new Set(localMatches.map(item => item.key)) + + return [ + ...localMatches.map(item => { + const remote = remoteByKey.get(item.key) + if (item.type === 'profile' && remote?.type === 'profile') { + return {...item, profile: remote.profile} + } + return item + }), + ...remoteItems.filter(item => !localKeys.has(item.key)), + ] +}