A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

feat(login): add handle autocomplete

Trezy 4ee385ba 2ad864d4

+199 -28
+133 -13
web/src/components/login-form.tsx
··· 1 1 "use client" 2 2 3 3 import { useState } from "react" 4 + import { XIcon } from "lucide-react" 4 5 5 6 import { cn } from "@/lib/utils" 6 7 import { useAuth } from "@/lib/auth-context" 8 + import { 9 + useHandleTypeahead, 10 + type TypeaheadActor, 11 + } from "@/hooks/use-handle-typeahead" 12 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 7 13 import { Button } from "@/components/ui/button" 8 14 import { 15 + Combobox, 16 + ComboboxContent, 17 + ComboboxInput, 18 + ComboboxItem, 19 + ComboboxList, 20 + } from "@/components/ui/combobox" 21 + import { 9 22 Field, 10 23 FieldDescription, 11 24 FieldGroup, 12 25 FieldLabel, 13 26 } from "@/components/ui/field" 14 - import { Input } from "@/components/ui/input" 15 27 16 28 export function LoginForm({ 17 29 className, ··· 19 31 ...props 20 32 }: React.ComponentProps<"div"> & { externalError?: string | null }) { 21 33 const [handle, setHandle] = useState("") 34 + const [selectedActor, setSelectedActor] = useState<TypeaheadActor | null>( 35 + null, 36 + ) 37 + const [dropdownOpen, setDropdownOpen] = useState(false) 22 38 const [loading, setLoading] = useState(false) 23 39 const [error, setError] = useState<string | null>(null) 24 40 const { login } = useAuth() 41 + const { actors } = useHandleTypeahead(selectedActor ? "" : handle) 42 + 43 + const actorsByHandle = new Map(actors.map((a) => [a.handle, a])) 44 + const showDropdown = dropdownOpen && actors.length > 0 25 45 26 46 async function handleSubmit(e: React.FormEvent) { 27 47 e.preventDefault() 28 - if (!handle.trim()) return 48 + const value = selectedActor?.handle ?? handle 49 + if (!value.trim()) return 29 50 setLoading(true) 30 51 setError(null) 31 52 try { 32 - await login(handle.trim().toLowerCase()) 53 + await login(value.trim().toLowerCase()) 33 54 } catch (e: unknown) { 34 55 setError(e instanceof Error ? e.message : "Login failed") 35 56 setLoading(false) 36 57 } 37 58 } 38 59 60 + function clearSelection() { 61 + setSelectedActor(null) 62 + setHandle("") 63 + } 64 + 39 65 return ( 40 66 <div className={cn("flex flex-col gap-6", className)} {...props}> 41 67 <form onSubmit={handleSubmit}> ··· 51 77 )} 52 78 <Field> 53 79 <FieldLabel htmlFor="handle">Handle</FieldLabel> 54 - <Input 55 - id="handle" 56 - type="text" 57 - placeholder="you.bsky.social" 58 - value={handle} 59 - onChange={(e) => setHandle(e.target.value)} 60 - required 61 - disabled={loading} 62 - /> 80 + {selectedActor ? ( 81 + <div className="border-input dark:bg-input/30 flex w-full items-center gap-3 rounded-md border px-3 py-2 shadow-xs"> 82 + <Avatar size="sm"> 83 + {selectedActor.avatar && ( 84 + <AvatarImage 85 + src={selectedActor.avatar} 86 + alt={selectedActor.handle} 87 + /> 88 + )} 89 + <AvatarFallback> 90 + {( 91 + selectedActor.displayName?.[0] ?? 92 + selectedActor.handle[0] 93 + ).toUpperCase()} 94 + </AvatarFallback> 95 + </Avatar> 96 + <div className="flex min-w-0 flex-1 flex-col"> 97 + {selectedActor.displayName && ( 98 + <span className="truncate text-sm font-medium"> 99 + {selectedActor.displayName} 100 + </span> 101 + )} 102 + <span className="text-muted-foreground truncate text-xs"> 103 + @{selectedActor.handle} 104 + </span> 105 + </div> 106 + <button 107 + type="button" 108 + onClick={clearSelection} 109 + disabled={loading} 110 + className="text-muted-foreground hover:text-foreground shrink-0 cursor-pointer disabled:pointer-events-none disabled:opacity-50" 111 + > 112 + <XIcon className="size-4" /> 113 + </button> 114 + </div> 115 + ) : ( 116 + <Combobox 117 + inputValue={handle} 118 + onInputValueChange={(value, details) => { 119 + if (details.reason === "input-change") { 120 + setHandle(value) 121 + } 122 + }} 123 + onValueChange={(value) => { 124 + if (value) { 125 + const actor = actorsByHandle.get(value as string) 126 + if (actor) { 127 + setSelectedActor(actor) 128 + setHandle(actor.handle) 129 + } else { 130 + setHandle(value as string) 131 + } 132 + } 133 + }} 134 + open={showDropdown} 135 + onOpenChange={setDropdownOpen} 136 + items={actors.map((a) => a.handle)} 137 + filter={null} 138 + > 139 + <ComboboxInput 140 + className="w-full" 141 + id="handle" 142 + placeholder="you.bsky.social" 143 + disabled={loading} 144 + showTrigger={false} 145 + /> 146 + <ComboboxContent className="min-w-(--anchor-width)"> 147 + <ComboboxList> 148 + {(item: string) => { 149 + const actor = actorsByHandle.get(item) 150 + return ( 151 + <ComboboxItem key={item} value={item}> 152 + <Avatar size="sm"> 153 + {actor?.avatar && ( 154 + <AvatarImage src={actor.avatar} alt={item} /> 155 + )} 156 + <AvatarFallback> 157 + {( 158 + actor?.displayName?.[0] ?? item[0] 159 + ).toUpperCase()} 160 + </AvatarFallback> 161 + </Avatar> 162 + <div className="flex flex-col"> 163 + {actor?.displayName && ( 164 + <span className="text-sm font-medium leading-tight"> 165 + {actor.displayName} 166 + </span> 167 + )} 168 + <span className="text-muted-foreground text-xs"> 169 + @{item} 170 + </span> 171 + </div> 172 + </ComboboxItem> 173 + ) 174 + }} 175 + </ComboboxList> 176 + </ComboboxContent> 177 + </Combobox> 178 + )} 63 179 </Field> 64 180 <Field> 65 - <Button type="submit" className="w-full" disabled={loading}> 181 + <Button 182 + type="submit" 183 + className="w-full" 184 + disabled={loading || (!selectedActor && !handle.trim())} 185 + > 66 186 {loading ? "Signing in..." : "Sign in"} 67 187 </Button> 68 188 </Field>
+17 -15
web/src/components/ui/combobox.tsx
··· 69 69 render={<InputGroupInput disabled={disabled} />} 70 70 {...props} 71 71 /> 72 - <InputGroupAddon align="inline-end"> 73 - {showTrigger && ( 74 - <InputGroupButton 75 - size="icon-xs" 76 - variant="ghost" 77 - asChild 78 - data-slot="input-group-button" 79 - className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" 80 - disabled={disabled} 81 - > 82 - <ComboboxTrigger /> 83 - </InputGroupButton> 84 - )} 85 - {showClear && <ComboboxClear disabled={disabled} />} 86 - </InputGroupAddon> 72 + {(showTrigger || showClear) && ( 73 + <InputGroupAddon align="inline-end"> 74 + {showTrigger && ( 75 + <InputGroupButton 76 + size="icon-xs" 77 + variant="ghost" 78 + asChild 79 + data-slot="input-group-button" 80 + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" 81 + disabled={disabled} 82 + > 83 + <ComboboxTrigger /> 84 + </InputGroupButton> 85 + )} 86 + {showClear && <ComboboxClear disabled={disabled} />} 87 + </InputGroupAddon> 88 + )} 87 89 {children} 88 90 </InputGroup> 89 91 )
+49
web/src/hooks/use-handle-typeahead.ts
··· 1 + import { useEffect, useRef, useState } from "react" 2 + 3 + export interface TypeaheadActor { 4 + did: string 5 + handle: string 6 + displayName?: string 7 + avatar?: string 8 + } 9 + 10 + export function useHandleTypeahead(query: string, delay = 200) { 11 + const [actors, setActors] = useState<TypeaheadActor[]>([]) 12 + const abortRef = useRef<AbortController | null>(null) 13 + 14 + useEffect(() => { 15 + if (query.length < 2) { 16 + setActors([]) 17 + return 18 + } 19 + 20 + abortRef.current?.abort() 21 + 22 + const timeout = setTimeout(() => { 23 + const controller = new AbortController() 24 + abortRef.current = controller 25 + 26 + fetch( 27 + `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8`, 28 + { 29 + signal: controller.signal, 30 + headers: { "X-Client": "happyview" }, 31 + }, 32 + ) 33 + .then((res) => (res.ok ? res.json() : Promise.reject())) 34 + .then((data) => setActors(data.actors ?? [])) 35 + .catch((e) => { 36 + if (!(e instanceof DOMException && e.name === "AbortError")) { 37 + setActors([]) 38 + } 39 + }) 40 + }, delay) 41 + 42 + return () => { 43 + clearTimeout(timeout) 44 + abortRef.current?.abort() 45 + } 46 + }, [query, delay]) 47 + 48 + return { actors } 49 + }