From d5686e8117944e33d5a67c177694853f701a2ec2 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Tue, 14 Apr 2026 14:15:45 -0400 Subject: [PATCH] feat(gif): add useKlipyAutocompleteQuery hook for Klipy typeahead Co-Authored-By: Claude Sonnet 4.6 --- .../gifPicker/hooks/useGifAutocomplete.ts | 110 ++++++++++++++++++ src/state/queries/klipy.ts | 69 ++++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/features/gifPicker/hooks/useGifAutocomplete.ts diff --git a/src/features/gifPicker/hooks/useGifAutocomplete.ts b/src/features/gifPicker/hooks/useGifAutocomplete.ts new file mode 100644 index 0000000000..c94551b418 --- /dev/null +++ b/src/features/gifPicker/hooks/useGifAutocomplete.ts @@ -0,0 +1,110 @@ +import {useRef, useState} from 'react' + +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {useKlipyAutocompleteQuery} from '#/state/queries/klipy' +import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' + +export type GifAutocompleteState = { + /** The suggestion strings to display */ + suggestions: string[] + /** Whether the suggestion list should be visible */ + isVisible: boolean + /** Index of the keyboard-highlighted suggestion (web only), -1 = none */ + activeIndex: number + /** Call when the user selects a suggestion */ + selectSuggestion: (suggestion: string) => void + /** Call when the raw search text changes (from the input's onChangeText) */ + handleTextChange: (text: string) => void + /** Call with the key event from the search input (web only) */ + handleKeyDown: (key: string) => boolean + /** Call to dismiss suggestions (e.g. escape key) */ + dismiss: () => void +} + +export function useGifAutocomplete({ + onSelectSuggestion, +}: { + onSelectSuggestion: (text: string) => void +}): GifAutocompleteState { + const ax = useAnalytics() + const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable) + + const [rawText, setRawText] = useState('') + const [dismissed, setDismissed] = useState(false) + const justSelectedRef = useRef(false) + + const autocompleteQuery = useThrottledValue(rawText, 200) + const {data: suggestions} = useKlipyAutocompleteQuery(autocompleteQuery, { + enabled: useKlipy && !justSelectedRef.current, + }) + + const [activeIndex, setActiveIndex] = useState(-1) + + const isVisible = + rawText.length > 0 && + !dismissed && + !justSelectedRef.current && + (suggestions?.length ?? 0) > 0 + + const handleTextChange = (text: string) => { + setRawText(text) + if (justSelectedRef.current) { + justSelectedRef.current = false + } + setDismissed(false) + setActiveIndex(-1) + } + + const selectSuggestion = (suggestion: string) => { + justSelectedRef.current = true + setRawText(suggestion) + setActiveIndex(-1) + onSelectSuggestion(suggestion) + } + + const dismiss = () => { + setDismissed(true) + setActiveIndex(-1) + } + + const handleKeyDown = (key: string): boolean => { + if (!IS_WEB || !isVisible || !suggestions?.length) return false + + switch (key) { + case 'ArrowDown': { + setActiveIndex(i => (i + 1) % suggestions.length) + return true + } + case 'ArrowUp': { + setActiveIndex(i => + i <= 0 ? suggestions.length - 1 : i - 1, + ) + return true + } + case 'Enter': { + if (activeIndex >= 0 && activeIndex < suggestions.length) { + selectSuggestion(suggestions[activeIndex]) + return true + } + return false + } + case 'Escape': { + dismiss() + return true + } + default: + return false + } + } + + return { + suggestions: suggestions ?? [], + isVisible, + activeIndex, + selectSuggestion, + handleTextChange, + handleKeyDown, + dismiss, + } +} diff --git a/src/state/queries/klipy.ts b/src/state/queries/klipy.ts index 7f385752a8..59971aa2b3 100644 --- a/src/state/queries/klipy.ts +++ b/src/state/queries/klipy.ts @@ -1,18 +1,66 @@ import {Platform} from 'react-native' import {getLocales} from 'expo-localization' -import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' +import { + keepPreviousData, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query' -import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants' +import { + GIF_KLIPY_AUTOCOMPLETE, + GIF_KLIPY_FEATURED, + GIF_KLIPY_SEARCH, +} from '#/lib/constants' import {logger} from '#/logger' +import {STALE} from '#/state/queries' import {type Gif} from '#/state/queries/tenor' export const RQKEY_ROOT = 'klipy-gif-service' export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured'] export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query] +export const RQKEY_AUTOCOMPLETE = (query: string) => [ + RQKEY_ROOT, + 'autocomplete', + query, +] const getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED) const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH) +async function fetchKlipyAutocomplete(query: string): Promise { + const params = new URLSearchParams() + + params.set( + 'client_key', + Platform.select({ + ios: 'bluesky-ios', + android: 'bluesky-android', + default: 'bluesky-web', + }), + ) + + params.set('limit', '8') + + const locale = getLocales?.()?.[0] + if (locale) { + params.set('locale', locale.languageTag.replace('-', '_')) + } + + params.set('q', query) + + const res = await fetch(GIF_KLIPY_AUTOCOMPLETE(params.toString()), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + if (!res.ok) { + throw new Error('Failed to fetch KLIPY autocomplete API') + } + const body: KlipyAutocompleteResponse = await res.json() + return body.results +} + export function useFeaturedGifsQuery(options?: {enabled?: boolean}) { return useInfiniteQuery({ queryKey: RQKEY_FEATURED, @@ -37,6 +85,18 @@ export function useGifSearchQuery( }) } +export function useKlipyAutocompleteQuery( + query: string, + options?: {enabled?: boolean}, +) { + return useQuery({ + queryKey: RQKEY_AUTOCOMPLETE(query), + queryFn: () => fetchKlipyAutocomplete(query), + enabled: query.length > 0 && options?.enabled !== false, + staleTime: STALE.HOURS.ONE, + }) +} + function createKlipyApi( urlFn: (params: string) => string, ): (input: Input & {pos?: string}) => Promise<{ @@ -175,3 +235,8 @@ type KlipyResponse = { next: string data: KlipyGif[] } + +type KlipyAutocompleteResponse = { + locale: string + results: string[] +}