add autocomplete merge with local sources

This commit is contained in:
vineyardbovines
2026-07-14 15:46:54 -04:00
parent 783dfe2a12
commit 540df4cebd
3 changed files with 168 additions and 0 deletions
+11
View File
@@ -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[]
}
@@ -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])
})
})
@@ -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<string>()
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)),
]
}