Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

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

web: cleanup DialBBS/Login and add kb nav

+183 -86
+55 -69
web/src/components/DialBBS.tsx
··· 1 - import { useEffect, useRef, useState, type SyntheticEvent } from "react"; 1 + import { useState, type SyntheticEvent } from "react"; 2 2 import { Link, useNavigate } from "react-router-dom"; 3 3 import HandleInput from "./form/HandleInput"; 4 4 import { Button } from "./form/Form"; 5 - import { resolveIdentity, getRecord } from "../lib/atproto"; 6 - import { SITE } from "../lib/lexicon"; 5 + import { useDropdown } from "../hooks/useDropdown"; 6 + import { useResolvedBBS } from "../hooks/useResolvedBBS"; 7 7 import type { DiscoveredBBS } from "../hooks/useDiscovery"; 8 8 9 9 export interface Suggestion { ··· 17 17 suggestions?: Suggestion[]; 18 18 } 19 19 20 - const RESOLVE_DEBOUNCE_MS = 300; 20 + function buildVisibleSuggestions( 21 + staticSuggestions: Suggestion[], 22 + filterQuery: string, 23 + resolved: Suggestion | null, 24 + ): Suggestion[] { 25 + const filtered = staticSuggestions.filter( 26 + (entry) => 27 + !filterQuery || 28 + entry.name.toLowerCase().includes(filterQuery) || 29 + entry.handle.toLowerCase().includes(filterQuery), 30 + ); 31 + 32 + if (resolved && !filtered.some((entry) => entry.handle === resolved.handle)) { 33 + return [resolved, ...filtered]; 34 + } 35 + return filtered; 36 + } 21 37 22 38 export default function DialBBS({ discovered, suggestions }: DialBBSProps) { 23 39 const navigate = useNavigate(); 24 40 const [inputValue, setInputValue] = useState(""); 25 - const [focused, setFocused] = useState(false); 26 - const [resolvedSuggestion, setResolvedSuggestion] = 27 - useState<Suggestion | null>(null); 28 - const blurTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); 41 + const resolved = useResolvedBBS(inputValue); 42 + 43 + const filterQuery = inputValue.trim().toLowerCase(); 44 + const visibleSuggestions = buildVisibleSuggestions( 45 + suggestions ?? [], 46 + filterQuery, 47 + resolved, 48 + ); 49 + 50 + const dropdown = useDropdown(visibleSuggestions.length); 51 + const dropdownOpen = dropdown.focused && visibleSuggestions.length > 0; 29 52 30 53 function onSubmit(event: SyntheticEvent) { 31 54 event.preventDefault(); ··· 43 66 } 44 67 } 45 68 46 - function onFocus() { 47 - clearTimeout(blurTimeout.current); 48 - setFocused(true); 49 - } 50 - 51 - function onBlur() { 52 - blurTimeout.current = setTimeout(() => setFocused(false), 150); 53 - } 54 - 55 - useEffect(() => { 56 - const query = inputValue.trim(); 57 - if (!query || !query.includes(".")) { 58 - setResolvedSuggestion(null); 59 - return; 60 - } 61 - 62 - let cancelled = false; 63 - const timeout = setTimeout(async () => { 64 - try { 65 - const identity = await resolveIdentity(query); 66 - const siteRecord = await getRecord(identity.did, SITE, "self"); 67 - const siteValue = siteRecord.value as { name?: string }; 68 - if (!cancelled) { 69 - setResolvedSuggestion({ 70 - to: `/bbs/${encodeURIComponent(identity.handle)}`, 71 - name: siteValue.name ?? identity.handle, 72 - handle: identity.handle, 73 - }); 74 - } 75 - } catch { 76 - if (!cancelled) setResolvedSuggestion(null); 77 - } 78 - }, RESOLVE_DEBOUNCE_MS); 79 - 80 - return () => { 81 - cancelled = true; 82 - clearTimeout(timeout); 83 - }; 84 - }, [inputValue]); 85 - 86 - const filterQuery = inputValue.trim().toLowerCase(); 87 - const staticMatches = (suggestions ?? []).filter( 88 - (entry) => 89 - !filterQuery || 90 - entry.name.toLowerCase().includes(filterQuery) || 91 - entry.handle.toLowerCase().includes(filterQuery), 92 - ); 93 - 94 - const visibleSuggestions = 95 - resolvedSuggestion && 96 - !staticMatches.some( 97 - (entry) => entry.handle === resolvedSuggestion.handle, 98 - ) 99 - ? [resolvedSuggestion, ...staticMatches] 100 - : staticMatches; 101 - 102 69 return ( 103 - <div onFocus={onFocus} onBlur={onBlur}> 70 + <div 71 + onFocus={dropdown.onFocus} 72 + onBlur={dropdown.onBlur} 73 + onKeyDown={(event) => 74 + dropdown.onKeyDown(event, (index) => 75 + navigate(visibleSuggestions[index].to), 76 + ) 77 + } 78 + > 104 79 <form onSubmit={onSubmit} className="flex flex-col sm:flex-row gap-2"> 105 80 <HandleInput 106 81 name="handle" ··· 109 84 required 110 85 className="sm:flex-1" 111 86 aria-autocomplete="list" 112 - aria-expanded={focused && visibleSuggestions.length > 0} 87 + aria-expanded={dropdownOpen} 88 + aria-activedescendant={ 89 + dropdown.activeIndex >= 0 90 + ? `dial-option-${dropdown.activeIndex}` 91 + : undefined 92 + } 113 93 aria-label="Dial a BBS by handle" 114 94 /> 115 95 <Button type="submit">go</Button> 116 96 <Button type="button" onClick={onRandom}>random</Button> 117 97 </form> 118 - {focused && visibleSuggestions.length > 0 && ( 98 + {dropdownOpen && ( 119 99 <div className="relative"> 120 100 <div role="listbox" className="absolute left-0 right-0 mt-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg z-10"> 121 - {visibleSuggestions.map((entry) => ( 101 + {visibleSuggestions.map((entry, index) => ( 122 102 <Link 123 103 key={entry.to} 104 + id={`dial-option-${index}`} 124 105 to={entry.to} 125 106 role="option" 126 - className="block px-3 py-2 text-sm text-neutral-300 hover:bg-neutral-800 first:rounded-t last:rounded-b" 107 + aria-selected={index === dropdown.activeIndex} 108 + className={`block px-3 py-2 text-sm text-neutral-300 first:rounded-t last:rounded-b ${ 109 + index === dropdown.activeIndex 110 + ? "bg-neutral-800" 111 + : "hover:bg-neutral-800" 112 + }`} 127 113 > 128 114 {entry.name} 129 115 {entry.name !== entry.handle && (
+53
web/src/hooks/useDropdown.ts
··· 1 + /** Shared focus/blur and keyboard navigation for dropdown menus. */ 2 + 3 + import { useRef, useState } from "react"; 4 + 5 + export function useDropdown(optionCount: number) { 6 + const [focused, setFocused] = useState(false); 7 + const [activeIndex, setActiveIndex] = useState(-1); 8 + const blurTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); 9 + 10 + function onFocus() { 11 + clearTimeout(blurTimeout.current); 12 + setFocused(true); 13 + } 14 + 15 + function onBlur() { 16 + blurTimeout.current = setTimeout(() => { 17 + setFocused(false); 18 + setActiveIndex(-1); 19 + }, 150); 20 + } 21 + 22 + function onKeyDown( 23 + event: React.KeyboardEvent, 24 + onSelect: (index: number) => void, 25 + ) { 26 + if (optionCount === 0 || !focused) return; 27 + 28 + if (event.key === "ArrowDown") { 29 + event.preventDefault(); 30 + setActiveIndex((prev) => 31 + prev < optionCount - 1 ? prev + 1 : 0, 32 + ); 33 + } else if (event.key === "ArrowUp") { 34 + event.preventDefault(); 35 + setActiveIndex((prev) => 36 + prev > 0 ? prev - 1 : optionCount - 1, 37 + ); 38 + } else if (event.key === "Enter" && activeIndex >= 0) { 39 + event.preventDefault(); 40 + onSelect(activeIndex); 41 + } else if (event.key === "Escape") { 42 + setFocused(false); 43 + setActiveIndex(-1); 44 + } 45 + } 46 + 47 + function close() { 48 + setFocused(false); 49 + setActiveIndex(-1); 50 + } 51 + 52 + return { focused, activeIndex, onFocus, onBlur, onKeyDown, close }; 53 + }
+45
web/src/hooks/useResolvedBBS.ts
··· 1 + /** Debounced BBS resolution — resolves a handle to a BBS name if one exists. */ 2 + 3 + import { useEffect, useState } from "react"; 4 + import { resolveIdentity, getRecord } from "../lib/atproto"; 5 + import { SITE } from "../lib/lexicon"; 6 + import type { Suggestion } from "../components/DialBBS"; 7 + 8 + const DEBOUNCE_MS = 300; 9 + 10 + export function useResolvedBBS(query: string): Suggestion | null { 11 + const [result, setResult] = useState<Suggestion | null>(null); 12 + 13 + useEffect(() => { 14 + const trimmed = query.trim(); 15 + if (!trimmed || !trimmed.includes(".")) { 16 + setResult(null); 17 + return; 18 + } 19 + 20 + let cancelled = false; 21 + const timeout = setTimeout(async () => { 22 + try { 23 + const identity = await resolveIdentity(trimmed); 24 + const siteRecord = await getRecord(identity.did, SITE, "self"); 25 + const siteValue = siteRecord.value as { name?: string }; 26 + if (!cancelled) { 27 + setResult({ 28 + to: `/bbs/${encodeURIComponent(identity.handle)}`, 29 + name: siteValue.name ?? identity.handle, 30 + handle: identity.handle, 31 + }); 32 + } 33 + } catch { 34 + if (!cancelled) setResult(null); 35 + } 36 + }, DEBOUNCE_MS); 37 + 38 + return () => { 39 + cancelled = true; 40 + clearTimeout(timeout); 41 + }; 42 + }, [query]); 43 + 44 + return result; 45 + }
+30 -17
web/src/pages/Login.tsx
··· 1 - import { useRef, useState, type SyntheticEvent } from "react"; 1 + import { useState, type SyntheticEvent } from "react"; 2 2 import { useAuth } from "../lib/auth"; 3 3 import { usePageTitle } from "../hooks/usePageTitle"; 4 4 import { useHandleSearch } from "../hooks/useHandleSearch"; 5 + import { useDropdown } from "../hooks/useDropdown"; 5 6 import HandleInput from "../components/form/HandleInput"; 6 7 import { Button } from "../components/form/Form"; 7 8 ··· 10 11 const [handle, setHandle] = useState(""); 11 12 const [error, setError] = useState<string | null>(null); 12 13 const [busy, setBusy] = useState(false); 13 - const [focused, setFocused] = useState(false); 14 - const blurTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); 15 14 const matches = useHandleSearch(handle); 15 + const dropdown = useDropdown(matches.length); 16 16 usePageTitle("Login — atbbs"); 17 17 18 18 async function onSubmit(event: SyntheticEvent) { ··· 29 29 30 30 function selectHandle(selected: string) { 31 31 setHandle(selected); 32 - setFocused(false); 33 - } 34 - 35 - function onFocus() { 36 - clearTimeout(blurTimeout.current); 37 - setFocused(true); 32 + dropdown.close(); 38 33 } 39 34 40 - function onBlur() { 41 - blurTimeout.current = setTimeout(() => setFocused(false), 150); 42 - } 35 + const dropdownOpen = dropdown.focused && matches.length > 0; 43 36 44 37 return ( 45 38 <div className="h-full flex flex-col justify-center overflow-hidden max-w-md mx-auto"> ··· 70 63 71 64 {error && <p className="text-red-400 mb-4 text-center">{error}</p>} 72 65 73 - <div onFocus={onFocus} onBlur={onBlur} className="mb-6"> 66 + <div 67 + onFocus={dropdown.onFocus} 68 + onBlur={dropdown.onBlur} 69 + onKeyDown={(event) => 70 + dropdown.onKeyDown(event, (index) => 71 + selectHandle(matches[index].handle), 72 + ) 73 + } 74 + className="mb-6" 75 + > 74 76 <form onSubmit={onSubmit} className="flex gap-2"> 75 77 <HandleInput 76 78 name="handle" ··· 79 81 required 80 82 className="flex-1" 81 83 aria-autocomplete="list" 82 - aria-expanded={focused && matches.length > 0} 84 + aria-expanded={dropdownOpen} 85 + aria-activedescendant={ 86 + dropdown.activeIndex >= 0 87 + ? `login-option-${dropdown.activeIndex}` 88 + : undefined 89 + } 83 90 aria-label="Enter your handle" 84 91 /> 85 92 <Button type="submit" disabled={busy}> 86 93 {busy ? "..." : "log in"} 87 94 </Button> 88 95 </form> 89 - {focused && matches.length > 0 && ( 96 + {dropdownOpen && ( 90 97 <div className="relative"> 91 98 <div role="listbox" className="absolute left-0 right-0 mt-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg z-10"> 92 - {matches.map((match) => ( 99 + {matches.map((match, index) => ( 93 100 <button 94 101 key={match.handle} 102 + id={`login-option-${index}`} 95 103 type="button" 96 104 role="option" 105 + aria-selected={index === dropdown.activeIndex} 97 106 onClick={() => selectHandle(match.handle)} 98 - className="flex items-center gap-3 w-full px-3 py-2 text-left hover:bg-neutral-800 first:rounded-t last:rounded-b" 107 + className={`flex items-center gap-3 w-full px-3 py-2 text-left first:rounded-t last:rounded-b ${ 108 + index === dropdown.activeIndex 109 + ? "bg-neutral-800" 110 + : "hover:bg-neutral-800" 111 + }`} 99 112 > 100 113 {match.avatar && ( 101 114 <img