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

Configure Feed

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

better signup flow and margin pds

scanash00 75b4aa8e 8f210ad0

+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();