remove giant superpowers files

This commit is contained in:
vineyardbovines
2026-04-15 14:24:14 -04:00
parent 09412c1c91
commit b1fded493f
4 changed files with 0 additions and 1367 deletions
@@ -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