Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

better signup flow and margin pds

+356 -309
+1
backend/cmd/server/main.go
··· 94 94 95 95 r.Get("/auth/login", oauthHandler.HandleLogin) 96 96 r.Post("/auth/start", oauthHandler.HandleStart) 97 + r.Post("/auth/signup", oauthHandler.HandleSignup) 97 98 r.Get("/auth/callback", oauthHandler.HandleCallback) 98 99 r.Post("/auth/logout", oauthHandler.HandleLogout) 99 100 r.Get("/auth/session", oauthHandler.HandleSession)
+19
backend/internal/oauth/client.go
··· 208 208 return &meta, nil 209 209 } 210 210 211 + func (c *Client) GetAuthServerMetadataForSignup(ctx context.Context, url string) (*AuthServerMetadata, error) { 212 + url = strings.TrimSuffix(url, "/") 213 + 214 + metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", url) 215 + metaResp, err := http.Get(metaURL) 216 + if err == nil && metaResp.StatusCode == 200 { 217 + defer metaResp.Body.Close() 218 + var meta AuthServerMetadata 219 + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err == nil && meta.Issuer != "" { 220 + return &meta, nil 221 + } 222 + } 223 + if metaResp != nil { 224 + metaResp.Body.Close() 225 + } 226 + 227 + return c.GetAuthServerMetadata(ctx, url) 228 + } 229 + 211 230 func (c *Client) GeneratePKCE() (verifier, challenge string) { 212 231 b := make([]byte, 32) 213 232 rand.Read(b)
+83 -2
backend/internal/oauth/handler.go
··· 283 283 }) 284 284 } 285 285 286 + func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) { 287 + if r.Method != "POST" { 288 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 289 + return 290 + } 291 + 292 + var req struct { 293 + PdsURL string `json:"pds_url"` 294 + } 295 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 296 + http.Error(w, "Invalid request body", http.StatusBadRequest) 297 + return 298 + } 299 + 300 + if req.PdsURL == "" { 301 + http.Error(w, "PDS URL is required", http.StatusBadRequest) 302 + return 303 + } 304 + 305 + client := h.getDynamicClient(r) 306 + ctx := r.Context() 307 + 308 + meta, err := client.GetAuthServerMetadataForSignup(ctx, req.PdsURL) 309 + if err != nil { 310 + log.Printf("Failed to get auth metadata for signup from %s: %v", req.PdsURL, err) 311 + w.Header().Set("Content-Type", "application/json") 312 + w.WriteHeader(http.StatusBadRequest) 313 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to connect to PDS"}) 314 + return 315 + } 316 + 317 + dpopKey, err := client.GenerateDPoPKey() 318 + if err != nil { 319 + w.Header().Set("Content-Type", "application/json") 320 + w.WriteHeader(http.StatusInternalServerError) 321 + json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"}) 322 + return 323 + } 324 + 325 + pkceVerifier, pkceChallenge := client.GeneratePKCE() 326 + scope := "atproto offline_access blob:* include:at.margin.authFull" 327 + 328 + parResp, state, dpopNonce, err := client.SendPAR(meta, "", scope, dpopKey, pkceChallenge) 329 + if err != nil { 330 + log.Printf("PAR request failed for signup: %v", err) 331 + w.Header().Set("Content-Type", "application/json") 332 + w.WriteHeader(http.StatusInternalServerError) 333 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate signup"}) 334 + return 335 + } 336 + 337 + pending := &PendingAuth{ 338 + State: state, 339 + DID: "", 340 + Handle: "", 341 + PDS: req.PdsURL, 342 + AuthServer: meta.TokenEndpoint, 343 + Issuer: meta.Issuer, 344 + PKCEVerifier: pkceVerifier, 345 + DPoPKey: dpopKey, 346 + DPoPNonce: dpopNonce, 347 + CreatedAt: time.Now(), 348 + } 349 + 350 + h.pendingMu.Lock() 351 + h.pending[state] = pending 352 + h.pendingMu.Unlock() 353 + 354 + authURL, _ := url.Parse(meta.AuthorizationEndpoint) 355 + q := authURL.Query() 356 + q.Set("client_id", client.ClientID) 357 + q.Set("request_uri", parResp.RequestURI) 358 + authURL.RawQuery = q.Encode() 359 + 360 + w.Header().Set("Content-Type", "application/json") 361 + json.NewEncoder(w).Encode(map[string]string{ 362 + "authorizationUrl": authURL.String(), 363 + }) 364 + } 365 + 286 366 func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { 287 367 client := h.getDynamicClient(r) 288 368 ··· 318 398 } 319 399 320 400 ctx := r.Context() 321 - meta, err := client.GetAuthServerMetadata(ctx, pending.PDS) 401 + meta, err := client.GetAuthServerMetadataForSignup(ctx, pending.PDS) 322 402 if err != nil { 403 + log.Printf("Failed to get auth metadata in callback for %s: %v", pending.PDS, err) 323 404 http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) 324 405 return 325 406 } ··· 330 411 return 331 412 } 332 413 333 - if tokenResp.Sub != pending.DID { 414 + if pending.DID != "" && tokenResp.Sub != pending.DID { 334 415 log.Printf("Security: OAuth sub mismatch, expected %s, got %s", pending.DID, tokenResp.Sub) 335 416 http.Error(w, "Account identity mismatch, authorization returned different account", http.StatusBadRequest) 336 417 return
+7
web/src/api/client.js
··· 452 452 body: JSON.stringify({ handle, invite_code: inviteCode }), 453 453 }); 454 454 } 455 + 456 + export async function startSignup(pdsUrl) { 457 + return request(`${AUTH_BASE}/signup`, { 458 + method: "POST", 459 + body: JSON.stringify({ pds_url: pdsUrl }), 460 + }); 461 + } 455 462 export async function getTrendingTags(limit = 10) { 456 463 return request(`${API_BASE}/tags/trending?limit=${limit}`); 457 464 }
+117 -255
web/src/components/SignUpModal.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 2 import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 3 import { BlackskyIcon, NorthskyIcon, BlueskyIcon, TopphieIcon } from "./Icons"; 4 - import { describeServer, createAccount, startLogin } from "../api/client"; 4 + import { startSignup } from "../api/client"; 5 + import logo from "../assets/logo.svg"; 5 6 6 - const PROVIDERS = [ 7 + const RECOMMENDED_PROVIDER = { 8 + id: "margin", 9 + name: "Margin", 10 + service: "https://pds.margin.at", 11 + Icon: null, 12 + description: "Hosted by Margin, the easiest way to get started", 13 + isMargin: true, 14 + }; 15 + 16 + const OTHER_PROVIDERS = [ 7 17 { 8 18 id: "bluesky", 9 19 name: "Bluesky", ··· 24 34 service: "https://northsky.social", 25 35 Icon: NorthskyIcon, 26 36 description: "A Canadian-based worker-owned cooperative", 27 - inviteUrl: "https://northskysocial.com/join", 28 37 }, 29 38 { 30 39 id: "topphie", ··· 41 50 description: "An independent, self-hosted PDS instance", 42 51 }, 43 52 { 44 - id: "selfhosted", 45 - name: "Self-Hosted", 53 + id: "custom", 54 + name: "Custom", 46 55 service: "", 47 56 custom: true, 48 57 Icon: null, 49 - description: "Connect to your own Personal Data Server", 58 + description: "Connect to your own or another custom PDS", 50 59 }, 51 60 ]; 52 61 53 62 export default function SignUpModal({ onClose }) { 54 - const [step, setStep] = useState(1); 55 - const [selectedProvider, setSelectedProvider] = useState(null); 63 + const [showOtherProviders, setShowOtherProviders] = useState(false); 64 + const [showCustomInput, setShowCustomInput] = useState(false); 56 65 const [customService, setCustomService] = useState(""); 57 - const [formData, setFormData] = useState({ 58 - handle: "", 59 - email: "", 60 - password: "", 61 - inviteCode: "", 62 - }); 63 66 const [loading, setLoading] = useState(false); 64 67 const [error, setError] = useState(null); 65 - const [serverInfo, setServerInfo] = useState(null); 66 68 67 69 useEffect(() => { 68 70 document.body.style.overflow = "hidden"; ··· 71 73 }; 72 74 }, []); 73 75 74 - const handleProviderSelect = (provider) => { 75 - setSelectedProvider(provider); 76 - if (!provider.custom) { 77 - checkServer(provider.service); 78 - } else { 79 - setStep(1.5); 76 + const handleProviderSelect = async (provider) => { 77 + if (provider.custom) { 78 + setShowCustomInput(true); 79 + return; 80 80 } 81 - }; 82 81 83 - const checkServer = async (url) => { 84 82 setLoading(true); 85 83 setError(null); 84 + 86 85 try { 87 - let serviceUrl = url.trim(); 88 - if (!serviceUrl.startsWith("http")) { 89 - serviceUrl = `https://${serviceUrl}`; 86 + const result = await startSignup(provider.service); 87 + if (result.authorizationUrl) { 88 + window.location.href = result.authorizationUrl; 90 89 } 91 - 92 - const info = await describeServer(serviceUrl); 93 - setServerInfo({ 94 - ...info, 95 - service: serviceUrl, 96 - inviteCodeRequired: info.inviteCodeRequired ?? true, 97 - }); 98 - 99 - if (selectedProvider?.custom) { 100 - setSelectedProvider({ ...selectedProvider, service: serviceUrl }); 101 - } 102 - 103 - setStep(2); 104 90 } catch (err) { 105 91 console.error(err); 106 - setError("Could not connect to this PDS. Please check the URL."); 107 - } finally { 92 + setError("Could not connect to this provider. Please try again."); 108 93 setLoading(false); 109 94 } 110 95 }; 111 96 112 - const handleCreateAccount = async (e) => { 97 + const handleCustomSubmit = async (e) => { 113 98 e.preventDefault(); 114 - if (!serverInfo) return; 99 + if (!customService.trim()) return; 115 100 116 101 setLoading(true); 117 102 setError(null); 118 103 119 - let domain = 120 - serverInfo.selectedDomain || serverInfo.availableUserDomains[0]; 121 - if (!domain.startsWith(".")) { 122 - domain = "." + domain; 104 + let serviceUrl = customService.trim(); 105 + if (!serviceUrl.startsWith("http")) { 106 + serviceUrl = `https://${serviceUrl}`; 123 107 } 124 108 125 - const cleanHandle = formData.handle.trim().replace(/^@/, ""); 126 - const fullHandle = cleanHandle.endsWith(domain) 127 - ? cleanHandle 128 - : `${cleanHandle}${domain}`; 129 - 130 109 try { 131 - await createAccount(serverInfo.service, { 132 - handle: fullHandle, 133 - email: formData.email, 134 - password: formData.password, 135 - inviteCode: formData.inviteCode, 136 - }); 137 - 138 - const result = await startLogin(fullHandle); 110 + const result = await startSignup(serviceUrl); 139 111 if (result.authorizationUrl) { 140 112 window.location.href = result.authorizationUrl; 141 - } else { 142 - onClose(); 143 - alert("Account created! Please sign in."); 144 113 } 145 114 } catch (err) { 146 - setError(err.message || "Failed to create account"); 115 + console.error(err); 116 + setError("Could not connect to this PDS. Please check the URL."); 147 117 setLoading(false); 148 118 } 149 119 }; ··· 155 125 <X size={20} /> 156 126 </button> 157 127 158 - {step === 1 && ( 159 - <div className="signup-step"> 160 - <h2>Choose a Provider</h2> 161 - <p className="signup-subtitle"> 162 - Where would you like to host your account? 128 + {loading ? ( 129 + <div className="signup-step" style={{ textAlign: "center" }}> 130 + <Loader2 size={32} className="spinner" /> 131 + <p style={{ marginTop: "1rem", color: "var(--text-secondary)" }}> 132 + Connecting to provider... 163 133 </p> 164 - <div className="provider-grid"> 165 - {PROVIDERS.map((p) => ( 166 - <button 167 - key={p.id} 168 - className="provider-card" 169 - onClick={() => handleProviderSelect(p)} 170 - > 171 - <div className={`provider-icon ${p.wide ? "wide" : ""}`}> 172 - {p.Icon ? ( 173 - <p.Icon size={p.wide ? 32 : 32} /> 174 - ) : ( 175 - <span className="provider-initial">{p.name[0]}</span> 176 - )} 177 - </div> 178 - <div className="provider-info"> 179 - <h3>{p.name}</h3> 180 - <span>{p.description}</span> 181 - </div> 182 - <ChevronRight size={16} className="provider-arrow" /> 183 - </button> 184 - ))} 185 - </div> 186 134 </div> 187 - )} 188 - 189 - {step === 1.5 && ( 135 + ) : showCustomInput ? ( 190 136 <div className="signup-step"> 191 137 <h2>Custom Provider</h2> 192 - <form 193 - onSubmit={(e) => { 194 - e.preventDefault(); 195 - checkServer(customService); 196 - }} 197 - > 138 + <form onSubmit={handleCustomSubmit}> 198 139 <div className="form-group"> 199 140 <label>PDS address (e.g. pds.example.com)</label> 200 141 <input 201 142 type="text" 202 - className="login-input" 203 143 value={customService} 204 144 onChange={(e) => setCustomService(e.target.value)} 205 - placeholder="example.com" 145 + placeholder="pds.example.com" 206 146 autoFocus 207 147 /> 208 148 </div> 149 + 209 150 {error && ( 210 151 <div className="error-message"> 211 - <AlertCircle size={14} /> {error} 152 + <AlertCircle size={16} /> 153 + {error} 212 154 </div> 213 155 )} 156 + 214 157 <div className="modal-actions"> 215 158 <button 216 159 type="button" 217 - className="btn btn-ghost" 218 - onClick={() => setStep(1)} 160 + className="btn-secondary" 161 + onClick={() => { 162 + setShowCustomInput(false); 163 + setError(null); 164 + }} 219 165 > 220 166 Back 221 167 </button> 222 168 <button 223 169 type="submit" 224 - className="btn btn-primary" 225 - disabled={!customService || loading} 170 + className="btn-primary" 171 + disabled={!customService.trim()} 226 172 > 227 - {loading ? <Loader2 className="animate-spin" /> : "Next"} 173 + Continue 228 174 </button> 229 175 </div> 230 176 </form> 231 177 </div> 232 - )} 233 - 234 - {step === 2 && serverInfo && ( 178 + ) : ( 235 179 <div className="signup-step"> 236 - <div className="step-header"> 237 - <button className="btn-back" onClick={() => setStep(1)}> 238 - ← Back 239 - </button> 240 - <h2> 241 - Create Account on {selectedProvider?.name || "Custom PDS"} 242 - </h2> 243 - </div> 244 - 245 - <form onSubmit={handleCreateAccount} className="signup-form"> 246 - {serverInfo.inviteCodeRequired && ( 247 - <div className="form-group"> 248 - <label>Invite Code *</label> 249 - <input 250 - type="text" 251 - className="login-input" 252 - value={formData.inviteCode} 253 - onChange={(e) => 254 - setFormData({ ...formData, inviteCode: e.target.value }) 255 - } 256 - placeholder="bsky-social-xxxxx" 257 - required 258 - /> 259 - {selectedProvider?.inviteUrl && ( 260 - <p 261 - className="legal-text" 262 - style={{ textAlign: "left", marginTop: "4px" }} 263 - > 264 - Need an invite code?{" "} 265 - <a 266 - href={selectedProvider.inviteUrl} 267 - target="_blank" 268 - rel="noopener noreferrer" 269 - style={{ color: "var(--accent)" }} 270 - > 271 - Get one here 272 - </a> 273 - </p> 274 - )} 275 - </div> 276 - )} 180 + <h2>Create your account</h2> 181 + <p className="signup-subtitle"> 182 + Margin uses the AT Protocol — the same decentralized network that 183 + powers Bluesky. Your account will be hosted on a server of your 184 + choice. 185 + </p> 277 186 278 - <div className="form-group"> 279 - <label>Email Address</label> 280 - <input 281 - type="email" 282 - className="login-input" 283 - value={formData.email} 284 - onChange={(e) => 285 - setFormData({ ...formData, email: e.target.value }) 286 - } 287 - placeholder="you@example.com" 288 - required 289 - /> 187 + {error && ( 188 + <div className="error-message" style={{ marginBottom: "1rem" }}> 189 + <AlertCircle size={16} /> 190 + {error} 290 191 </div> 192 + )} 291 193 292 - <div className="form-group"> 293 - <label>Password</label> 294 - <input 295 - type="password" 296 - className="login-input" 297 - value={formData.password} 298 - onChange={(e) => 299 - setFormData({ ...formData, password: e.target.value }) 300 - } 301 - required 302 - /> 303 - </div> 304 - 305 - <div className="form-group"> 306 - <label>Handle</label> 307 - <div className="handle-input-group"> 308 - <input 309 - type="text" 310 - className="login-input" 311 - value={formData.handle} 312 - onChange={(e) => 313 - setFormData({ ...formData, handle: e.target.value }) 314 - } 315 - placeholder="username" 316 - required 317 - style={{ flex: 1 }} 194 + <div className="signup-recommended"> 195 + <div className="signup-recommended-badge">Recommended</div> 196 + <button 197 + className="provider-card provider-card-featured" 198 + onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)} 199 + > 200 + <div className="provider-icon"> 201 + <img 202 + src={logo} 203 + alt="Margin" 204 + style={{ width: 24, height: 24 }} 318 205 /> 319 - {serverInfo.availableUserDomains && 320 - serverInfo.availableUserDomains.length > 1 ? ( 321 - <select 322 - className="login-input" 323 - style={{ 324 - width: "auto", 325 - flex: "0 0 auto", 326 - paddingRight: "24px", 327 - }} 328 - onChange={(e) => { 329 - setServerInfo({ 330 - ...serverInfo, 331 - selectedDomain: e.target.value, 332 - }); 333 - }} 334 - value={ 335 - serverInfo.selectedDomain || 336 - serverInfo.availableUserDomains[0] 337 - } 338 - > 339 - {serverInfo.availableUserDomains.map((d) => ( 340 - <option key={d} value={d}> 341 - .{d.startsWith(".") ? d.substring(1) : d} 342 - </option> 343 - ))} 344 - </select> 345 - ) : ( 346 - <span className="handle-suffix"> 347 - {(() => { 348 - const d = 349 - serverInfo.availableUserDomains?.[0] || "bsky.social"; 350 - return d.startsWith(".") ? d : `.${d}`; 351 - })()} 352 - </span> 353 - )} 354 206 </div> 355 - </div> 356 - 357 - {error && ( 358 - <div className="error-message"> 359 - <AlertCircle size={14} /> {error} 207 + <div className="provider-info"> 208 + <h3>{RECOMMENDED_PROVIDER.name}</h3> 209 + <span>{RECOMMENDED_PROVIDER.description}</span> 360 210 </div> 361 - )} 211 + <ChevronRight size={16} className="provider-arrow" /> 212 + </button> 213 + </div> 362 214 363 - <button 364 - type="submit" 365 - className="btn btn-primary full-width" 366 - disabled={loading} 367 - > 368 - {loading ? "Creating Account..." : "Create Account"} 369 - </button> 215 + <button 216 + type="button" 217 + className="signup-toggle-others" 218 + onClick={() => setShowOtherProviders(!showOtherProviders)} 219 + > 220 + {showOtherProviders ? "Hide other options" : "More options"} 221 + <ChevronRight 222 + size={14} 223 + className={`toggle-chevron ${showOtherProviders ? "open" : ""}`} 224 + /> 225 + </button> 370 226 371 - <p className="legal-text"> 372 - By creating an account, you agree to {selectedProvider?.name} 373 - &apos;s{" "} 374 - {serverInfo.links?.termsOfService ? ( 375 - <a 376 - href={serverInfo.links.termsOfService} 377 - target="_blank" 378 - rel="noopener noreferrer" 379 - style={{ color: "var(--accent)" }} 227 + {showOtherProviders && ( 228 + <div className="provider-grid"> 229 + {OTHER_PROVIDERS.map((p) => ( 230 + <button 231 + key={p.id} 232 + className="provider-card" 233 + onClick={() => handleProviderSelect(p)} 380 234 > 381 - Terms of Service 382 - </a> 383 - ) : ( 384 - "Terms of Service" 385 - )} 386 - . 387 - </p> 388 - </form> 235 + <div className={`provider-icon ${p.wide ? "wide" : ""}`}> 236 + {p.Icon ? ( 237 + <p.Icon size={32} /> 238 + ) : ( 239 + <span className="provider-initial">{p.name[0]}</span> 240 + )} 241 + </div> 242 + <div className="provider-info"> 243 + <h3>{p.name}</h3> 244 + <span>{p.description}</span> 245 + </div> 246 + <ChevronRight size={16} className="provider-arrow" /> 247 + </button> 248 + ))} 249 + </div> 250 + )} 389 251 </div> 390 252 )} 391 253 </div>
+74
web/src/css/modals.css
··· 10 10 animation: fadeIn 0.15s ease-out; 11 11 } 12 12 13 + .spinner { 14 + animation: spin 1s linear infinite; 15 + } 16 + 17 + @keyframes spin { 18 + from { 19 + transform: rotate(0deg); 20 + } 21 + 22 + to { 23 + transform: rotate(360deg); 24 + } 25 + } 26 + 13 27 .modal-container { 14 28 background: var(--bg-secondary); 15 29 border-radius: var(--radius-lg); ··· 74 88 from { 75 89 opacity: 0; 76 90 } 91 + 77 92 to { 78 93 opacity: 1; 79 94 } ··· 84 99 opacity: 0; 85 100 transform: scale(0.96) translateY(-8px); 86 101 } 102 + 87 103 to { 88 104 opacity: 1; 89 105 transform: scale(1) translateY(0); ··· 379 395 380 396 .provider-arrow { 381 397 color: var(--text-tertiary); 398 + } 399 + 400 + .signup-recommended { 401 + position: relative; 402 + margin-bottom: var(--spacing-md); 403 + } 404 + 405 + .signup-recommended-badge { 406 + position: absolute; 407 + top: -8px; 408 + left: 12px; 409 + background: var(--accent); 410 + color: white; 411 + font-size: 0.7rem; 412 + font-weight: 600; 413 + padding: 2px 8px; 414 + border-radius: var(--radius-sm); 415 + text-transform: uppercase; 416 + letter-spacing: 0.5px; 417 + z-index: 1; 418 + } 419 + 420 + .provider-card-featured { 421 + border-color: var(--accent); 422 + background: var(--accent-subtle); 423 + } 424 + 425 + .provider-card-featured:hover { 426 + border-color: var(--accent); 427 + background: var(--bg-tertiary); 428 + } 429 + 430 + .signup-toggle-others { 431 + display: flex; 432 + align-items: center; 433 + justify-content: center; 434 + gap: 6px; 435 + width: 100%; 436 + padding: 10px; 437 + background: transparent; 438 + border: none; 439 + color: var(--text-secondary); 440 + font-size: 0.85rem; 441 + cursor: pointer; 442 + transition: color 0.15s; 443 + } 444 + 445 + .signup-toggle-others:hover { 446 + color: var(--text-primary); 447 + } 448 + 449 + .toggle-chevron { 450 + transition: transform 0.2s ease; 451 + transform: rotate(90deg); 452 + } 453 + 454 + .toggle-chevron.open { 455 + transform: rotate(-90deg); 382 456 } 383 457 384 458 .signup-form {
+55 -52
web/src/pages/Feed.jsx
··· 1 - import { useState, useEffect, useMemo } from "react"; 1 + import { useState, useEffect, useMemo, useCallback } from "react"; 2 2 import { useSearchParams } from "react-router-dom"; 3 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 4 import BookmarkCard from "../components/BookmarkCard"; ··· 45 45 46 46 const { user } = useAuth(); 47 47 48 - const fetchFeed = async (isLoadMore = false) => { 49 - try { 50 - if (isLoadMore) { 51 - setLoadingMore(true); 52 - } else { 53 - setLoading(true); 54 - } 48 + const fetchFeed = useCallback( 49 + async (isLoadMore = false) => { 50 + try { 51 + if (isLoadMore) { 52 + setLoadingMore(true); 53 + } else { 54 + setLoading(true); 55 + } 55 56 56 - let creatorDid = ""; 57 + let creatorDid = ""; 57 58 58 - if (feedType === "my-feed") { 59 - if (user?.did) { 60 - creatorDid = user.did; 61 - } else { 62 - setAnnotations([]); 63 - setLoading(false); 64 - setLoadingMore(false); 65 - return; 59 + if (feedType === "my-feed") { 60 + if (user?.did) { 61 + creatorDid = user.did; 62 + } else { 63 + setAnnotations([]); 64 + setLoading(false); 65 + setLoadingMore(false); 66 + return; 67 + } 66 68 } 67 - } 68 69 69 - const motivationMap = { 70 - commenting: "commenting", 71 - highlighting: "highlighting", 72 - bookmarking: "bookmarking", 73 - }; 74 - const motivation = motivationMap[filter] || ""; 75 - const limit = 50; 76 - const offset = isLoadMore ? annotations.length : 0; 70 + const motivationMap = { 71 + commenting: "commenting", 72 + highlighting: "highlighting", 73 + bookmarking: "bookmarking", 74 + }; 75 + const motivation = motivationMap[filter] || ""; 76 + const limit = 50; 77 + const offset = isLoadMore ? annotations.length : 0; 77 78 78 - const data = await getAnnotationFeed( 79 - limit, 80 - offset, 81 - tagFilter || "", 82 - creatorDid, 83 - feedType, 84 - motivation, 85 - ); 79 + const data = await getAnnotationFeed( 80 + limit, 81 + offset, 82 + tagFilter || "", 83 + creatorDid, 84 + feedType, 85 + motivation, 86 + ); 86 87 87 - const newItems = data.items || []; 88 - if (newItems.length < limit) { 89 - setHasMore(false); 90 - } else { 91 - setHasMore(true); 92 - } 88 + const newItems = data.items || []; 89 + if (newItems.length < limit) { 90 + setHasMore(false); 91 + } else { 92 + setHasMore(true); 93 + } 93 94 94 - if (isLoadMore) { 95 - setAnnotations((prev) => [...prev, ...newItems]); 96 - } else { 97 - setAnnotations(newItems); 95 + if (isLoadMore) { 96 + setAnnotations((prev) => [...prev, ...newItems]); 97 + } else { 98 + setAnnotations(newItems); 99 + } 100 + } catch (err) { 101 + setError(err.message); 102 + } finally { 103 + setLoading(false); 104 + setLoadingMore(false); 98 105 } 99 - } catch (err) { 100 - setError(err.message); 101 - } finally { 102 - setLoading(false); 103 - setLoadingMore(false); 104 - } 105 - }; 106 + }, 107 + [tagFilter, feedType, filter, user, annotations.length], 108 + ); 106 109 107 110 useEffect(() => { 108 111 fetchFeed(false); 109 - }, [tagFilter, feedType, filter, user]); 112 + }, [fetchFeed]); 110 113 111 114 const deduplicatedAnnotations = useMemo(() => { 112 115 const inCollectionUris = new Set();