feat(gif): add useKlipyAutocompleteQuery hook for Klipy typeahead
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,66 @@
|
|||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {getLocales} from 'expo-localization'
|
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 {logger} from '#/logger'
|
||||||
|
import {STALE} from '#/state/queries'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
import {type Gif} from '#/state/queries/tenor'
|
||||||
|
|
||||||
export const RQKEY_ROOT = 'klipy-gif-service'
|
export const RQKEY_ROOT = 'klipy-gif-service'
|
||||||
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
||||||
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
|
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 getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED)
|
||||||
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
|
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
|
||||||
|
|
||||||
|
async function fetchKlipyAutocomplete(query: string): Promise<string[]> {
|
||||||
|
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}) {
|
export function useFeaturedGifsQuery(options?: {enabled?: boolean}) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: RQKEY_FEATURED,
|
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<Input extends object>(
|
function createKlipyApi<Input extends object>(
|
||||||
urlFn: (params: string) => string,
|
urlFn: (params: string) => string,
|
||||||
): (input: Input & {pos?: string}) => Promise<{
|
): (input: Input & {pos?: string}) => Promise<{
|
||||||
@@ -175,3 +235,8 @@ type KlipyResponse = {
|
|||||||
next: string
|
next: string
|
||||||
data: KlipyGif[]
|
data: KlipyGif[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KlipyAutocompleteResponse = {
|
||||||
|
locale: string
|
||||||
|
results: string[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user