this repo has no description
0
fork

Configure Feed

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

at main 255 lines 8.6 kB view raw
1import { useSignal } from "@preact/signals"; 2import { useEffect, useRef } from "preact/hooks"; 3import { useT } from "../i18n/mod.ts"; 4 5interface Props { 6 /** Optional path to redirect to after successful login (defaults to /explore/manage) */ 7 returnTo?: string; 8} 9 10interface PreviewMatch { 11 did: string; 12 handle: string; 13 displayName?: string; 14 avatarUrl?: string; 15} 16 17interface PreviewSuccess { 18 found: true; 19 matches: PreviewMatch[]; 20} 21 22interface PreviewMiss { 23 found: false; 24 reason: "invalid_handle" | "not_found"; 25} 26 27type PreviewResponse = PreviewSuccess | PreviewMiss; 28 29export default function SignInForm({ returnTo }: Props) { 30 const t = useT(); 31 const handle = useSignal(""); 32 const submitting = useSignal(false); 33 const error = useSignal<string | null>(null); 34 const matches = useSignal<PreviewMatch[]>([]); 35 const previewLoading = useSignal(false); 36 const missReason = useSignal<PreviewMiss["reason"] | null>(null); 37 const showPreview = useSignal(false); 38 39 const debounceRef = useRef<number | null>(null); 40 const requestSeq = useRef(0); 41 const wrapRef = useRef<HTMLDivElement | null>(null); 42 const formRef = useRef<HTMLFormElement | null>(null); 43 44 useEffect(() => { 45 function onDocPointerDown(e: PointerEvent) { 46 if (!wrapRef.current) return; 47 const node = e.target; 48 if (node instanceof Node && !wrapRef.current.contains(node)) { 49 showPreview.value = false; 50 } 51 } 52 document.addEventListener("pointerdown", onDocPointerDown); 53 return () => document.removeEventListener("pointerdown", onDocPointerDown); 54 }, []); 55 56 function schedulePreview(value: string) { 57 if (debounceRef.current !== null) { 58 clearTimeout(debounceRef.current); 59 debounceRef.current = null; 60 } 61 const trimmed = value.trim().replace(/^@/, "").toLowerCase(); 62 if (!trimmed) { 63 matches.value = []; 64 missReason.value = null; 65 previewLoading.value = false; 66 showPreview.value = false; 67 return; 68 } 69 matches.value = []; 70 missReason.value = null; 71 previewLoading.value = true; 72 showPreview.value = true; 73 const mySeq = ++requestSeq.current; 74 debounceRef.current = setTimeout(() => { 75 fetch(`/api/identity/preview?handle=${encodeURIComponent(trimmed)}`) 76 .then((r) => r.json() as Promise<PreviewResponse>) 77 .then((data) => { 78 if (mySeq !== requestSeq.current) return; 79 previewLoading.value = false; 80 if (data.found) { 81 matches.value = data.matches; 82 missReason.value = null; 83 } else { 84 matches.value = []; 85 missReason.value = data.reason; 86 } 87 }) 88 .catch(() => { 89 if (mySeq !== requestSeq.current) return; 90 previewLoading.value = false; 91 matches.value = []; 92 missReason.value = "not_found"; 93 }); 94 }, 150); 95 } 96 97 const onSubmit = (event: Event) => { 98 event.preventDefault(); 99 if (!handle.value.trim()) return; 100 submitting.value = true; 101 error.value = null; 102 const form = event.currentTarget as HTMLFormElement; 103 form.submit(); 104 }; 105 106 const onSelectMatch = (m: PreviewMatch) => { 107 handle.value = m.handle; 108 showPreview.value = false; 109 submitting.value = true; 110 error.value = null; 111 /** Defer one tick so the controlled <input> reflects the new value 112 * before the native form submission serialises it. */ 113 setTimeout(() => { 114 formRef.current?.submit(); 115 }, 0); 116 }; 117 118 return ( 119 <form 120 ref={formRef} 121 method="POST" 122 action="/oauth/login" 123 onSubmit={onSubmit} 124 class="signin-form" 125 > 126 {returnTo && <input type="hidden" name="next" value={returnTo} />} 127 <div class="signin-form-preview-wrap" ref={wrapRef}> 128 <label class="signin-form-label" for="signin-handle"> 129 {t.explore.create.signInLabel} 130 </label> 131 <div class="signin-form-row"> 132 <div style={{ flex: 1, minWidth: 0, position: "relative" }}> 133 <input 134 id="signin-handle" 135 name="handle" 136 type="text" 137 inputMode="email" 138 autoCapitalize="none" 139 autoCorrect="off" 140 spellcheck={false} 141 autoComplete="off" 142 required 143 placeholder={t.explore.create.handlePlaceholder} 144 value={handle.value} 145 onInput={(e) => { 146 const v = (e.currentTarget as HTMLInputElement).value; 147 handle.value = v; 148 schedulePreview(v); 149 }} 150 onFocus={() => { 151 const v = handle.value; 152 if (v.trim()) schedulePreview(v); 153 }} 154 class="signin-form-input" 155 style={{ width: "100%" }} 156 aria-autocomplete="list" 157 aria-expanded={showPreview.value} 158 aria-controls="signin-handle-preview" 159 /> 160 {showPreview.value && ( 161 <div 162 id="signin-handle-preview" 163 class="signin-form-preview glass" 164 role="listbox" 165 > 166 {previewLoading.value && ( 167 <div class="signin-form-preview-status"> 168 <span 169 class="signin-form-preview-spinner" 170 aria-hidden="true" 171 /> 172 <span>{t.explore.create.previewLoading}</span> 173 </div> 174 )} 175 {!previewLoading.value && missReason.value !== null && ( 176 <div class="signin-form-preview-status"> 177 <span>{t.explore.create.previewNotFound}</span> 178 </div> 179 )} 180 {!previewLoading.value && 181 missReason.value === null && 182 matches.value.length > 0 && ( 183 <div class="signin-form-preview-list"> 184 {matches.value.map((m) => ( 185 <button 186 key={m.did} 187 type="button" 188 class="signin-form-preview-row" 189 role="option" 190 onPointerDown={(e) => { 191 e.preventDefault(); 192 onSelectMatch(m); 193 }} 194 > 195 {m.avatarUrl 196 ? ( 197 <img 198 class="signin-form-preview-avatar" 199 src={m.avatarUrl} 200 alt="" 201 loading="lazy" 202 decoding="async" 203 /> 204 ) 205 : ( 206 <span 207 class="signin-form-preview-avatar" 208 aria-hidden="true" 209 /> 210 )} 211 <span class="signin-form-preview-meta"> 212 {m.displayName 213 ? ( 214 <> 215 <span class="signin-form-preview-name"> 216 {m.displayName} 217 </span> 218 <span class="signin-form-preview-handle"> 219 @{m.handle} 220 </span> 221 </> 222 ) 223 : ( 224 <span class="signin-form-preview-name"> 225 @{m.handle} 226 </span> 227 )} 228 </span> 229 </button> 230 ))} 231 </div> 232 )} 233 {!previewLoading.value && 234 missReason.value === null && 235 matches.value.length === 0 && ( 236 <div class="signin-form-preview-status"> 237 <span>{t.explore.create.previewNotFound}</span> 238 </div> 239 )} 240 </div> 241 )} 242 </div> 243 <button 244 type="submit" 245 class="signin-form-submit" 246 disabled={submitting.value} 247 > 248 {submitting.value ? "…" : t.explore.create.signIn} 249 </button> 250 </div> 251 </div> 252 {error.value && <p class="signin-form-error">{error.value}</p>} 253 </form> 254 ); 255}