wip autocomplete
This commit is contained in:
@@ -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">🔍</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">🔍</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">🔍</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">🔍</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">🔍</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">🔍</span>
|
||||||
|
<span>happy birthday</span>
|
||||||
|
<span style="margin-left:auto;opacity:0.4;font-size:11px">✕</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">🔍</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">🔍</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">🔍</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">🔍</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">🔍</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">🔍</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">🔍</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">🔍</span> <span>happy birthday</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:8px 12px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="opacity:0.4">🔍</span> <span>happy</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:8px 12px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="opacity:0.4">🔍</span> <span>happy easter</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:8px 12px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="opacity:0.4">🔍</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
|
||||||
Reference in New Issue
Block a user