forked from
joebasser.com/atmosphere-account
this repo has no description
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}