dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

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

frontend: hook pagination up

Luna b8215d7c 649ae0ab

+174 -69
+15
frontend/src/App.css
··· 437 437 } 438 438 439 439 440 + /* ── pagination ── */ 441 + 442 + .vouch-count { 443 + font-weight: 400; 444 + font-size: 0.85em; 445 + color: var(--text-dim); 446 + } 447 + 448 + .load-more { 449 + width: 100%; 450 + margin-top: 0.5rem; 451 + font-size: 11px; 452 + padding: 0.4rem 0.8rem; 453 + } 454 + 440 455 /* ── footer ── */ 441 456 442 457 .footer {
+126 -61
frontend/src/App.tsx
··· 175 175 }) { 176 176 const [handle, setHandle] = useState<string | null>(null); 177 177 const [vouches, setVouches] = useState<VouchEntry[] | null>(null); 178 + const [vouchesTotal, setVouchesTotal] = useState(0); 179 + const [vouchesCursor, setVouchesCursor] = useState<string | undefined>(); 178 180 const [vouchesLoading, setVouchesLoading] = useState(true); 181 + const [vouchesLoadingMore, setVouchesLoadingMore] = useState(false); 179 182 const [vouchesError, setVouchesError] = useState<string | null>(null); 180 183 181 184 useEffect(() => { ··· 188 191 setVouchesLoading(true); 189 192 setVouchesError(null); 190 193 try { 191 - setVouches(await listVouches(agent)); 194 + const result = await listVouches(agent); 195 + setVouches(result.vouches); 196 + setVouchesTotal(result.total); 197 + setVouchesCursor(result.cursor); 192 198 } catch (err) { 193 199 setVouchesError(String(err)); 194 200 } 195 201 setVouchesLoading(false); 196 202 }, [agent]); 197 203 204 + const loadMoreVouches = useCallback(async () => { 205 + if (!vouchesCursor) return; 206 + setVouchesLoadingMore(true); 207 + try { 208 + const result = await listVouches(agent, { cursor: vouchesCursor }); 209 + setVouches((prev) => [...(prev ?? []), ...result.vouches]); 210 + setVouchesTotal(result.total); 211 + setVouchesCursor(result.cursor); 212 + } catch (err) { 213 + setVouchesError(String(err)); 214 + } 215 + setVouchesLoadingMore(false); 216 + }, [agent, vouchesCursor]); 217 + 198 218 useEffect(() => { 199 219 refreshVouches(); 200 220 }, [refreshVouches]); ··· 209 229 </div> 210 230 <div className="layout"> 211 231 <aside className="sidebar"> 212 - <h2>Your vouches</h2> 232 + <h2>Your vouches {vouchesTotal > 0 && <span className="vouch-count">({vouchesTotal})</span>}</h2> 213 233 {vouchesError && <div className="error">{vouchesError}</div>} 214 234 {vouchesLoading ? ( 215 235 <p className="muted">Loading...</p> 216 236 ) : vouches !== null && vouches.length === 0 ? ( 217 237 <p className="muted">No vouches yet.</p> 218 238 ) : ( 219 - <ul className="vouch-list"> 220 - {vouches?.map((v) => ( 221 - <li key={v.rkey}> 222 - {v.valid ? ( 223 - <> 224 - <a className="vouch-handle" href={`https://bsky.app/profile/${v.handle ?? v.did}`} target="_blank" rel="noopener noreferrer">{v.handle ?? v.did}</a> 225 - <span className="vouch-date"> 226 - {new Date(v.createdAt).toISOString().slice(0, 10)} 227 - </span> 228 - </> 229 - ) : ( 230 - <span className="vouch-handle vouch-invalid">INVALID</span> 231 - )} 232 - <button 233 - className="vouch-delete" 234 - title="Delete vouch" 235 - onClick={async () => { 236 - if (!confirm(v.valid ? `Remove vouch for ${v.handle ?? v.did}?` : `Delete invalid record?`)) return; 237 - await deleteVouch(agent, v.rkey); 238 - refreshVouches(); 239 - }} 240 - > 241 - &#x1f5d1; 242 - </button> 243 - </li> 244 - ))} 245 - </ul> 239 + <> 240 + <ul className="vouch-list"> 241 + {vouches?.map((v) => ( 242 + <li key={v.rkey}> 243 + {v.valid ? ( 244 + <> 245 + <a className="vouch-handle" href={`https://bsky.app/profile/${v.handle ?? v.did}`} target="_blank" rel="noopener noreferrer">{v.handle ?? v.did}</a> 246 + <span className="vouch-date"> 247 + {new Date(v.createdAt).toISOString().slice(0, 10)} 248 + </span> 249 + </> 250 + ) : ( 251 + <span className="vouch-handle vouch-invalid">INVALID</span> 252 + )} 253 + <button 254 + className="vouch-delete" 255 + title="Delete vouch" 256 + onClick={async () => { 257 + if (!confirm(v.valid ? `Remove vouch for ${v.handle ?? v.did}?` : `Delete invalid record?`)) return; 258 + await deleteVouch(agent, v.rkey); 259 + refreshVouches(); 260 + }} 261 + > 262 + &#x1f5d1; 263 + </button> 264 + </li> 265 + ))} 266 + </ul> 267 + {vouchesCursor && ( 268 + <button 269 + className="load-more" 270 + onClick={loadMoreVouches} 271 + disabled={vouchesLoadingMore} 272 + > 273 + {vouchesLoadingMore ? "Loading..." : "Load more"} 274 + </button> 275 + )} 276 + </> 246 277 )} 247 278 </aside> 248 279 <main className="main-panel"> ··· 372 403 myVouches: VouchEntry[] | null; 373 404 }) { 374 405 const [vouchers, setVouchers] = useState<RemoteVoucher[] | null>(null); 406 + const [total, setTotal] = useState(0); 407 + const [cursor, setCursor] = useState<string | undefined>(); 375 408 const [loading, setLoading] = useState(true); 409 + const [loadingMore, setLoadingMore] = useState(false); 376 410 const [error, setError] = useState<string | null>(null); 377 411 412 + const resolveVouches = useCallback(async (vouches: { creatorDid: string }[]): Promise<RemoteVoucher[]> => { 413 + return Promise.all( 414 + vouches.map(async (v) => { 415 + let handle: string | null = null; 416 + try { 417 + handle = await resolveDidToHandle(v.creatorDid); 418 + } catch { 419 + // leave as null 420 + } 421 + return { did: v.creatorDid, handle }; 422 + }), 423 + ); 424 + }, []); 425 + 378 426 useEffect(() => { 379 427 (async () => { 380 428 setLoading(true); 381 429 setError(null); 382 430 try { 383 - const vouches = await fetchRemoteVouchers(agent); 384 - const resolved: RemoteVoucher[] = await Promise.all( 385 - vouches.map(async (v) => { 386 - let handle: string | null = null; 387 - try { 388 - handle = await resolveDidToHandle(v.creatorDid); 389 - } catch { 390 - // leave as null 391 - } 392 - return { did: v.creatorDid, handle }; 393 - }), 394 - ); 395 - setVouchers(resolved); 431 + const result = await fetchRemoteVouchers(agent); 432 + setVouchers(await resolveVouches(result.vouches)); 433 + setTotal(result.total); 434 + setCursor(result.cursor); 396 435 } catch (err) { 397 436 setError(String(err)); 398 437 } 399 438 setLoading(false); 400 439 })(); 401 - }, [agent]); 440 + }, [agent, resolveVouches]); 441 + 442 + const loadMore = useCallback(async () => { 443 + if (!cursor) return; 444 + setLoadingMore(true); 445 + try { 446 + const result = await fetchRemoteVouchers(agent, { cursor }); 447 + const resolved = await resolveVouches(result.vouches); 448 + setVouchers((prev) => [...(prev ?? []), ...resolved]); 449 + setTotal(result.total); 450 + setCursor(result.cursor); 451 + } catch (err) { 452 + setError(String(err)); 453 + } 454 + setLoadingMore(false); 455 + }, [agent, cursor, resolveVouches]); 402 456 403 457 const alreadyVouched = new Set(myVouches?.filter((v) => v.valid).map((v) => v.did) ?? []); 404 458 405 459 return ( 406 460 <aside className="sidebar sidebar-right"> 407 - <h2>Remote vouches</h2> 461 + <h2>Remote vouches {total > 0 && <span className="vouch-count">({total})</span>}</h2> 408 462 {error && <div className="error">{error}</div>} 409 463 {loading ? ( 410 464 <p className="muted">Loading...</p> 411 465 ) : vouchers !== null && vouchers.length === 0 ? ( 412 466 <p className="muted">No one has vouched for you yet.</p> 413 467 ) : ( 414 - <ul className="vouch-list"> 415 - {vouchers?.map((v) => ( 416 - <li key={v.did}> 417 - <a 418 - className="vouch-handle" 419 - href={`https://bsky.app/profile/${v.handle ?? v.did}`} 420 - target="_blank" 421 - rel="noopener noreferrer" 422 - > 423 - {v.handle ?? v.did} 424 - </a> 425 - {alreadyVouched.has(v.did) && ( 426 - <span className="vouch-mutual">mutual</span> 427 - )} 428 - </li> 429 - ))} 430 - </ul> 468 + <> 469 + <ul className="vouch-list"> 470 + {vouchers?.map((v) => ( 471 + <li key={v.did}> 472 + <a 473 + className="vouch-handle" 474 + href={`https://bsky.app/profile/${v.handle ?? v.did}`} 475 + target="_blank" 476 + rel="noopener noreferrer" 477 + > 478 + {v.handle ?? v.did} 479 + </a> 480 + {alreadyVouched.has(v.did) && ( 481 + <span className="vouch-mutual">mutual</span> 482 + )} 483 + </li> 484 + ))} 485 + </ul> 486 + {cursor && ( 487 + <button 488 + className="load-more" 489 + onClick={loadMore} 490 + disabled={loadingMore} 491 + > 492 + {loadingMore ? "Loading..." : "Load more"} 493 + </button> 494 + )} 495 + </> 431 496 )} 432 497 </aside> 433 498 );
+33 -8
frontend/src/api.ts
··· 62 62 // --- Vouch queries --- 63 63 64 64 // Fetch who vouched for the current user (via appview proxy) 65 - export async function fetchRemoteVouchers(agent: OAuthUserAgent): Promise<AppviewVouchView[]> { 65 + export interface PaginatedRemoteVouches { 66 + vouches: AppviewVouchView[]; 67 + total: number; 68 + cursor?: string; 69 + } 70 + 71 + export async function fetchRemoteVouchers( 72 + agent: OAuthUserAgent, 73 + opts?: { limit?: number; cursor?: string }, 74 + ): Promise<PaginatedRemoteVouches> { 66 75 const rpc = appviewProxyRpc(agent); 67 - const resp = await rpc.get("dev.atvouch.graph.getRemoteVouches" as any, {}); 68 - const data = resp.data as unknown as { vouches: AppviewVouchView[] }; 69 - return data.vouches; 76 + const params: Record<string, unknown> = {}; 77 + if (opts?.limit) params.limit = opts.limit; 78 + if (opts?.cursor) params.cursor = opts.cursor; 79 + const resp = await rpc.get("dev.atvouch.graph.getRemoteVouches" as any, { params }); 80 + const data = resp.data as unknown as PaginatedRemoteVouches; 81 + return data; 70 82 } 71 83 72 84 // Fetch who vouches for an arbitrary DID (via Microcosm reverse graph) ··· 176 188 valid: boolean; 177 189 } 178 190 179 - export async function listVouches(agent: OAuthUserAgent): Promise<VouchEntry[]> { 191 + export interface PaginatedVouches { 192 + vouches: VouchEntry[]; 193 + total: number; 194 + cursor?: string; 195 + } 196 + 197 + export async function listVouches( 198 + agent: OAuthUserAgent, 199 + opts?: { limit?: number; cursor?: string }, 200 + ): Promise<PaginatedVouches> { 180 201 const rpc = appviewProxyRpc(agent); 181 202 182 - const resp = await rpc.get("dev.atvouch.graph.getCurrentUserVouches" as any, {}); 183 - const data = resp.data as unknown as { vouches: AppviewVouchView[] }; 203 + const params: Record<string, unknown> = {}; 204 + if (opts?.limit) params.limit = opts.limit; 205 + if (opts?.cursor) params.cursor = opts.cursor; 206 + 207 + const resp = await rpc.get("dev.atvouch.graph.getCurrentUserVouches" as any, { params }); 208 + const data = resp.data as unknown as { vouches: AppviewVouchView[]; total: number; cursor?: string }; 184 209 185 210 const entries: VouchEntry[] = data.vouches.map((v) => { 186 211 const rkey = v.uri.split("/").pop()!; ··· 204 229 }), 205 230 ); 206 231 207 - return entries; 232 + return { vouches: entries, total: data.total, cursor: data.cursor }; 208 233 } 209 234 210 235 // --- Vouch path checking ---