(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useState, useEffect, useMemo } from "react";
2import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react";
3import { useTranslation } from "react-i18next";
4import {
5 BlackskyIcon,
6 NorthskyIcon,
7 BlueskyIcon,
8 TophhieIcon,
9 MarginIcon,
10 EuroskuIcon,
11} from "../common/Icons";
12import { startSignup } from "../../api/client";
13import { analytics } from "../../lib/analytics";
14
15interface Provider {
16 id: string;
17 name: string;
18 service: string;
19 Icon: React.ComponentType<{ size?: number }> | null;
20 description: string;
21 custom?: boolean;
22 wide?: boolean;
23}
24
25type ProviderBase = {
26 id: string;
27 service: string;
28 Icon: React.ComponentType<{ size?: number }> | null;
29 custom?: boolean;
30};
31
32const MARGIN_PROVIDER_BASE: ProviderBase = {
33 id: "margin",
34 service: "https://margin.cafe",
35 Icon: MarginIcon,
36};
37
38const OTHER_PROVIDERS_BASE: ProviderBase[] = [
39 { id: "bluesky", service: "https://bsky.social", Icon: BlueskyIcon },
40 { id: "blacksky", service: "https://blacksky.app", Icon: BlackskyIcon },
41 { id: "eurosky", service: "https://eurosky.social", Icon: EuroskuIcon },
42 { id: "selfhosted.social", service: "https://selfhosted.social", Icon: null },
43 { id: "northsky", service: "https://northsky.social", Icon: NorthskyIcon },
44 { id: "tophhie", service: "https://tophhie.social", Icon: TophhieIcon },
45 { id: "custom", service: "", custom: true, Icon: null },
46];
47
48function shuffleArray<T>(arr: T[]): T[] {
49 const shuffled = [...arr];
50 for (let i = shuffled.length - 1; i > 0; i--) {
51 const j = Math.floor(Math.random() * (i + 1));
52 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
53 }
54 return shuffled;
55}
56
57const inviteStatusPromise: Promise<Record<string, boolean>> = (async () => {
58 const results: Record<string, boolean> = {};
59 await Promise.allSettled(
60 [MARGIN_PROVIDER_BASE, ...OTHER_PROVIDERS_BASE]
61 .filter((p) => p.service && !p.custom)
62 .map(async (p) => {
63 try {
64 const res = await fetch(
65 `${p.service}/xrpc/com.atproto.server.describeServer`,
66 );
67 if (res.ok) {
68 const data = await res.json();
69 results[p.id] = !!data.inviteCodeRequired;
70 }
71 } catch {
72 // ignore unreachable providers
73 }
74 }),
75 );
76 return results;
77})();
78
79interface SignUpModalProps {
80 onClose: () => void;
81}
82
83export default function SignUpModal({ onClose }: SignUpModalProps) {
84 const { t } = useTranslation();
85 const [showCustomInput, setShowCustomInput] = useState(false);
86 const [showMore, setShowMore] = useState(false);
87 const [customService, setCustomService] = useState("");
88 const [loading, setLoading] = useState(false);
89 const [error, setError] = useState<string | null>(null);
90 const [inviteStatus, setInviteStatus] = useState<Record<string, boolean>>({});
91 const [statusLoaded, setStatusLoaded] = useState(false);
92
93 const MARGIN_PROVIDER: Provider = {
94 ...MARGIN_PROVIDER_BASE,
95 name: t("signUp.providers.margin.name"),
96 description: t("signUp.providers.margin.description"),
97 };
98
99 const providerI18nKey: Record<string, string> = {
100 bluesky: "bluesky",
101 blacksky: "blacksky",
102 eurosky: "eurosky",
103 "selfhosted.social": "selfhostedSocial",
104 northsky: "northsky",
105 tophhie: "tophhie",
106 custom: "customPds",
107 };
108 const OTHER_PROVIDERS: Provider[] = OTHER_PROVIDERS_BASE.map((p) => {
109 const k = providerI18nKey[p.id] || p.id;
110 return {
111 ...p,
112 name: t(`signUp.providers.${k}.name`),
113 description: t(`signUp.providers.${k}.description`),
114 };
115 });
116
117 useEffect(() => {
118 inviteStatusPromise.then((status) => {
119 setInviteStatus(status);
120 setStatusLoaded(true);
121 });
122 }, []);
123
124 const providers = useMemo(() => {
125 const nonCustom = OTHER_PROVIDERS.filter((p) => !p.custom);
126 const custom = OTHER_PROVIDERS.find((p) => p.custom);
127
128 if (!statusLoaded) {
129 return [
130 MARGIN_PROVIDER,
131 ...shuffleArray(nonCustom),
132 ...(custom ? [custom] : []),
133 ];
134 }
135
136 const open = nonCustom.filter((p) => !inviteStatus[p.id]);
137 const inviteOnly = nonCustom.filter((p) => inviteStatus[p.id]);
138 return [
139 MARGIN_PROVIDER,
140 ...shuffleArray(open),
141 ...shuffleArray(inviteOnly),
142 ...(custom ? [custom] : []),
143 ];
144 // eslint-disable-next-line react-hooks/exhaustive-deps
145 }, [statusLoaded, inviteStatus]);
146
147 useEffect(() => {
148 document.body.style.overflow = "hidden";
149 return () => {
150 document.body.style.overflow = "unset";
151 };
152 }, []);
153
154 const handleProviderSelect = async (provider: Provider) => {
155 if (provider.custom) {
156 setShowCustomInput(true);
157 return;
158 }
159
160 setLoading(true);
161 setError(null);
162
163 try {
164 analytics.capture("signup_initiated", { provider: provider.id });
165 const result = await startSignup(provider.service);
166 if (result.authorizationUrl) {
167 window.location.assign(result.authorizationUrl);
168 }
169 } catch (err) {
170 console.error(err);
171 analytics.captureException(err);
172 setError(t("signUp.providerError"));
173 setLoading(false);
174 }
175 };
176
177 const handleCustomSubmit = async (e: React.FormEvent) => {
178 e.preventDefault();
179 if (!customService.trim()) return;
180
181 setLoading(true);
182 setError(null);
183
184 let serviceUrl = customService.trim();
185 if (!serviceUrl.startsWith("http")) {
186 serviceUrl = `https://${serviceUrl}`;
187 }
188
189 try {
190 analytics.capture("signup_initiated", { provider: "custom" });
191 const result = await startSignup(serviceUrl);
192 if (result.authorizationUrl) {
193 const url = new URL(result.authorizationUrl);
194 if (url.protocol !== "https:")
195 throw new Error("Invalid authorization URL");
196 window.location.href = result.authorizationUrl;
197 }
198 } catch (err) {
199 console.error(err);
200 analytics.captureException(err);
201 setError(t("signUp.customPdsError"));
202 setLoading(false);
203 }
204 };
205
206 return (
207 <div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
208 <div className="w-full sm:max-w-md bg-white dark:bg-surface-900 rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden animate-slide-up flex flex-col">
209 <div className="px-5 sm:px-8 pt-5 sm:pt-6 pb-2 flex items-center justify-between flex-shrink-0">
210 <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white">
211 {loading
212 ? t("signUp.connecting")
213 : showCustomInput
214 ? t("signUp.customPdsTitle")
215 : t("signUp.title")}
216 </h2>
217 <button
218 onClick={onClose}
219 className="p-2 text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors"
220 >
221 <X size={20} />
222 </button>
223 </div>
224
225 <div className="px-5 sm:px-8 pb-8 sm:pb-10 overflow-y-auto custom-scrollbar">
226 {loading ? (
227 <div className="text-center py-10">
228 <Loader2
229 size={40}
230 className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-4"
231 />
232 </div>
233 ) : showCustomInput ? (
234 <div>
235 <h2 className="sr-only">{t("signUp.customPdsTitle")}</h2>
236
237 <p className="text-sm text-surface-500 dark:text-surface-400 mb-6">
238 {t("signUp.customPdsSubtitle")}
239 </p>
240 <form onSubmit={handleCustomSubmit} className="space-y-4">
241 <div>
242 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
243 {t("signUp.pdsAddressLabel")}
244 </label>
245 <input
246 type="text"
247 className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 dark:focus:ring-primary-400/10 outline-none transition-all"
248 value={customService}
249 onChange={(e) => setCustomService(e.target.value)}
250 placeholder={t("signUp.pdsAddressPlaceholder")}
251 autoFocus
252 />
253 </div>
254
255 {error && (
256 <div className="p-3 bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900/40">
257 <AlertCircle size={16} />
258 {error}
259 </div>
260 )}
261
262 <div className="flex gap-3 pt-4">
263 <button
264 type="button"
265 className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-300 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors"
266 onClick={() => {
267 setShowCustomInput(false);
268 setError(null);
269 }}
270 >
271 {t("signUp.back")}
272 </button>
273 <button
274 type="submit"
275 className="flex-1 py-3 bg-primary-600 dark:bg-primary-500 text-white font-semibold rounded-xl hover:bg-primary-700 dark:hover:bg-primary-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
276 disabled={!customService.trim()}
277 >
278 {t("signUp.continue")}
279 </button>
280 </div>
281 </form>
282 </div>
283 ) : (
284 <div>
285 <p className="text-surface-500 dark:text-surface-400 mb-6">
286 {t("signUp.subtitle")}{" "}
287 <a
288 href="https://atproto.com"
289 target="_blank"
290 rel="noopener noreferrer"
291 className="text-primary-600 dark:text-primary-400 hover:underline"
292 >
293 {t("signUp.atProtocol")}
294 </a>
295 {t("signUp.subtitleSuffix")}
296 </p>
297
298 {error && (
299 <div className="mb-4 p-3 bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 border border-red-100 dark:border-red-900/40">
300 <AlertCircle size={16} />
301 {error}
302 </div>
303 )}
304
305 <div className="space-y-2">
306 {(showMore ? providers : providers.slice(0, 1)).map((p) => (
307 <button
308 key={p.id}
309 className={`w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left group ${
310 p.id === "margin"
311 ? "bg-primary-50/60 dark:bg-primary-900/15 border border-transparent hover:bg-primary-100/60 dark:hover:bg-primary-900/25"
312 : "bg-surface-50 dark:bg-surface-800/60 hover:bg-surface-100 dark:hover:bg-surface-800 border border-transparent"
313 }`}
314 onClick={() => handleProviderSelect(p)}
315 >
316 <div
317 className={`w-9 h-9 flex items-center justify-center rounded-full flex-shrink-0 ${
318 p.id === "margin"
319 ? "bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-400"
320 : "bg-white dark:bg-surface-700 shadow-sm dark:shadow-none text-surface-600 dark:text-surface-300"
321 }`}
322 >
323 {p.Icon ? (
324 <p.Icon size={18} />
325 ) : (
326 <span className="font-bold text-xs">{p.name[0]}</span>
327 )}
328 </div>
329 <div className="flex-1 min-w-0">
330 <h3 className="text-sm font-bold text-surface-900 dark:text-white">
331 {p.name}
332 </h3>
333 <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1">
334 {p.description}
335 </p>
336 </div>
337 {inviteStatus[p.id] && (
338 <span className="text-[10px] font-medium text-surface-400 dark:text-surface-500 bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded-md flex-shrink-0">
339 {t("signUp.invite")}
340 </span>
341 )}
342 <ChevronRight
343 size={16}
344 className="text-surface-300 dark:text-surface-600 group-hover:text-surface-600 dark:group-hover:text-surface-400"
345 />
346 </button>
347 ))}
348 </div>
349
350 {!showMore && (
351 <div className="mt-3 space-y-3">
352 <button
353 onClick={() => setShowMore(true)}
354 className="w-full py-2.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white bg-surface-50 dark:bg-surface-800/60 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl border border-transparent transition-colors"
355 >
356 {t("signUp.moreOptions")}
357 </button>
358 <p className="text-center text-xs text-surface-400 dark:text-surface-500">
359 {t("signUp.atmosphereNote")}
360 </p>
361 </div>
362 )}
363 </div>
364 )}
365 </div>
366 </div>
367 </div>
368 );
369}