dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

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

add typeahead

Luna a262395b 926cf05c

+195 -10
+67
frontend/src/App.css
··· 501 501 text-align: left; 502 502 } 503 503 } 504 + 505 + /* Typeahead */ 506 + .handle-input-wrapper { 507 + position: relative; 508 + flex: 1; 509 + } 510 + 511 + .handle-input-wrapper input { 512 + width: 100%; 513 + box-sizing: border-box; 514 + } 515 + 516 + .typeahead-dropdown { 517 + position: absolute; 518 + top: 100%; 519 + left: 0; 520 + right: 0; 521 + margin: 0; 522 + padding: 0; 523 + list-style: none; 524 + background: var(--bg); 525 + border: 1px solid var(--border); 526 + border-top: none; 527 + z-index: 10; 528 + max-height: 280px; 529 + overflow-y: auto; 530 + } 531 + 532 + .typeahead-dropdown li { 533 + display: flex; 534 + align-items: center; 535 + gap: 0.5rem; 536 + padding: 0.4rem 0.6rem; 537 + cursor: pointer; 538 + } 539 + 540 + .typeahead-dropdown li.typeahead-active { 541 + background: var(--border); 542 + } 543 + 544 + .typeahead-avatar { 545 + width: 24px; 546 + height: 24px; 547 + border-radius: 50%; 548 + flex-shrink: 0; 549 + } 550 + 551 + .typeahead-info { 552 + display: flex; 553 + flex-direction: column; 554 + min-width: 0; 555 + } 556 + 557 + .typeahead-name { 558 + font-size: 0.85rem; 559 + white-space: nowrap; 560 + overflow: hidden; 561 + text-overflow: ellipsis; 562 + } 563 + 564 + .typeahead-handle { 565 + font-size: 0.75rem; 566 + opacity: 0.6; 567 + white-space: nowrap; 568 + overflow: hidden; 569 + text-overflow: ellipsis; 570 + }
+7 -10
frontend/src/App.tsx
··· 18 18 type CheckResult, 19 19 type VouchEntry, 20 20 } from "./api"; 21 + import { HandleInput } from "./HandleInput"; 21 22 22 23 initOAuth(); 23 24 ··· 282 283 <h2>Create vouch</h2> 283 284 <form onSubmit={onSubmit}> 284 285 <div className="field"> 285 - <input 286 - type="text" 287 - placeholder="handle to vouch for" 286 + <HandleInput 288 287 value={handle} 289 - className={handle.includes("@") ? "input-invalid" : ""} 290 - onChange={(e) => setHandle(e.target.value)} 288 + onChange={setHandle} 291 289 disabled={submitting} 290 + placeholder="handle to vouch for" 292 291 /> 293 292 <button type="submit" disabled={submitting || !handle.trim() || handle.includes("@")}> 294 293 {submitting ? "Creating..." : "Vouch"} ··· 332 331 <h2>Check vouch paths</h2> 333 332 <form onSubmit={onSubmit}> 334 333 <div className="field"> 335 - <input 336 - type="text" 337 - placeholder="handle to check" 334 + <HandleInput 338 335 value={handle} 339 - className={handle.includes("@") ? "input-invalid" : ""} 340 - onChange={(e) => setHandle(e.target.value)} 336 + onChange={setHandle} 341 337 disabled={loading} 338 + placeholder="handle to check" 342 339 /> 343 340 <button type="submit" disabled={loading || !handle.trim() || handle.includes("@")}> 344 341 {loading ? "Checking..." : "Check"}
+105
frontend/src/HandleInput.tsx
··· 1 + import { useState, useRef, useEffect, useCallback } from "react"; 2 + import { searchActorsTypeahead, type TypeaheadActor } from "./api"; 3 + 4 + export function HandleInput({ 5 + value, 6 + onChange, 7 + disabled, 8 + placeholder, 9 + }: { 10 + value: string; 11 + onChange: (value: string) => void; 12 + disabled?: boolean; 13 + placeholder?: string; 14 + }) { 15 + const [suggestions, setSuggestions] = useState<TypeaheadActor[]>([]); 16 + const [showDropdown, setShowDropdown] = useState(false); 17 + const [activeIndex, setActiveIndex] = useState(-1); 18 + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 19 + const containerRef = useRef<HTMLDivElement>(null); 20 + 21 + const doSearch = useCallback(async (q: string) => { 22 + const results = await searchActorsTypeahead(q); 23 + setSuggestions(results); 24 + setShowDropdown(results.length > 0); 25 + setActiveIndex(-1); 26 + }, []); 27 + 28 + const handleChange = (val: string) => { 29 + onChange(val); 30 + if (timerRef.current) clearTimeout(timerRef.current); 31 + if (val.trim().length < 2 || val.includes("@")) { 32 + setSuggestions([]); 33 + setShowDropdown(false); 34 + return; 35 + } 36 + timerRef.current = setTimeout(() => doSearch(val.trim()), 200); 37 + }; 38 + 39 + const selectActor = (actor: TypeaheadActor) => { 40 + onChange(actor.handle); 41 + setShowDropdown(false); 42 + setSuggestions([]); 43 + }; 44 + 45 + const handleKeyDown = (e: React.KeyboardEvent) => { 46 + if (!showDropdown || suggestions.length === 0) return; 47 + if (e.key === "ArrowDown") { 48 + e.preventDefault(); 49 + setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1)); 50 + } else if (e.key === "ArrowUp") { 51 + e.preventDefault(); 52 + setActiveIndex((i) => Math.max(i - 1, 0)); 53 + } else if (e.key === "Enter" && activeIndex >= 0) { 54 + e.preventDefault(); 55 + selectActor(suggestions[activeIndex]); 56 + } else if (e.key === "Escape") { 57 + setShowDropdown(false); 58 + } 59 + }; 60 + 61 + // Close dropdown on outside click 62 + useEffect(() => { 63 + const handler = (e: MouseEvent) => { 64 + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { 65 + setShowDropdown(false); 66 + } 67 + }; 68 + document.addEventListener("mousedown", handler); 69 + return () => document.removeEventListener("mousedown", handler); 70 + }, []); 71 + 72 + return ( 73 + <div className="handle-input-wrapper" ref={containerRef}> 74 + <input 75 + type="text" 76 + placeholder={placeholder ?? "handle (e.g. alice.bsky.social)"} 77 + value={value} 78 + className={value.includes("@") ? "input-invalid" : ""} 79 + onChange={(e) => handleChange(e.target.value)} 80 + onKeyDown={handleKeyDown} 81 + onFocus={() => { if (suggestions.length > 0) setShowDropdown(true); }} 82 + disabled={disabled} 83 + autoComplete="off" 84 + /> 85 + {showDropdown && ( 86 + <ul className="typeahead-dropdown"> 87 + {suggestions.map((actor, i) => ( 88 + <li 89 + key={actor.did} 90 + className={i === activeIndex ? "typeahead-active" : ""} 91 + onMouseDown={() => selectActor(actor)} 92 + onMouseEnter={() => setActiveIndex(i)} 93 + > 94 + {actor.avatar && <img className="typeahead-avatar" src={actor.avatar} alt="" />} 95 + <span className="typeahead-info"> 96 + {actor.displayName && <span className="typeahead-name">{actor.displayName}</span>} 97 + <span className="typeahead-handle">{actor.handle}</span> 98 + </span> 99 + </li> 100 + ))} 101 + </ul> 102 + )} 103 + </div> 104 + ); 105 + }
+16
frontend/src/api.ts
··· 6 6 // Microcosm: reverse graph queries 7 7 const MICROCOSM = "https://constellation.microcosm.blue"; 8 8 9 + export interface TypeaheadActor { 10 + did: string; 11 + handle: string; 12 + displayName?: string; 13 + avatar?: string; 14 + } 15 + 16 + export async function searchActorsTypeahead(query: string, limit = 8): Promise<TypeaheadActor[]> { 17 + if (!query || query.length < 2) return []; 18 + const params = new URLSearchParams({ q: query, limit: String(limit) }); 19 + const resp = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?${params}`); 20 + if (!resp.ok) return []; 21 + const data: { actors: TypeaheadActor[] } = await resp.json(); 22 + return data.actors; 23 + } 24 + 9 25 export async function resolveHandle(handle: string): Promise<string> { 10 26 const url = `${SLINGSHOT}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 11 27 const resp = await fetch(url);