Files
bsky-social-app/src/components/Autocomplete/useAutocomplete/index.ts
T
2026-04-07 13:24:23 -05:00

168 lines
4.1 KiB
TypeScript

import {useCallback} from 'react'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {type Emoji} from '@emoji-mart/data'
import {keepPreviousData, useQuery} from '@tanstack/react-query'
import Fuse from 'fuse.js'
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
import {useGetEmojis} from '#/lib/useGetEmojis'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {
type AutocompleteApi,
type AutocompleteItem,
type AutocompleteItemType,
type AutocompleteProfile,
} from '#/components/Autocomplete/types'
const DEFAULT_MOD_OPTS = {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
}
/*
* Lazily loaded Fuse instance for emoji search. Built once on first search,
* then reused for all subsequent searches.
*/
let emojiFuseInstance: Fuse<Emoji> | null = null
function useEmojiSearch() {
const getEmojis = useGetEmojis()
return useCallback(
async (query: string, limit: number = 8) => {
if (!emojiFuseInstance) {
const data = await getEmojis()
emojiFuseInstance = new Fuse(Object.values(data.emojis), {
keys: ['search'],
threshold: 0.3,
})
}
return emojiFuseInstance.search(query, {limit})
},
[getEmojis],
)
}
export function useAutocomplete({
type,
query: q,
limit,
showSearchFallback = false,
}: {
type: AutocompleteItemType
query: string
limit?: number
showSearchFallback?: boolean
}): AutocompleteApi {
const agent = useAgent()
const moderationOpts = useModerationOpts()
const emojiSearch = useEmojiSearch()
const query = useQuery({
staleTime: STALE.MINUTES.ONE,
queryKey: [
'autocomplete',
{
type,
query: q,
},
],
async queryFn() {
if (type === 'profile') {
// TODO return recents
if (!query) return []
// Going from "foo" to "foo." should not clear matches.
q = q.toLowerCase().trim().replace(/\.$/, '')
const res = await agent.searchActorsTypeahead({
q,
limit: limit || 8,
})
return (res?.data.actors || []).map(profile => ({
key: profile.did,
type: 'profile' as const,
value: '@' + profile.handle,
profile,
}))
} else if (type === 'emoji') {
const results = await emojiSearch(q, limit || 8)
return results.map(result => ({
key: result.item.id,
type: 'emoji' as const,
value: result.item.skins[0].native,
emoji: result.item,
}))
}
return []
},
select: useCallback(
(items: AutocompleteItem[]) => {
const seen = new Set<string>()
let results: AutocompleteItem[] = []
for (const item of items) {
if (seen.has(item.key)) continue
seen.add(item.key)
if (item.type === 'profile') {
const moderated = moderateProfileItem({
query: q,
item,
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
})
if (moderated) results.push(moderated)
} else {
results.push(item)
}
}
if (showSearchFallback && q) {
results.unshift({
key: `search-${q}`,
type: 'search' as const,
value: q,
})
}
return results
},
[q, showSearchFallback, moderationOpts],
),
placeholderData: keepPreviousData,
})
return {
query: q,
items: query.data || [],
}
}
function moderateProfileItem({
query,
item,
moderationOpts,
}: {
query: string
item: AutocompleteProfile
moderationOpts: ModerationOpts
}) {
const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
const isExactMatch = query && item.profile.handle.toLowerCase() === query
if (
(isExactMatch && !moduiContainsHideableOffense(modui)) ||
!modui.filter ||
isJustAMute(modui)
) {
return item
}
return null
}