wip autocomplete

This commit is contained in:
vineyardbovines
2026-04-14 14:24:02 -04:00
parent 127d214de5
commit 794ffd474a
8 changed files with 1259 additions and 0 deletions
@@ -0,0 +1,173 @@
<h2>GIF Autocomplete — Full Design</h2>
<p class="subtitle">Inline search suggestions powered by Klipy's autocomplete endpoint</p>
<div class="section">
<h3>Architecture Overview</h3>
<div class="mockup">
<div class="mockup-header">Component & Data Flow</div>
<div class="mockup-body" style="padding:24px;font-family:system-ui;font-size:14px;line-height:1.6">
<div style="display:flex;gap:32px;flex-wrap:wrap">
<!-- Left: component tree -->
<div style="flex:1;min-width:300px">
<div style="font-weight:600;margin-bottom:12px;color:#7c7cff;text-transform:uppercase;font-size:11px;letter-spacing:1px">Component Tree</div>
<div style="font-family:monospace;font-size:13px;line-height:2">
<div>GifPickerDialog</div>
<div style="padding-left:20px;opacity:0.6">└─ GifPickerBody</div>
<div style="padding-left:40px">├─ <span style="color:#7c7cff">GifPickerHeader</span></div>
<div style="padding-left:60px">│ ├─ TextField.Input <span style="opacity:0.5">(search box)</span></div>
<div style="padding-left:60px">│ └─ <span style="color:#5cdb5c;font-weight:600">GifAutocompleteSuggestions</span> <span style="color:#5cdb5c;opacity:0.7">← NEW</span></div>
<div style="padding-left:40px">├─ GifPickerPlaceholder</div>
<div style="padding-left:40px">└─ GifPickerGrid</div>
</div>
</div>
<!-- Right: data flow -->
<div style="flex:1;min-width:300px">
<div style="font-weight:600;margin-bottom:12px;color:#ff7c7c;text-transform:uppercase;font-size:11px;letter-spacing:1px">Data Flow</div>
<div style="font-family:monospace;font-size:13px;line-height:2">
<div><span style="opacity:0.5">1.</span> User types → rawSearch state</div>
<div><span style="opacity:0.5">2.</span> rawSearch throttled @ <span style="color:#5cdb5c">200ms</span> → autocomplete query</div>
<div><span style="opacity:0.5">3.</span> rawSearch throttled @ 500ms → GIF search query</div>
<div><span style="opacity:0.5">4.</span> Autocomplete response → suggestion strings</div>
<div><span style="opacity:0.5">5.</span> User taps suggestion → fills input + fires search</div>
<div><span style="opacity:0.5">6.</span> Suggestions hide when input matches a selected term</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>New Files</h3>
<div style="font-family:monospace;font-size:13px;line-height:2.2;padding:0 8px">
<div><span style="color:#5cdb5c">+</span> <strong>src/lib/constants.ts</strong> — add <code>GIF_KLIPY_AUTOCOMPLETE</code> endpoint</div>
<div><span style="color:#5cdb5c">+</span> <strong>src/state/queries/klipy.ts</strong> — add <code>useKlipyAutocompleteQuery</code> hook</div>
<div><span style="color:#5cdb5c">+</span> <strong>src/features/gifPicker/hooks/useGifAutocomplete.ts</strong> — orchestration hook (throttle + query + keyboard state)</div>
<div><span style="color:#5cdb5c">+</span> <strong>src/features/gifPicker/components/GifAutocompleteSuggestions.tsx</strong> — inline suggestion list UI</div>
<div><span style="color:#ffcc00">~</span> <strong>src/features/gifPicker/components/GifPickerHeader.tsx</strong> — render suggestions below input</div>
<div><span style="color:#ffcc00">~</span> <strong>src/features/gifPicker/GifPickerDialog.tsx</strong> — wire up autocomplete state</div>
</div>
</div>
<div class="section">
<h3>Interaction Flow</h3>
<div style="display:flex;gap:16px;flex-wrap:wrap">
<!-- State 1: Idle -->
<div style="flex:1;min-width:220px">
<div class="mockup">
<div class="mockup-header">1. Idle — no suggestions</div>
<div class="mockup-body" style="padding:12px;font-family:system-ui;background:#1a1a2e;color:#e0e0e0;font-size:13px">
<div style="background:#2a2a4a;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="opacity:0.4">&#128269;</span>
<span style="opacity:0.4">Search GIFs</span>
</div>
<div style="display:flex;gap:6px">
<div style="flex:1;background:#333;border-radius:6px;height:50px;opacity:0.4"></div>
<div style="flex:1;background:#333;border-radius:6px;height:40px;opacity:0.4"></div>
</div>
<div style="text-align:center;margin-top:8px;font-size:11px;opacity:0.5">Featured GIFs</div>
</div>
</div>
</div>
<!-- State 2: Typing, suggestions visible -->
<div style="flex:1;min-width:220px">
<div class="mockup">
<div class="mockup-header">2. Typing — suggestions appear</div>
<div class="mockup-body" style="padding:12px;font-family:system-ui;background:#1a1a2e;color:#e0e0e0;font-size:13px">
<div style="background:#2a2a4a;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="opacity:0.4">&#128269;</span>
<span>hap<span style="opacity:0.3">|</span></span>
</div>
<div style="background:#22223a;border-radius:6px;margin-bottom:8px;overflow:hidden;border:1px solid #333">
<div style="padding:7px 10px;background:#2a2a5a;display:flex;align-items:center;gap:6px;font-size:12px">
<span style="opacity:0.3">&#128269;</span> happy birthday
</div>
<div style="padding:7px 10px;display:flex;align-items:center;gap:6px;font-size:12px;border-top:1px solid #2a2a3a">
<span style="opacity:0.3">&#128269;</span> happy
</div>
<div style="padding:7px 10px;display:flex;align-items:center;gap:6px;font-size:12px;border-top:1px solid #2a2a3a">
<span style="opacity:0.3">&#128269;</span> happy easter
</div>
</div>
<div style="display:flex;gap:6px;opacity:0.3">
<div style="flex:1;background:#333;border-radius:6px;height:30px"></div>
<div style="flex:1;background:#333;border-radius:6px;height:30px"></div>
</div>
</div>
</div>
</div>
<!-- State 3: Selected, search fires -->
<div style="flex:1;min-width:220px">
<div class="mockup">
<div class="mockup-header">3. Selected — GIFs load</div>
<div class="mockup-body" style="padding:12px;font-family:system-ui;background:#1a1a2e;color:#e0e0e0;font-size:13px">
<div style="background:#2a2a4a;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="opacity:0.4">&#128269;</span>
<span>happy birthday</span>
<span style="margin-left:auto;opacity:0.4;font-size:11px">&#10005;</span>
</div>
<div style="display:flex;gap:6px">
<div style="flex:1;display:flex;flex-direction:column;gap:6px">
<div style="background:#444;border-radius:6px;height:50px"></div>
<div style="background:#444;border-radius:6px;height:40px"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:6px">
<div style="background:#444;border-radius:6px;height:40px"></div>
<div style="background:#444;border-radius:6px;height:50px"></div>
</div>
</div>
<div style="text-align:center;margin-top:8px;font-size:11px;opacity:0.5">Results for "happy birthday"</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Keyboard Navigation (Web)</h3>
<div style="font-family:monospace;font-size:13px;line-height:2;padding:0 8px">
<div><strong>ArrowDown / ArrowUp</strong> — move active highlight through suggestions</div>
<div><strong>Enter</strong> — select highlighted suggestion (fills input, fires search)</div>
<div><strong>Escape</strong> — dismiss suggestions (existing behavior closes dialog)</div>
<div><strong>Continue typing</strong> — resets active index to 0, re-fetches suggestions</div>
</div>
<p style="margin-top:8px;font-size:13px;opacity:0.7">
ARIA: input gets <code>role="combobox"</code> + <code>aria-controls</code>, list gets <code>role="listbox"</code>,
items get <code>role="option"</code> + <code>aria-selected</code>. ~30 lines of <code>onKeyDown</code> handling on web only.
</p>
</div>
<div class="section">
<h3>Visibility Rules</h3>
<div style="font-family:monospace;font-size:13px;line-height:2;padding:0 8px">
<div><span style="color:#5cdb5c">SHOW</span> when: rawSearch.length ≥ 1 AND suggestions.length > 0 AND user hasn't just selected a suggestion</div>
<div><span style="color:#ff7c7c">HIDE</span> when: rawSearch is empty OR user selected a suggestion OR user clears input</div>
<div><span style="color:#ffcc00">RE-SHOW</span> when: user edits the input after a selection (e.g., backspace to modify)</div>
</div>
</div>
<div class="section">
<h3>Scope Boundaries</h3>
<div class="pros-cons">
<div class="pros">
<h4>In Scope</h4>
<ul>
<li>Klipy autocomplete endpoint integration</li>
<li>Inline suggestion list in GifPickerHeader</li>
<li>200ms throttle for autocomplete, independent of 500ms search throttle</li>
<li>Keyboard navigation on web (arrow keys, enter, escape)</li>
<li>ARIA accessibility attributes</li>
<li>Tap-to-select on native</li>
</ul>
</div>
<div class="cons">
<h4>Out of Scope</h4>
<ul>
<li>Tenor autocomplete (Klipy only)</li>
<li>Trending/popular suggestions on empty input</li>
<li>Search history / recents</li>
<li>Categories or tag browsing</li>
</ul>
</div>
</div>
</div>
@@ -0,0 +1,149 @@
<h2>Where should autocomplete suggestions appear?</h2>
<p class="subtitle">As you type in the GIF search box, where do completion suggestions show up?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Inline list below the search input</h3>
<p>Suggestions render as a vertical list between the search bar and the GIF grid. Tapping a suggestion fills the search field and triggers a GIF search. Simple, no z-index or portal concerns inside the dialog.</p>
<div class="mockup" style="margin-top:12px">
<div class="mockup-header">GIF Dialog — Inline Suggestions</div>
<div class="mockup-body" style="padding:16px;font-family:system-ui;background:#1a1a2e;color:#e0e0e0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<div style="background:#2a2a4a;border-radius:8px;padding:8px 12px;flex:1;display:flex;align-items:center;gap:8px">
<span style="opacity:0.5">&#128269;</span>
<span>hap<span style="opacity:0.4;border-right:2px solid #7c7cff">|</span></span>
</div>
</div>
<div style="background:#22223a;border-radius:8px;margin-bottom:12px;overflow:hidden">
<div style="padding:10px 14px;border-bottom:1px solid #333;display:flex;align-items:center;gap:8px;cursor:pointer">
<span style="opacity:0.4">&#128269;</span> <span>happy birthday</span>
</div>
<div style="padding:10px 14px;border-bottom:1px solid #333;display:flex;align-items:center;gap:8px;cursor:pointer">
<span style="opacity:0.4">&#128269;</span> <span>happy</span>
</div>
<div style="padding:10px 14px;border-bottom:1px solid #333;display:flex;align-items:center;gap:8px;cursor:pointer">
<span style="opacity:0.4">&#128269;</span> <span>happy easter</span>
</div>
<div style="padding:10px 14px;display:flex;align-items:center;gap:8px;cursor:pointer">
<span style="opacity:0.4">&#128269;</span> <span>happy dance</span>
</div>
</div>
<div style="display:flex;gap:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div style="background:#333;border-radius:8px;height:80px;opacity:0.3"></div>
<div style="background:#333;border-radius:8px;height:60px;opacity:0.3"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div style="background:#333;border-radius:8px;height:60px;opacity:0.3"></div>
<div style="background:#333;border-radius:8px;height:80px;opacity:0.3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Horizontal chips below the search input</h3>
<p>Suggestions render as a scrollable row of pill/chip buttons. Compact, leaves more vertical space for the GIF grid. Common in mobile search UIs (YouTube, Google Images).</p>
<div class="mockup" style="margin-top:12px">
<div class="mockup-header">GIF Dialog — Chip Suggestions</div>
<div class="mockup-body" style="padding:16px;font-family:system-ui;background:#1a1a2e;color:#e0e0e0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<div style="background:#2a2a4a;border-radius:8px;padding:8px 12px;flex:1;display:flex;align-items:center;gap:8px">
<span style="opacity:0.5">&#128269;</span>
<span>hap<span style="opacity:0.4;border-right:2px solid #7c7cff">|</span></span>
</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;overflow-x:auto;padding-bottom:4px">
<div style="background:#2a2a4a;border:1px solid #444;border-radius:20px;padding:6px 14px;white-space:nowrap;font-size:13px;cursor:pointer">happy birthday</div>
<div style="background:#2a2a4a;border:1px solid #444;border-radius:20px;padding:6px 14px;white-space:nowrap;font-size:13px;cursor:pointer">happy</div>
<div style="background:#2a2a4a;border:1px solid #444;border-radius:20px;padding:6px 14px;white-space:nowrap;font-size:13px;cursor:pointer">happy easter</div>
<div style="background:#2a2a4a;border:1px solid #444;border-radius:20px;padding:6px 14px;white-space:nowrap;font-size:13px;cursor:pointer">happy dance</div>
</div>
<div style="display:flex;gap:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div style="background:#333;border-radius:8px;height:90px;opacity:0.3"></div>
<div style="background:#333;border-radius:8px;height:70px;opacity:0.3"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div style="background:#333;border-radius:8px;height:70px;opacity:0.3"></div>
<div style="background:#333;border-radius:8px;height:90px;opacity:0.3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>Sift popover dropdown (like Composer autocomplete)</h3>
<p>Uses the <code>@bsky.app/sift</code> library to show a positioned popover below the input, matching how @mentions work in the Composer. Keyboard navigable on web. Needs Portal/z-index verification inside the dialog.</p>
<div class="mockup" style="margin-top:12px">
<div class="mockup-header">GIF Dialog — Sift Popover</div>
<div class="mockup-body" style="padding:16px;font-family:system-ui;background:#1a1a2e;color:#e0e0e0">
<div style="position:relative">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<div style="background:#2a2a4a;border-radius:8px;padding:8px 12px;flex:1;display:flex;align-items:center;gap:8px">
<span style="opacity:0.5">&#128269;</span>
<span>hap<span style="opacity:0.4;border-right:2px solid #7c7cff">|</span></span>
</div>
</div>
<div style="position:absolute;top:40px;left:0;right:0;background:#22223a;border:1px solid #444;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.4);z-index:10;overflow:hidden">
<div style="padding:8px 12px;background:#3a3a5a;display:flex;align-items:center;gap:8px">
<span style="opacity:0.4">&#128269;</span> <span>happy birthday</span>
</div>
<div style="padding:8px 12px;display:flex;align-items:center;gap:8px">
<span style="opacity:0.4">&#128269;</span> <span>happy</span>
</div>
<div style="padding:8px 12px;display:flex;align-items:center;gap:8px">
<span style="opacity:0.4">&#128269;</span> <span>happy easter</span>
</div>
<div style="padding:8px 12px;display:flex;align-items:center;gap:8px">
<span style="opacity:0.4">&#128269;</span> <span>happy dance</span>
</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px;filter:blur(1px);opacity:0.5">
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div style="background:#333;border-radius:8px;height:90px"></div>
<div style="background:#333;border-radius:8px;height:70px"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div style="background:#333;border-radius:8px;height:70px"></div>
<div style="background:#333;border-radius:8px;height:90px"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:24px">
<h3>My recommendation: Option A (inline list)</h3>
<div class="pros-cons">
<div class="pros">
<h4>Why A</h4>
<ul>
<li>No Portal/z-index issues inside the bottom sheet (native) or modal (web)</li>
<li>Naturally fits the reserved slot in GifPickerHeader ("future: tabs render here")</li>
<li>Clear visual hierarchy — suggestions push the grid down, making them prominent</li>
<li>Simple to implement — just a conditional list render</li>
</ul>
</div>
<div class="cons">
<h4>Tradeoffs</h4>
<ul>
<li>Takes more vertical space than chips (B) when visible</li>
<li>Doesn't reuse Sift (C), but that's OK — Sift solves cursor-anchored popovers which we don't need here</li>
</ul>
</div>
</div>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776191006962}
@@ -0,0 +1,5 @@
{"type":"server-started","port":49342,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:49342","screen_dir":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content","state_dir":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/state"}
{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content/suggestion-placement.html"}
{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content/waiting.html"}
{"type":"screen-added","file":"/Users/pope/dev/bsky/social-app/.superpowers/brainstorm/86663-1776187526/content/design-overview.html"}
{"type":"server-stopped","reason":"idle timeout"}
@@ -0,0 +1 @@
86672
@@ -0,0 +1,735 @@
# 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"
```
@@ -0,0 +1,192 @@
# 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