add autocomplete merge with local sources
This commit is contained in:
@@ -47,3 +47,14 @@ export type AutocompleteApi = {
|
|||||||
items: AutocompleteItem[]
|
items: AutocompleteItem[]
|
||||||
isFetching: boolean
|
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