add autocomplete merge with local sources
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
|
||||
+95
@@ -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)),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user