remove giant superpowers files
This commit is contained in:
@@ -1,735 +0,0 @@
|
||||
# GIF Autocomplete Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add inline autocomplete suggestions to the GIF picker dialog using Klipy's `/v2/autocomplete` endpoint, so users see search term completions as they type.
|
||||
|
||||
**Architecture:** A new `useKlipyAutocompleteQuery` hook fetches string suggestions from Klipy with a 200ms throttle. A `useGifAutocomplete` orchestration hook manages visibility and keyboard state. `GifAutocompleteSuggestions` renders an inline list below the search input inside `GifPickerHeader`, with ARIA attributes and web keyboard navigation.
|
||||
|
||||
**Tech Stack:** React Native, TypeScript, TanStack Query, Lingui (i18n), ALF design system
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-14-gif-autocomplete-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/lib/constants.ts` | Modify | Add `GIF_KLIPY_AUTOCOMPLETE` endpoint constant |
|
||||
| `src/state/queries/klipy.ts` | Modify | Add `fetchKlipyAutocomplete` function and `useKlipyAutocompleteQuery` hook |
|
||||
| `src/features/gifPicker/hooks/useGifAutocomplete.ts` | Create | Orchestration hook: 200ms throttle, visibility flag, keyboard nav state |
|
||||
| `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx` | Create | Inline suggestion list UI with ARIA and keyboard highlight |
|
||||
| `src/features/gifPicker/components/GifPickerHeader.tsx` | Modify | Render `GifAutocompleteSuggestions` below search input, wire keyboard events |
|
||||
| `src/features/gifPicker/GifPickerDialog.tsx` | Modify | Wire `useGifAutocomplete` into `GifPickerBody`, connect to search state |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Klipy Autocomplete Endpoint Constant
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/constants.ts:181-184`
|
||||
|
||||
- [ ] **Step 1: Add the endpoint constant**
|
||||
|
||||
In `src/lib/constants.ts`, add the autocomplete URL builder after the existing `GIF_KLIPY_FEATURED` constant (after line 184):
|
||||
|
||||
```ts
|
||||
export const GIF_KLIPY_AUTOCOMPLETE = (params: string) =>
|
||||
`${GIF_SERVICE}/klipy/v2/autocomplete?${params}`
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/constants.ts
|
||||
git commit -m "feat(gif): add GIF_KLIPY_AUTOCOMPLETE endpoint constant"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `useKlipyAutocompleteQuery` Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/state/queries/klipy.ts`
|
||||
|
||||
- [ ] **Step 1: Add imports and query key**
|
||||
|
||||
At the top of `src/state/queries/klipy.ts`, update the imports:
|
||||
|
||||
Change:
|
||||
```ts
|
||||
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
||||
|
||||
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
|
||||
```
|
||||
|
||||
To:
|
||||
```ts
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
GIF_KLIPY_AUTOCOMPLETE,
|
||||
GIF_KLIPY_FEATURED,
|
||||
GIF_KLIPY_SEARCH,
|
||||
} from '#/lib/constants'
|
||||
import {STALE} from '#/state/queries'
|
||||
```
|
||||
|
||||
After the existing `RQKEY_SEARCH` line (line 11), add:
|
||||
|
||||
```ts
|
||||
export const RQKEY_AUTOCOMPLETE = (query: string) => [
|
||||
RQKEY_ROOT,
|
||||
'autocomplete',
|
||||
query,
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the fetch function**
|
||||
|
||||
After line 14 (`const searchGifs = ...`), add the autocomplete fetcher. This is a standalone function (not using `createKlipyApi`) because the autocomplete endpoint has a different response shape (string array, not GIF objects) and different parameters (limit=8, no contentfilter):
|
||||
|
||||
```ts
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the response type**
|
||||
|
||||
At the bottom of the file, after the existing `KlipyResponse` type (after line 177), add:
|
||||
|
||||
```ts
|
||||
type KlipyAutocompleteResponse = {
|
||||
locale: string
|
||||
results: string[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the query hook**
|
||||
|
||||
After the existing `useGifSearchQuery` function (after line 38), add:
|
||||
|
||||
```ts
|
||||
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,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/state/queries/klipy.ts
|
||||
git commit -m "feat(gif): add useKlipyAutocompleteQuery hook for Klipy typeahead"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `useGifAutocomplete` Orchestration Hook
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/gifPicker/hooks/useGifAutocomplete.ts`
|
||||
|
||||
- [ ] **Step 1: Create the hook file**
|
||||
|
||||
Create `src/features/gifPicker/hooks/useGifAutocomplete.ts`:
|
||||
|
||||
```ts
|
||||
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,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/gifPicker/hooks/useGifAutocomplete.ts
|
||||
git commit -m "feat(gif): add useGifAutocomplete orchestration hook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create `GifAutocompleteSuggestions` Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx`:
|
||||
|
||||
```tsx
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const LISTBOX_ID = 'gif-autocomplete-listbox'
|
||||
|
||||
export function suggestionItemId(index: number) {
|
||||
return `gif-autocomplete-option-${index}`
|
||||
}
|
||||
|
||||
export {LISTBOX_ID as GIF_AUTOCOMPLETE_LISTBOX_ID}
|
||||
|
||||
export function GifAutocompleteSuggestions({
|
||||
suggestions,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
}: {
|
||||
suggestions: string[]
|
||||
activeIndex: number
|
||||
onSelect: (suggestion: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
if (suggestions.length === 0) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
role="listbox"
|
||||
id={LISTBOX_ID}
|
||||
aria-label={_(msg`Search suggestions`)}
|
||||
style={[a.rounded_sm, a.overflow_hidden, a.mt_xs, a.mb_sm]}>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const isActive = index === activeIndex
|
||||
return (
|
||||
<Pressable
|
||||
key={suggestion}
|
||||
role="option"
|
||||
id={suggestionItemId(index)}
|
||||
aria-selected={isActive}
|
||||
accessibilityLabel={suggestion}
|
||||
onPress={() => onSelect(suggestion)}
|
||||
style={state => [
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
(isActive || ('hovered' in state && state.hovered)) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<SearchIcon
|
||||
size="sm"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
<Text
|
||||
style={[a.text_md, a.flex_1]}
|
||||
numberOfLines={1}>
|
||||
{suggestion}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/gifPicker/components/GifAutocompleteSuggestions.tsx
|
||||
git commit -m "feat(gif): add GifAutocompleteSuggestions inline list component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire Autocomplete into `GifPickerHeader`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/gifPicker/components/GifPickerHeader.tsx`
|
||||
|
||||
- [ ] **Step 1: Update the component**
|
||||
|
||||
Replace the entire contents of `src/features/gifPicker/components/GifPickerHeader.tsx` with:
|
||||
|
||||
```tsx
|
||||
import {type Ref} from 'react'
|
||||
import {type TextInput, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {type GifAutocompleteState} from '#/features/gifPicker/hooks/useGifAutocomplete'
|
||||
import {
|
||||
GIF_AUTOCOMPLETE_LISTBOX_ID,
|
||||
GifAutocompleteSuggestions,
|
||||
suggestionItemId,
|
||||
} from '#/features/gifPicker/components/GifAutocompleteSuggestions'
|
||||
|
||||
export function GifPickerHeader({
|
||||
inputRef,
|
||||
onChangeText,
|
||||
onClose,
|
||||
onEscape,
|
||||
autocomplete,
|
||||
}: {
|
||||
inputRef: Ref<TextInput>
|
||||
onChangeText: (text: string) => void
|
||||
onClose: () => void
|
||||
onEscape: () => void
|
||||
autocomplete: GifAutocompleteState
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
native(a.pt_4xl),
|
||||
a.relative,
|
||||
a.mb_lg,
|
||||
a.pb_sm,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
!gtMobile && web(a.gap_md),
|
||||
]}>
|
||||
{!gtMobile && IS_WEB && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={onClose}
|
||||
label={_(msg`Close GIF dialog`)}>
|
||||
<ButtonIcon icon={Arrow} size="md" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search GIFs`)}
|
||||
onChangeText={onChangeText}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={inputRef}
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
if (!autocomplete.handleKeyDown('Escape')) {
|
||||
onEscape()
|
||||
}
|
||||
} else {
|
||||
autocomplete.handleKeyDown(nativeEvent.key)
|
||||
}
|
||||
}}
|
||||
// @ts-ignore web-only ARIA props
|
||||
role={autocomplete.isVisible ? 'combobox' : undefined}
|
||||
aria-controls={
|
||||
autocomplete.isVisible
|
||||
? GIF_AUTOCOMPLETE_LISTBOX_ID
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={autocomplete.isVisible}
|
||||
aria-autocomplete={autocomplete.isVisible ? 'list' : undefined}
|
||||
aria-activedescendant={
|
||||
autocomplete.isVisible && autocomplete.activeIndex >= 0
|
||||
? suggestionItemId(autocomplete.activeIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
|
||||
{autocomplete.isVisible && (
|
||||
<GifAutocompleteSuggestions
|
||||
suggestions={autocomplete.suggestions}
|
||||
activeIndex={autocomplete.activeIndex}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Key changes from the original:
|
||||
- Added `autocomplete` prop of type `GifAutocompleteState`
|
||||
- Wrapped the search row and suggestion list in a single `View` (the outer `View` no longer has `a.flex_row` / `a.align_center` — those moved to an inner `View` so suggestions render below)
|
||||
- `onKeyPress` now delegates to `autocomplete.handleKeyDown` first; Escape is handled by autocomplete if suggestions are visible, otherwise falls through to `onEscape`
|
||||
- Added ARIA attributes to the input when suggestions are visible
|
||||
- Renders `GifAutocompleteSuggestions` below the input when `autocomplete.isVisible`
|
||||
|
||||
- [ ] **Step 2: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: Errors in `GifPickerDialog.tsx` because `GifPickerHeader` now requires the `autocomplete` prop. This is expected and will be fixed in Task 6.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/gifPicker/components/GifPickerHeader.tsx
|
||||
git commit -m "feat(gif): wire autocomplete suggestions into GifPickerHeader"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire Everything into `GifPickerDialog`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/gifPicker/GifPickerDialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Update GifPickerBody**
|
||||
|
||||
Replace the `GifPickerBody` function in `src/features/gifPicker/GifPickerDialog.tsx` (lines 56-140) with:
|
||||
|
||||
```tsx
|
||||
function GifPickerBody({
|
||||
control,
|
||||
onSelectGif,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const search = useThrottledValue(rawSearch, 500)
|
||||
|
||||
const autocomplete = useGifAutocomplete({
|
||||
onSelectSuggestion: text => {
|
||||
setRawSearch(text)
|
||||
// Set the TextInput's displayed value to match
|
||||
textInputRef.current?.setNativeProps({text})
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isPending,
|
||||
isError,
|
||||
isSearching,
|
||||
refetch,
|
||||
} = useGifPickerData(search)
|
||||
|
||||
const items = data?.pages.flatMap(page => page.results) ?? []
|
||||
const hasData = items.length > 0
|
||||
|
||||
const onEndReached = () => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
void fetchNextPage()
|
||||
}
|
||||
|
||||
const onGoBack = () => {
|
||||
if (isSearching) {
|
||||
textInputRef.current?.clear()
|
||||
setRawSearch('')
|
||||
} else {
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeSearch = (text: string) => {
|
||||
setRawSearch(text)
|
||||
autocomplete.handleTextChange(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}
|
||||
|
||||
const header = (
|
||||
<>
|
||||
<GifPickerHeader
|
||||
inputRef={textInputRef}
|
||||
onChangeText={onChangeSearch}
|
||||
onClose={() => control.close()}
|
||||
onEscape={() => control.close()}
|
||||
autocomplete={autocomplete}
|
||||
/>
|
||||
{!hasData && (
|
||||
<GifPickerPlaceholder
|
||||
isLoading={isPending}
|
||||
isError={isError}
|
||||
isSearching={isSearching}
|
||||
query={search}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{gtMobile && <Dialog.Close />}
|
||||
<GifPickerGrid
|
||||
ref={listRef}
|
||||
items={items}
|
||||
header={header}
|
||||
hasData={hasData}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={error}
|
||||
fetchNextPage={fetchNextPage}
|
||||
onEndReached={onEndReached}
|
||||
onSelectGif={onSelectGif}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the import**
|
||||
|
||||
At the top of `src/features/gifPicker/GifPickerDialog.tsx`, add after the existing imports:
|
||||
|
||||
```ts
|
||||
import {useGifAutocomplete} from '#/features/gifPicker/hooks/useGifAutocomplete'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 4: Verify lint passes**
|
||||
|
||||
Run: `yarn lint`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/gifPicker/GifPickerDialog.tsx
|
||||
git commit -m "feat(gif): wire useGifAutocomplete into GifPickerDialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Manual Testing
|
||||
|
||||
- [ ] **Step 1: Start web dev server**
|
||||
|
||||
Run: `yarn web`
|
||||
|
||||
- [ ] **Step 2: Test the happy path**
|
||||
|
||||
1. Open the composer and click the GIF button to open the dialog
|
||||
2. Start typing a search term (e.g., "hap")
|
||||
3. Verify suggestions appear below the search input within ~200ms
|
||||
4. Verify suggestions update as you continue typing
|
||||
5. Click a suggestion — verify it fills the input and GIF results load
|
||||
6. Verify suggestions disappear after selection
|
||||
|
||||
- [ ] **Step 3: Test keyboard navigation (web)**
|
||||
|
||||
1. Type a partial query (e.g., "dan")
|
||||
2. Press ArrowDown — verify the first suggestion highlights
|
||||
3. Press ArrowDown again — verify highlight moves to second suggestion
|
||||
4. Press ArrowUp — verify highlight moves back
|
||||
5. Press Enter — verify the highlighted suggestion is selected, input fills, GIFs load
|
||||
6. Press Escape while suggestions are visible — verify suggestions dismiss but dialog stays open
|
||||
7. Press Escape again — verify dialog closes
|
||||
|
||||
- [ ] **Step 4: Test edge cases**
|
||||
|
||||
1. Type and then clear the input — verify suggestions disappear
|
||||
2. Select a suggestion, then backspace to edit — verify suggestions re-appear
|
||||
3. Type something with no autocomplete results — verify no suggestion list renders
|
||||
4. Rapidly type and delete — verify no visual glitches or stale suggestions
|
||||
5. Verify the GIF grid still scrolls and paginates normally when suggestions are not visible
|
||||
|
||||
- [ ] **Step 5: Test on Tenor path**
|
||||
|
||||
1. If you can disable the `KlipyGifProviderEnable` feature flag, verify the GIF picker works normally without autocomplete (no suggestions, no errors)
|
||||
2. If you can't toggle the flag, verify there are no runtime errors when the hook is called — it should simply never show suggestions
|
||||
|
||||
- [ ] **Step 6: Commit any fixes from testing**
|
||||
|
||||
If any issues were found and fixed during testing, commit them:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(gif): address issues found during manual autocomplete testing"
|
||||
```
|
||||
@@ -1,315 +0,0 @@
|
||||
# GIF Category Pills Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a horizontal scrollable row of category pills (Trending, Love, Happy, Sad, etc.) to the GIF picker's idle screen so users can browse GIFs by emotion/reaction without typing.
|
||||
|
||||
**Architecture:** A new `GifCategoryPills` component renders a horizontal `ScrollView` of `Pressable` pills. `GifPickerDialog` manages `activeCategory` state — when a non-trending pill is tapped, its searchterm is passed to the existing `useGifPickerData` hook. Pills hide when the user types and reappear when the input is cleared.
|
||||
|
||||
**Tech Stack:** React Native, TypeScript, ALF design system, Lingui (i18n)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-14-gif-category-pills-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/features/gifPicker/components/GifCategoryPills.tsx` | Create | Horizontal scrollable pill row with active state |
|
||||
| `src/features/gifPicker/GifPickerDialog.tsx` | Modify | Add category state, render pills, connect to search |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create `GifCategoryPills` Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/gifPicker/components/GifCategoryPills.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `src/features/gifPicker/components/GifCategoryPills.tsx`:
|
||||
|
||||
```tsx
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type GifCategory = {
|
||||
id: string
|
||||
emoji: string
|
||||
label: ReturnType<typeof msg>
|
||||
searchterm: string | null // null = trending (uses featured endpoint)
|
||||
}
|
||||
|
||||
export const GIF_CATEGORIES: readonly GifCategory[] = [
|
||||
{id: 'trending', emoji: '🔥', label: msg`Trending`, searchterm: null},
|
||||
{id: 'love', emoji: '❤️', label: msg`Love`, searchterm: 'love'},
|
||||
{id: 'happy', emoji: '😄', label: msg`Happy`, searchterm: 'happy'},
|
||||
{id: 'sad', emoji: '😢', label: msg`Sad`, searchterm: 'cry'},
|
||||
{id: 'party', emoji: '🎉', label: msg`Party`, searchterm: 'congratulations'},
|
||||
{id: 'yes', emoji: '👍', label: msg`Yes`, searchterm: 'yes'},
|
||||
{id: 'lol', emoji: '😂', label: msg`LOL`, searchterm: 'lol'},
|
||||
{id: 'excited', emoji: '🤩', label: msg`Excited`, searchterm: 'excited'},
|
||||
] as const
|
||||
|
||||
export function GifCategoryPills({
|
||||
activeId,
|
||||
onSelect,
|
||||
}: {
|
||||
activeId: string
|
||||
onSelect: (category: GifCategory) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.mb_sm]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[a.flex_row, a.gap_xs, a.px_xl]}>
|
||||
{GIF_CATEGORIES.map(category => {
|
||||
const isActive = category.id === activeId
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
label={_(category.label)}
|
||||
onPress={() => onSelect(category)}
|
||||
size="small"
|
||||
variant={isActive ? 'solid' : 'outline'}
|
||||
color={isActive ? 'primary' : 'secondary'}
|
||||
shape="default">
|
||||
<ButtonText>
|
||||
<Text emoji>{category.emoji}</Text> {_(category.label)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the implementation:
|
||||
- Uses the existing `Button` component with `variant="solid"` for active and `variant="outline"` for inactive, matching ALF patterns
|
||||
- `shape="default"` gives pill-shaped buttons (the default shape is a pill/rounded)
|
||||
- `ScrollView` with `horizontal` and `showsHorizontalScrollIndicator={false}`
|
||||
- `px_xl` padding matches the GIF grid's `contentContainerStyle` on native
|
||||
- The `GIF_CATEGORIES` array and `GifCategory` type are exported so `GifPickerDialog` can reference them
|
||||
- `searchterm: null` for trending means "use the featured endpoint" — handled by the consumer
|
||||
- Emoji is wrapped in `<Text emoji>` per codebase convention for emoji rendering
|
||||
|
||||
- [ ] **Step 2: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/gifPicker/components/GifCategoryPills.tsx
|
||||
git commit -m "feat(gif): add GifCategoryPills component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire Category Pills into `GifPickerDialog`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/gifPicker/GifPickerDialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
At the top of `src/features/gifPicker/GifPickerDialog.tsx`, add after the existing imports:
|
||||
|
||||
```ts
|
||||
import {
|
||||
GIF_CATEGORIES,
|
||||
GifCategoryPills,
|
||||
type GifCategory,
|
||||
} from '#/features/gifPicker/components/GifCategoryPills'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `GifPickerBody`**
|
||||
|
||||
Replace the entire `GifPickerBody` function (lines 56-140) with:
|
||||
|
||||
```tsx
|
||||
function GifPickerBody({
|
||||
control,
|
||||
onSelectGif,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string>('trending')
|
||||
const search = useThrottledValue(rawSearch, 750)
|
||||
|
||||
// Determine the effective search query:
|
||||
// - If user is typing, use the typed text
|
||||
// - If a non-trending category is active, use its searchterm
|
||||
// - Otherwise (trending), empty string triggers the featured endpoint
|
||||
const activeCategorySearchterm =
|
||||
GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? ''
|
||||
const effectiveSearch = search.length > 0 ? search : activeCategorySearchterm
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isPending,
|
||||
isError,
|
||||
isSearching,
|
||||
refetch,
|
||||
} = useGifPickerData(effectiveSearch)
|
||||
|
||||
const items = data?.pages.flatMap(page => page.results) ?? []
|
||||
const hasData = items.length > 0
|
||||
|
||||
const onEndReached = () => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
void fetchNextPage()
|
||||
}
|
||||
|
||||
const onGoBack = () => {
|
||||
if (isSearching) {
|
||||
textInputRef.current?.clear()
|
||||
setRawSearch('')
|
||||
setActiveCategory('trending')
|
||||
} else {
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeSearch = (text: string) => {
|
||||
setRawSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}
|
||||
|
||||
const onSelectCategory = (category: GifCategory) => {
|
||||
setActiveCategory(category.id)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}
|
||||
|
||||
const showPills = rawSearch.length === 0
|
||||
|
||||
const header = (
|
||||
<>
|
||||
<GifPickerHeader
|
||||
inputRef={textInputRef}
|
||||
onChangeText={onChangeSearch}
|
||||
onClose={() => control.close()}
|
||||
onEscape={() => control.close()}
|
||||
/>
|
||||
{showPills && (
|
||||
<GifCategoryPills
|
||||
activeId={activeCategory}
|
||||
onSelect={onSelectCategory}
|
||||
/>
|
||||
)}
|
||||
{!hasData && (
|
||||
<GifPickerPlaceholder
|
||||
isLoading={isPending}
|
||||
isError={isError}
|
||||
isSearching={isSearching}
|
||||
query={effectiveSearch}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{gtMobile && <Dialog.Close />}
|
||||
<GifPickerGrid
|
||||
ref={listRef}
|
||||
items={items}
|
||||
header={header}
|
||||
hasData={hasData}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={error}
|
||||
fetchNextPage={fetchNextPage}
|
||||
onEndReached={onEndReached}
|
||||
onSelectGif={onSelectGif}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Key changes from the current version:
|
||||
- Added `activeCategory` state (defaults to `'trending'`)
|
||||
- Added `effectiveSearch` that resolves the search query from either typed text or the active category's searchterm
|
||||
- Added `onSelectCategory` handler
|
||||
- Added `showPills` flag — `true` when `rawSearch` is empty
|
||||
- Render `GifCategoryPills` between the header and placeholder, conditionally on `showPills`
|
||||
- `onGoBack` now also resets `activeCategory` to `'trending'`
|
||||
- `GifPickerPlaceholder` receives `effectiveSearch` instead of `search` so error messages reflect the actual query
|
||||
|
||||
- [ ] **Step 3: Verify typecheck passes**
|
||||
|
||||
Run: `yarn typecheck`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 4: Verify lint passes**
|
||||
|
||||
Run: `yarn lint`
|
||||
Expected: No new errors
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/gifPicker/GifPickerDialog.tsx
|
||||
git commit -m "feat(gif): wire category pills into GIF picker dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Manual Testing
|
||||
|
||||
- [ ] **Step 1: Test on web (CORS-disabled Chrome) or iOS simulator**
|
||||
|
||||
1. Open the composer and click the GIF button
|
||||
2. Verify the pill row appears below the search input with "Trending" highlighted
|
||||
3. Verify featured GIFs load in the grid below
|
||||
|
||||
- [ ] **Step 2: Test category selection**
|
||||
|
||||
1. Tap "Happy" — verify the pill highlights, grid shows happy GIFs
|
||||
2. Tap "Sad" — verify it switches, grid shows sad GIFs
|
||||
3. Tap "Trending" — verify it goes back to featured GIFs
|
||||
4. Tap several pills quickly — verify no visual glitches
|
||||
|
||||
- [ ] **Step 3: Test interaction with search**
|
||||
|
||||
1. Start typing in the search box — verify pills hide
|
||||
2. Clear the search box — verify pills reappear with "Trending" active
|
||||
3. Select a category, then type — verify pills hide and search results show
|
||||
4. Clear again — verify pills return with "Trending" (not the previously selected category)
|
||||
|
||||
- [ ] **Step 4: Test pill scrolling**
|
||||
|
||||
1. On a narrow screen (mobile or narrow browser), verify the pill row scrolls horizontally
|
||||
2. Verify all 8 pills are reachable by scrolling
|
||||
3. Verify no horizontal scroll indicator is visible
|
||||
|
||||
- [ ] **Step 5: Commit any fixes from testing**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(gif): address issues found during category pills testing"
|
||||
```
|
||||
@@ -1,192 +0,0 @@
|
||||
# GIF Autocomplete Design
|
||||
|
||||
Inline search suggestions in the GIF picker dialog, powered by Klipy's
|
||||
`/v2/autocomplete` endpoint.
|
||||
|
||||
## Context
|
||||
|
||||
The GIF picker currently has a search field that fires a full GIF search after a
|
||||
500ms throttle. There is no typeahead or suggestion behavior — the user types
|
||||
blindly and waits for results. Klipy exposes an autocomplete endpoint that
|
||||
returns lightweight string suggestions for a partial query, which we can use to
|
||||
help users find the right search term faster.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| Suggestion placement | Inline list below search input | Avoids Portal/z-index issues inside the dialog. Fits the reserved slot in `GifPickerHeader`. No need for Sift's anchor-relative positioning. |
|
||||
| Keyboard navigation (web) | Custom lightweight handler (~30 lines) | Sift's positioning model (`position: fixed`) fights inline rendering. Borrowing the keyboard pattern without the positioning baggage. |
|
||||
| Visibility | Show when 1+ characters typed, hide on selection or clear | No suggestions on empty input — featured GIFs serve that role. |
|
||||
| Provider scope | Klipy only | Tenor is being sunset. Users on the Tenor path don't get suggestions. |
|
||||
| Throttle timing | 200ms for autocomplete, independent of 500ms search throttle | Autocomplete responses are tiny (string array). Snappy feel expected for typeahead. |
|
||||
|
||||
## Klipy Autocomplete Endpoint
|
||||
|
||||
**Request:**
|
||||
```
|
||||
GET https://gifs.bsky.app/klipy/v2/autocomplete?q=<query>&client_key=<key>&limit=<n>&locale=<locale>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"locale": "en",
|
||||
"results": ["happy birthday", "happy", "happy easter", "happy dance"]
|
||||
}
|
||||
```
|
||||
|
||||
Parameters follow the same pattern as the existing search/featured endpoints
|
||||
(`client_key`, `locale`). We'll request `limit=8` suggestions — enough to be
|
||||
useful without overwhelming the list or pushing the GIF grid too far down.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Tree
|
||||
|
||||
```
|
||||
GifPickerDialog
|
||||
└─ GifPickerBody
|
||||
├─ GifPickerHeader
|
||||
│ ├─ TextField.Input (search box)
|
||||
│ └─ GifAutocompleteSuggestions ← NEW
|
||||
├─ GifPickerPlaceholder
|
||||
└─ GifPickerGrid
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User types → `rawSearch` state updates
|
||||
2. `rawSearch` throttled at **200ms** → fires `useKlipyAutocompleteQuery`
|
||||
3. `rawSearch` throttled at **500ms** → fires GIF search query (existing)
|
||||
4. Autocomplete response → string array rendered as suggestion list
|
||||
5. User taps/selects suggestion → fills input + triggers GIF search
|
||||
6. Suggestions hide when input matches a selected term
|
||||
|
||||
### File Changes
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/features/gifPicker/components/GifAutocompleteSuggestions.tsx` — inline
|
||||
suggestion list UI. Renders a vertical list of suggestion rows, each with a
|
||||
search icon and the suggestion text. Handles `onPress` to select a suggestion.
|
||||
On web, tracks `activeIndex` for keyboard highlight state.
|
||||
|
||||
- `src/features/gifPicker/hooks/useGifAutocomplete.ts` — orchestration hook.
|
||||
Manages the 200ms throttled value, calls `useKlipyAutocompleteQuery`,
|
||||
tracks whether suggestions should be visible (based on typing vs. selection),
|
||||
and exposes keyboard navigation state for web.
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/lib/constants.ts` — add `GIF_KLIPY_AUTOCOMPLETE` endpoint constant.
|
||||
|
||||
- `src/state/queries/klipy.ts` — add `useKlipyAutocompleteQuery` hook. Uses
|
||||
`useQuery` (not infinite — no pagination). Returns `string[]`.
|
||||
|
||||
- `src/features/gifPicker/components/GifPickerHeader.tsx` — render
|
||||
`GifAutocompleteSuggestions` below the search input. Pass down the
|
||||
autocomplete state and selection callback.
|
||||
|
||||
- `src/features/gifPicker/GifPickerDialog.tsx` — wire up `useGifAutocomplete`
|
||||
hook. Manage the interaction between autocomplete selection and the existing
|
||||
search state (selecting a suggestion sets `rawSearch` to the suggestion text).
|
||||
|
||||
## Interaction States
|
||||
|
||||
### 1. Idle — no suggestions
|
||||
|
||||
Search field is empty or unfocused. Featured GIFs show in the grid. No
|
||||
suggestion list rendered.
|
||||
|
||||
### 2. Typing — suggestions appear
|
||||
|
||||
User has typed 1+ characters. Suggestion list appears between the search input
|
||||
and the GIF grid, pushing the grid down. Suggestions update as the user types
|
||||
(200ms throttle). On web, the first suggestion is highlighted by default.
|
||||
|
||||
### 3. Selected — GIFs load
|
||||
|
||||
User taps a suggestion (or presses Enter on web). The suggestion text fills the
|
||||
search input. Suggestions hide. The 500ms search throttle fires with the
|
||||
selected term and GIF results populate the grid.
|
||||
|
||||
### 4. Editing after selection
|
||||
|
||||
User modifies the input after a selection (e.g., backspace). Suggestions
|
||||
re-appear with updated results for the new partial query.
|
||||
|
||||
## Keyboard Navigation (Web Only)
|
||||
|
||||
Handled via an `onKeyDown` listener on the search `TextInput`:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| ArrowDown | Move active highlight to next suggestion |
|
||||
| ArrowUp | Move active highlight to previous suggestion |
|
||||
| Enter | Select the active suggestion (fill input, fire search) |
|
||||
| Escape | Dismiss suggestions (then closes dialog on second press) |
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Search input: `role="combobox"`, `aria-controls="<listbox-id>"`,
|
||||
`aria-expanded`, `aria-autocomplete="list"`,
|
||||
`aria-activedescendant="<active-item-id>"`
|
||||
- Suggestion list: `role="listbox"`, `id="<listbox-id>"`
|
||||
- Each suggestion: `role="option"`, `aria-selected`, `id="<item-id>"`
|
||||
|
||||
## Visibility Rules
|
||||
|
||||
```
|
||||
SHOW when: rawSearch.length >= 1 AND suggestions.length > 0 AND not just selected
|
||||
HIDE when: rawSearch is empty OR user selected a suggestion OR user clears input
|
||||
RE-SHOW when: user edits the input after a selection (e.g., backspace)
|
||||
```
|
||||
|
||||
The "not just selected" flag prevents suggestions from flickering when the
|
||||
selected term is written into the input (which would otherwise trigger a new
|
||||
autocomplete query matching the full term).
|
||||
|
||||
## Query Hook Design
|
||||
|
||||
```ts
|
||||
// src/state/queries/klipy.ts
|
||||
|
||||
export const RQKEY_AUTOCOMPLETE = (query: string) =>
|
||||
[RQKEY_ROOT, 'autocomplete', query]
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Uses `useQuery` (not `useInfiniteQuery`) since there's no pagination. Results
|
||||
are cached with a long stale time — autocomplete suggestions for a given prefix
|
||||
don't change frequently.
|
||||
|
||||
The fetch function reuses the same `client_key`/`locale`/`contentfilter`
|
||||
parameter pattern as the existing `createKlipyApi` helper.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- Klipy autocomplete endpoint integration
|
||||
- Inline suggestion list in GifPickerHeader
|
||||
- 200ms throttle for autocomplete, independent of 500ms search throttle
|
||||
- Keyboard navigation on web (arrow keys, enter, escape)
|
||||
- ARIA accessibility attributes
|
||||
- Tap-to-select on native
|
||||
|
||||
**Out of scope:**
|
||||
- Tenor autocomplete (Klipy only)
|
||||
- Trending/popular suggestions on empty input
|
||||
- Search history / recents
|
||||
- Categories or tag browsing
|
||||
@@ -1,125 +0,0 @@
|
||||
# GIF Category Pills Design
|
||||
|
||||
Horizontal scrollable row of category pills on the GIF picker's idle screen,
|
||||
letting users browse GIFs by emotion/reaction category.
|
||||
|
||||
## Context
|
||||
|
||||
The GIF picker currently shows featured/trending GIFs on the idle screen with a
|
||||
search input above. Users can type to search, but there's no way to browse by
|
||||
category. Threads shows a row of category pills (Trending, Love, Happy, Sad,
|
||||
etc.) that give quick access to common GIF reactions without typing.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| Layout | Horizontal scrollable pill row + featured grid below | Matches Threads pattern. Pills provide quick access, grid fills remaining space. |
|
||||
| Categories | Hardcoded curated list of 8 | Predictable, no extra API call, intentional selection of popular emotions. |
|
||||
| "Trending" pill | Uses existing featured endpoint | No search query needed — it's the default idle view. |
|
||||
| Category tap behavior | Sets search query to category's searchterm | Reuses existing `useGifPickerData` search path, no new data fetching logic. |
|
||||
| Pills while typing | Hide when input has text, reappear when cleared | Clean separation between browse and search modes. |
|
||||
|
||||
## Curated Categories
|
||||
|
||||
| Pill | Search term | Emoji |
|
||||
|------|------------|-------|
|
||||
| Trending | *(featured endpoint, no search)* | 🔥 |
|
||||
| Love | `love` | ❤️ |
|
||||
| Happy | `happy` | 😄 |
|
||||
| Sad | `cry` | 😢 |
|
||||
| Party | `congratulations` | 🎉 |
|
||||
| Yes | `yes` | 👍 |
|
||||
| LOL | `lol` | 😂 |
|
||||
| Excited | `excited` | 🤩 |
|
||||
|
||||
"Trending" is the default active pill when the dialog opens.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Tree
|
||||
|
||||
```
|
||||
GifPickerBody
|
||||
├─ GifPickerHeader
|
||||
│ └─ TextField.Input
|
||||
├─ GifCategoryPills ← NEW (hidden when typing)
|
||||
├─ GifPickerPlaceholder
|
||||
└─ GifPickerGrid
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Dialog opens → "Trending" pill active → featured endpoint fires (existing)
|
||||
2. User taps a category pill → active pill updates → search query set to
|
||||
pill's `searchterm` → `useGifPickerData` fires the search endpoint
|
||||
3. User starts typing → pills hide → search query comes from text input
|
||||
4. User clears input → pills reappear → active pill resets to "Trending" →
|
||||
featured GIFs reload
|
||||
|
||||
### File Changes
|
||||
|
||||
**New files:**
|
||||
|
||||
- `src/features/gifPicker/components/GifCategoryPills.tsx` — horizontal
|
||||
scrollable row of pill buttons. Accepts `activeCategory`, `onSelectCategory`,
|
||||
and `visible` props. Each pill is a `Pressable` with emoji + label. The
|
||||
active pill gets a highlighted background. The component renders `null` when
|
||||
`visible` is false.
|
||||
|
||||
**Modified files:**
|
||||
|
||||
- `src/features/gifPicker/GifPickerDialog.tsx` — add `activeCategory` state to
|
||||
`GifPickerBody`. When a category is selected (and it's not "trending"), pass
|
||||
its searchterm as the search query to `useGifPickerData`. Render
|
||||
`GifCategoryPills` in the header area. Hide pills when `rawSearch.length > 0`.
|
||||
Reset `activeCategory` to "trending" when input is cleared.
|
||||
|
||||
## Interaction States
|
||||
|
||||
### 1. Idle — "Trending" active
|
||||
|
||||
Dialog just opened. "Trending" pill is highlighted. Featured GIFs show in the
|
||||
grid. Search input is empty.
|
||||
|
||||
### 2. Category selected
|
||||
|
||||
User tapped a category pill (e.g., "Happy"). That pill highlights, "Trending"
|
||||
unhighlights. Grid shows search results for "happy". Search input stays empty
|
||||
— the query comes from the pill, not the text field.
|
||||
|
||||
### 3. Typing — pills hidden
|
||||
|
||||
User started typing in the search input. The pill row hides entirely. Grid
|
||||
shows search results for whatever the user typed. The `activeCategory` state
|
||||
stays as-is (not reset) — it's just not visible or used while typing. This
|
||||
avoids unnecessary state churn; it only resets when the input is fully cleared.
|
||||
|
||||
### 4. Input cleared — pills return
|
||||
|
||||
User cleared the search input (backspace or clear button). Pills reappear with
|
||||
"Trending" active. Featured GIFs reload in the grid.
|
||||
|
||||
## Pill Styling
|
||||
|
||||
- Horizontal `ScrollView` with `horizontal` and `showsHorizontalScrollIndicator={false}`
|
||||
- Each pill: `Pressable` with `rounded_full`, emoji + text, theme-aware colors
|
||||
- Active pill: stronger background (`t.atoms.bg_contrast_100` or similar)
|
||||
- Inactive pill: subtle border (`t.atoms.border_contrast_low`)
|
||||
- Row has horizontal padding matching the GIF grid
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- `GifCategoryPills` component with horizontal scroll
|
||||
- 8 curated category pills with emoji + label
|
||||
- Tap pill → search GIFs for that category
|
||||
- Pills hide when typing, reappear when cleared
|
||||
- Active pill highlight styling
|
||||
- "Trending" as default active pill (uses featured endpoint)
|
||||
|
||||
**Out of scope:**
|
||||
- Recent / Favorited pills (needs client-side persistence)
|
||||
- Dynamic categories from the `/v2/categories` API
|
||||
- Category GIF thumbnails on the pills
|
||||
- Animation for pills show/hide transition
|
||||
Reference in New Issue
Block a user