(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

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

at main 369 lines 14 kB view raw
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}