Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add handle typeahead search for AT Proto sign-in

Search Bluesky's public typeahead API as the user types their handle.
Shows avatar, display name, and handle in a dropdown. Debounced at
250ms with abort controller for in-flight requests. Keyboard
navigation (arrow keys, enter, escape) and mouse selection supported.

+207 -1
+57
src/css/app.css
··· 1696 1696 border-color: var(--color-accent); 1697 1697 } 1698 1698 1699 + /* Handle typeahead suggestions */ 1700 + .handle-suggestions { 1701 + position: absolute; 1702 + left: 0; 1703 + right: 0; 1704 + top: 100%; 1705 + z-index: 1000; 1706 + background: var(--color-surface); 1707 + border: 1px solid var(--color-border); 1708 + border-radius: var(--radius-md); 1709 + margin-top: 4px; 1710 + max-height: 280px; 1711 + overflow-y: auto; 1712 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); 1713 + } 1714 + .handle-suggestion { 1715 + display: flex; 1716 + align-items: center; 1717 + gap: var(--space-sm); 1718 + padding: var(--space-sm) var(--space-md); 1719 + cursor: pointer; 1720 + transition: background 0.1s; 1721 + } 1722 + .handle-suggestion:hover, 1723 + .handle-suggestion.active { 1724 + background: var(--color-hover); 1725 + } 1726 + .suggestion-avatar { 1727 + width: 32px; 1728 + height: 32px; 1729 + border-radius: 50%; 1730 + flex-shrink: 0; 1731 + object-fit: cover; 1732 + } 1733 + .suggestion-avatar-placeholder { 1734 + background: var(--color-border); 1735 + } 1736 + .suggestion-text { 1737 + display: flex; 1738 + flex-direction: column; 1739 + min-width: 0; 1740 + } 1741 + .suggestion-name { 1742 + font-weight: 500; 1743 + font-size: 0.9rem; 1744 + overflow: hidden; 1745 + text-overflow: ellipsis; 1746 + white-space: nowrap; 1747 + } 1748 + .suggestion-handle { 1749 + font-size: 0.8rem; 1750 + color: var(--color-text-secondary); 1751 + overflow: hidden; 1752 + text-overflow: ellipsis; 1753 + white-space: nowrap; 1754 + } 1755 + 1699 1756 .username-modal-actions, 1700 1757 .folder-modal-actions, 1701 1758 .move-modal-actions {
+109 -1
src/landing-events-identity.ts
··· 1 1 /** 2 - * AT Proto identity: sign-in state, badge display, sign-out handler. 2 + * AT Proto identity: sign-in state, badge display, sign-out handler, 3 + * handle typeahead search. 3 4 */ 4 5 5 6 import type { EventDeps } from './landing-events.js'; 6 7 import { initAuth, signIn, getSession, onSessionChange } from './lib/auth.js'; 8 + import { debouncedSearch, type HandleSuggestion } from './lib/handle-search.js'; 7 9 8 10 function showUserBadge(deps: EventDeps, name: string, avatar?: string | null): void { 9 11 if (avatar) { ··· 15 17 deps.userBadge.style.display = ''; 16 18 } 17 19 20 + function escapeHtml(s: string): string { 21 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 22 + } 23 + 24 + function createSuggestionsDropdown(input: HTMLInputElement): HTMLElement { 25 + const dropdown = document.createElement('div'); 26 + dropdown.className = 'handle-suggestions'; 27 + dropdown.setAttribute('role', 'listbox'); 28 + dropdown.style.display = 'none'; 29 + input.parentElement!.style.position = 'relative'; 30 + input.insertAdjacentElement('afterend', dropdown); 31 + input.setAttribute('role', 'combobox'); 32 + input.setAttribute('aria-autocomplete', 'list'); 33 + input.setAttribute('aria-expanded', 'false'); 34 + return dropdown; 35 + } 36 + 37 + function renderSuggestions(dropdown: HTMLElement, input: HTMLInputElement, results: HandleSuggestion[]): void { 38 + if (results.length === 0) { 39 + dropdown.style.display = 'none'; 40 + input.setAttribute('aria-expanded', 'false'); 41 + return; 42 + } 43 + 44 + dropdown.innerHTML = results.map((r, i) => ` 45 + <div class="handle-suggestion" role="option" data-index="${i}" data-handle="${escapeHtml(r.handle)}"> 46 + ${r.avatar ? `<img src="${escapeHtml(r.avatar)}" alt="" class="suggestion-avatar" />` : '<div class="suggestion-avatar suggestion-avatar-placeholder"></div>'} 47 + <div class="suggestion-text"> 48 + <span class="suggestion-name">${escapeHtml(r.displayName)}</span> 49 + <span class="suggestion-handle">@${escapeHtml(r.handle)}</span> 50 + </div> 51 + </div> 52 + `).join(''); 53 + 54 + dropdown.style.display = ''; 55 + input.setAttribute('aria-expanded', 'true'); 56 + } 57 + 58 + function attachTypeahead(input: HTMLInputElement): void { 59 + const dropdown = createSuggestionsDropdown(input); 60 + let activeIndex = -1; 61 + let currentResults: HandleSuggestion[] = []; 62 + 63 + function selectSuggestion(handle: string): void { 64 + input.value = handle; 65 + dropdown.style.display = 'none'; 66 + input.setAttribute('aria-expanded', 'false'); 67 + currentResults = []; 68 + activeIndex = -1; 69 + } 70 + 71 + function updateActive(): void { 72 + dropdown.querySelectorAll('.handle-suggestion').forEach((el, i) => { 73 + el.classList.toggle('active', i === activeIndex); 74 + }); 75 + } 76 + 77 + input.addEventListener('input', () => { 78 + const query = input.value.trim().replace(/^@/, ''); 79 + debouncedSearch(query, (results) => { 80 + currentResults = results; 81 + activeIndex = -1; 82 + renderSuggestions(dropdown, input, results); 83 + }); 84 + }); 85 + 86 + input.addEventListener('keydown', (e) => { 87 + if (!currentResults.length) return; 88 + 89 + if (e.key === 'ArrowDown') { 90 + e.preventDefault(); 91 + activeIndex = Math.min(activeIndex + 1, currentResults.length - 1); 92 + updateActive(); 93 + } else if (e.key === 'ArrowUp') { 94 + e.preventDefault(); 95 + activeIndex = Math.max(activeIndex - 1, 0); 96 + updateActive(); 97 + } else if (e.key === 'Enter' && activeIndex >= 0) { 98 + e.preventDefault(); 99 + e.stopPropagation(); 100 + selectSuggestion(currentResults[activeIndex].handle); 101 + } else if (e.key === 'Escape') { 102 + dropdown.style.display = 'none'; 103 + input.setAttribute('aria-expanded', 'false'); 104 + activeIndex = -1; 105 + } 106 + }); 107 + 108 + dropdown.addEventListener('mousedown', (e) => { 109 + e.preventDefault(); 110 + const target = (e.target as HTMLElement).closest('.handle-suggestion') as HTMLElement | null; 111 + if (target) { 112 + selectSuggestion(target.dataset.handle!); 113 + } 114 + }); 115 + 116 + input.addEventListener('blur', () => { 117 + setTimeout(() => { 118 + dropdown.style.display = 'none'; 119 + input.setAttribute('aria-expanded', 'false'); 120 + }, 150); 121 + }); 122 + } 123 + 18 124 export async function initUsername(deps: EventDeps): Promise<void> { 19 125 const session = getSession(); 20 126 if (session) { ··· 35 141 } 36 142 37 143 export function attachUsernameListeners(deps: EventDeps): void { 144 + attachTypeahead(deps.usernameInput); 145 + 38 146 deps.usernameConfirm.addEventListener('click', () => { 39 147 const handle = deps.usernameInput.value.trim().replace(/^@/, ''); 40 148 if (!handle) {
+41
src/lib/handle-search.ts
··· 1 + const SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead'; 2 + const DEBOUNCE_MS = 250; 3 + const MIN_QUERY_LENGTH = 2; 4 + const MAX_RESULTS = 6; 5 + 6 + export interface HandleSuggestion { 7 + handle: string; 8 + displayName: string; 9 + avatar: string | null; 10 + } 11 + 12 + let debounceTimer: ReturnType<typeof setTimeout> | null = null; 13 + let abortController: AbortController | null = null; 14 + 15 + export function searchHandles(query: string): Promise<HandleSuggestion[]> { 16 + abortController?.abort(); 17 + if (query.length < MIN_QUERY_LENGTH) return Promise.resolve([]); 18 + 19 + abortController = new AbortController(); 20 + const signal = abortController.signal; 21 + 22 + return fetch(`${SEARCH_API}?q=${encodeURIComponent(query)}&limit=${MAX_RESULTS}`, { signal }) 23 + .then(r => r.ok ? r.json() : Promise.resolve({ actors: [] })) 24 + .then(data => (data.actors || []).map((a: any) => ({ 25 + handle: a.handle, 26 + displayName: a.displayName || a.handle, 27 + avatar: a.avatar || null, 28 + }))) 29 + .catch(() => []); 30 + } 31 + 32 + export function debouncedSearch(query: string, callback: (results: HandleSuggestion[]) => void): void { 33 + if (debounceTimer) clearTimeout(debounceTimer); 34 + if (query.length < MIN_QUERY_LENGTH) { 35 + callback([]); 36 + return; 37 + } 38 + debounceTimer = setTimeout(() => { 39 + searchHandles(query).then(callback); 40 + }, DEBOUNCE_MS); 41 + }