From 89ab07c4baf504cba87b830984bb48bdfc76788c Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Tue, 14 Jul 2026 16:13:37 -0400 Subject: [PATCH] add imperative useAutocompleteFn --- .../Autocomplete/useAutocomplete/index.ts | 2 + .../useAutocomplete/useAutocompleteFn.ts | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/components/Autocomplete/useAutocomplete/useAutocompleteFn.ts diff --git a/src/components/Autocomplete/useAutocomplete/index.ts b/src/components/Autocomplete/useAutocomplete/index.ts index 1b0aab9bf7..c8241511da 100644 --- a/src/components/Autocomplete/useAutocomplete/index.ts +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -129,3 +129,5 @@ export function useAutocomplete({ isError: query.isError, } } + +export * from './useAutocompleteFn' diff --git a/src/components/Autocomplete/useAutocomplete/useAutocompleteFn.ts b/src/components/Autocomplete/useAutocomplete/useAutocompleteFn.ts new file mode 100644 index 0000000000..d58abde416 --- /dev/null +++ b/src/components/Autocomplete/useAutocomplete/useAutocompleteFn.ts @@ -0,0 +1,94 @@ +import {useCallback} from 'react' +import {useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' +import { + type AutocompleteItem, + type AutocompleteItemType, + type LocalSource, +} from '#/components/Autocomplete/types' +import {mergeAutocompleteResults} from './mergeAutocompleteResults' +import {DEFAULT_MOD_OPTS, moderateProfileItem} from './moderation' +import {useEmojiSearch} from './useEmojiSearch' + +/** + * Imperative variant of useAutocomplete for callers that cannot re-render + * per keystroke (e.g. the tiptap mention suggestion plugin). Shares the + * same query cache, merge, and moderation pipeline. + */ +export function useAutocompleteFn({ + type, + sources, +}: { + type: AutocompleteItemType + sources?: LocalSource[] +}) { + const queryClient = useQueryClient() + const agent = useAgent() + const moderationOpts = useModerationOpts() + const emojiSearch = useEmojiSearch() + + return useCallback( + async (query: string, limit: number = 8): Promise => { + let remoteItems: AutocompleteItem[] = [] + + if (type === 'profile' && query) { + try { + remoteItems = await queryClient.fetchQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: ['autocomplete', {type, query}], + async queryFn() { + // Going from "foo" to "foo." should not clear matches. + const q = query.toLowerCase().trim().replace(/\.$/, '') + const res = await agent.searchActorsTypeahead({q, limit}) + return (res?.data.actors || []).map(profile => ({ + key: profile.did, + type: 'profile' as const, + value: '@' + profile.handle, + profile, + })) + }, + }) + } catch (e) { + logger.error('useAutocompleteFn: searchActorsTypeahead failed', { + message: e, + }) + } + } else if (type === 'emoji' && query) { + remoteItems = await emojiSearch(query, limit) + } + + // Match the hook's select: dedupe remote items by key. + const seen = new Set() + remoteItems = remoteItems.filter(item => { + if (seen.has(item.key)) return false + seen.add(item.key) + return true + }) + + const opts = moderationOpts || DEFAULT_MOD_OPTS + const moderate = (items: AutocompleteItem[]) => + items.filter(item => { + if (item.type !== 'profile') return true + return !!moderateProfileItem({query, item, moderationOpts: opts}) + }) + + const moderatedSources = sources?.map(source => ({ + ...source, + items: moderate(source.items), + })) + + const results = mergeAutocompleteResults({ + query, + sources: moderatedSources, + remoteItems: moderate(remoteItems), + }) + + return results.slice(0, limit) + }, + [type, sources, queryClient, agent, moderationOpts, emojiSearch], + ) +}