ANProto over ATProto -- using Bluesky PDSes to store ANProto messages and blobs

ui tweaks and add wiredove pub connections

Changed files
+332 -123
src
+332 -123
src/index.ts
··· 33 33 app.use(express.json({ limit: '10mb' })) 34 34 app.use(express.urlencoded({ extended: true, limit: '2mb' })) 35 35 app.use('/apds', express.static('apds')) 36 + // Keep host consistent during the OAuth loopback flow: the OAuth libraries force 37 + // redirect_uris to use a loopback IP (127.0.0.1), so nudge any localhost traffic 38 + // over to that host up front to avoid mid-flow host swapping/cookie issues. 39 + app.use((req, res, next) => { 40 + if (process.env.NODE_ENV !== 'production' && req.hostname === 'localhost') { 41 + const target = `http://127.0.0.1:${port}${req.originalUrl}` 42 + return res.redirect(302, target) 43 + } 44 + next() 45 + }) 36 46 37 47 const run = async () => { 38 48 const client = await createClient() ··· 52 62 const agent = new Agent(oauthSession) 53 63 54 64 try { 55 - const prof = await agent.getProfile({ actor: session.did }) 56 - profile = prof.data 57 - handle = profile.handle ?? '' 58 - avatarUrl = profile.avatar ?? '' 59 - } catch (e) { 60 - console.warn('Could not fetch profile via getProfile, falling back to record:', e) 61 - } 62 - 63 - if (!avatarUrl || !handle) { 64 - try { 65 - const { data } = await agent.com.atproto.repo.getRecord({ 66 - repo: session.did, 67 - collection: 'app.bsky.actor.profile', 68 - rkey: 'self', 69 - }) 70 - const rec = data.value 71 - profile = { ...rec, ...(profile || {}) } 72 - if (rec?.avatar && !avatarUrl) { 73 - const serviceUrl = new URL((agent as any).service?.toString() ?? 'https://bsky.social/') 74 - const cid = rec.avatar.ref?.toString() ?? '' 75 - if (cid) { 76 - avatarUrl = new URL(`xrpc/com.atproto.sync.getBlob`, serviceUrl).toString() 77 - avatarUrl += `?did=${encodeURIComponent(session.did!)}&cid=${encodeURIComponent(cid)}` 78 - } 65 + const { data } = await agent.com.atproto.repo.getRecord({ 66 + repo: session.did, 67 + collection: 'app.bsky.actor.profile', 68 + rkey: 'self', 69 + }) 70 + const rec = data.value 71 + profile = { ...rec, ...(profile || {}) } 72 + if (rec?.avatar && !avatarUrl) { 73 + const serviceUrl = new URL((agent as any).service?.toString() ?? 'https://bsky.social/') 74 + const cid = rec.avatar.ref?.toString() ?? '' 75 + if (cid) { 76 + avatarUrl = new URL(`xrpc/com.atproto.sync.getBlob`, serviceUrl).toString() 77 + avatarUrl += `?did=${encodeURIComponent(session.did!)}&cid=${encodeURIComponent(cid)}` 79 78 } 80 - } catch (e) { 81 - console.warn('Could not fetch profile record or construct avatar:', e) 82 79 } 80 + } catch (e) { 81 + console.warn('Could not fetch profile record or construct avatar:', e) 83 82 } 84 83 } catch (err) { 85 84 console.error('Error processing session:', err) ··· 261 260 <!DOCTYPE html> 262 261 <html> 263 262 <head> 264 - <title>ANProto over ATProto</title> 263 + <title>ANonAT</title> 265 264 <link rel="preconnect" href="https://fonts.googleapis.com"> 266 265 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 267 266 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> 268 267 <style> 269 - :root { --bg:#f7f7f8; --card:#fff; --border:#e3e3e6; --text:#111; --muted:#666; --accent:#111; } 268 + :root { 269 + --bg:#f7f7f8; 270 + --card:#fff; 271 + --border:#e3e3e6; 272 + --text:#111; 273 + --muted:#666; 274 + --accent:#111; 275 + --surface-soft:#f5f5f7; 276 + --avatar-bg:#ddd; 277 + --pill-bg:#f0f0f2; 278 + --btn-primary-bg: linear-gradient(180deg, #2a2a2f, #111114); 279 + --btn-primary-color:#fff; 280 + --btn-secondary-bg: linear-gradient(180deg, #ffffff, #f2f2f2); 281 + --btn-secondary-color:#111; 282 + --btn-border:#b7b7bb; 283 + --input-bg:#fff; 284 + --textarea-bg:#fff; 285 + } 286 + @media (prefers-color-scheme: dark) { 287 + :root { 288 + --bg:#1a1a1b; 289 + --card:#202023; 290 + --border:#353539; 291 + --text:#f0f0f2; 292 + --muted:#b1b1b7; 293 + --accent:#f0f0f2; 294 + --surface-soft:#2a2a2d; 295 + --input-bg:#2f2f33; 296 + --textarea-bg:#2f2f33; 297 + --avatar-bg:#3a3a3f; 298 + --pill-bg:#242427; 299 + /* Swap button treatments in dark mode */ 300 + --btn-primary-bg: linear-gradient(180deg, #f2f2f2, #dadada); 301 + --btn-primary-color:#111; 302 + --btn-secondary-bg: linear-gradient(180deg, #1c1c1f, #0f0f12); 303 + --btn-secondary-color:#f5f5f5; 304 + --btn-border:#4c4c50; 305 + } 306 + } 270 307 * { box-sizing: border-box; } 271 308 body { font-family: "Inter", system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; } 272 309 header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); background: var(--card); position: sticky; top: 0; } 273 310 .brand { font-weight: 700; letter-spacing: -0.01em; } 274 311 .auth { display: flex; align-items: center; gap: 12px; } 275 312 .login-form { display: flex; align-items: center; gap: 8px; } 276 - .login-form input { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 200px; font-size: 1rem; } 277 - .btn { cursor: pointer; border: 1px solid #b7b7bb; background: linear-gradient(180deg, #2a2a2f, #111114); color: #fff; border-radius: 10px; padding: 8px 12px; font-weight: 600; box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), inset 0 -2px 0 rgba(0,0,0,0.35), inset 0 10px 20px rgba(255,255,255,0.04); } 278 - .btn.secondary { background: linear-gradient(180deg, #ffffff, #f2f2f2); color: var(--text); box-shadow: inset 0 1px 0 rgba(255,255,255,0.7), inset 0 -1px 0 rgba(0,0,0,0.08); } 279 - .btn.sm { padding: 6px 10px; font-size: 1rem; } 280 - .user-chip { display: flex; align-items: center; gap: 10px; background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 6px 10px; } 281 - .avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); background: #ddd; } 313 + .login-form input { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 200px; font-size: 1rem; background: var(--input-bg, var(--card)); color: var(--text); } 314 + .btn { cursor: pointer; border: 1px solid var(--btn-border); background: var(--btn-primary-bg); color: var(--btn-primary-color); border-radius: 10px; padding: 6px 10px; font-weight: 600; font-size: 0.95rem; } 315 + .btn.secondary { background: var(--btn-secondary-bg); color: var(--btn-secondary-color); } 316 + .user-chip { display: flex; align-items: center; gap: 10px; background: var(--card); border: 1px solid var(--border); border-radius: 999px; padding: 6px 10px; } 317 + .avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); background: var(--avatar-bg); } 282 318 .avatar.interactive { cursor: pointer; box-shadow: 0 0 0 0 transparent; transition: box-shadow 120ms ease, transform 120ms ease; } 283 319 .avatar.interactive:hover { box-shadow: 0 0 0 3px rgba(0,0,0,0.05); transform: translateY(-1px); } 284 320 .avatar.profile { width: 72px; height: 72px; } ··· 289 325 .profile-name { font-weight: 700; font-size: 1rem; } 290 326 .name-block { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } 291 327 .name-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } 292 - .name-form input { flex: 1 1 180px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; } 293 - .pubkey-pill { background: #f0f0f2; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; font-size: 1rem; width: fit-content; } 294 - .composer textarea { width: 100%; min-height: 140px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; } 328 + .name-form input { flex: 1 1 180px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; background: var(--input-bg, var(--card)); color: var(--text); } 329 + .pubkey-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } 330 + .pubkey-pill { background: var(--pill-bg); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; font-size: 0.8rem; width: fit-content; font-family: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; } 331 + .composer textarea { width: 100%; min-height: 140px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; background: var(--textarea-bg, var(--card)); color: var(--text); } 295 332 .composer .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 10px; } 296 333 .status { margin-top: 8px; font-size: 0.92rem; color: var(--muted); } 334 + .status-bar { margin-top: 0; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320px; } 297 335 .muted { color: var(--muted); } 298 336 .feed { padding: 0; } 299 337 .feed-list { display: flex; flex-direction: column; gap: 12px; } 300 - .feed-item { border: 1px solid var(--border); border-radius: 12px; padding: 12px 12px 8px; background: #fff; display: grid; grid-template-columns: 44px 1fr; gap: 12px; align-items: flex-start; } 338 + .feed-item { border: 1px solid var(--border); border-radius: 12px; padding: 12px 12px 8px; background: var(--card); display: grid; grid-template-columns: 44px 1fr; gap: 12px; align-items: flex-start; } 301 339 .feed-head { display: flex; justify-content: space-between; align-items: center; font-size: 1rem; color: var(--muted); } 302 - .feed-identity { display: flex; align-items: center; gap: 8px; } 340 + .feed-identity { display: flex; align-items: center; gap: 8px; color: var(--text); } 303 341 .feed-avatar .avatar { width: 40px; height: 40px; } 304 342 .feed-main { min-width: 0; } 305 - .pill { background: #f0f0f2; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 1rem; } 343 + .pill { background: var(--pill-bg); border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 1rem; } 306 344 .key-hint { font-size: 0.85rem; color: var(--muted); margin-right: 8px; } 307 345 .logout { margin-left: 8px; } 308 346 .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } 309 347 .feed-body { margin-top: 6px; line-height: 1.5; } 310 - .feed-body pre { background: #f5f5f7; padding: 8px; border-radius: 8px; overflow-x: auto; } 348 + .feed-body pre { background: var(--surface-soft); padding: 8px; border-radius: 8px; overflow-x: auto; } 311 349 .feed-body blockquote { border-left: 3px solid var(--border); margin: 8px 0; padding-left: 10px; color: var(--muted); } 350 + .key-hint { font-family: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; } 312 351 </style> 313 352 </head> 314 353 <body> 315 354 <header> 316 - <div class="brand">ANProto over ATProto</div> 355 + <div style="display:flex; align-items:center; gap:12px; min-width:0;"> 356 + <a class="brand" href="/" style="color:inherit; text-decoration:none;">ANonAT</a> 357 + <div class="status status-bar" id="conn-log">Ready.</div> 358 + </div> 317 359 <div class="auth"> 318 360 ${ 319 361 loggedIn ··· 326 368 } 327 369 <div> 328 370 <div style="font-weight:600">${displayHandle}</div> 329 - <div style="font-size:0.85rem" class="muted">${did}</div> 330 371 </div> 331 372 <form class="logout" action="/logout" method="POST"> 332 373 <button class="btn secondary" type="submit">Logout</button> ··· 347 388 <form id="composer-form"> 348 389 <div class="profile-row" style="margin-bottom:12px;"> 349 390 <div id="profile-avatar-wrapper"> 350 - <div class="avatar profile" style="background:#ddd;"></div> 391 + <div class="avatar profile" style="background:var(--avatar-bg);"></div> 351 392 </div> 352 393 <div class="profile-meta"> 353 394 <div class="name-block"> 354 395 <div id="profile-name-display" class="profile-name">Anonymous</div> 355 396 <div class="name-form"> 356 397 <input id="name-input" type="text" placeholder="Name yourself" /> 357 - <button class="btn sm" type="button" id="name-save">Save</button> 398 + <button class="btn" type="button" id="name-save">Save</button> 358 399 </div> 359 400 </div> 360 - <div class="pubkey-pill" id="pubkey-display">Loading key...</div> 401 + <div class="pubkey-row"> 402 + <div class="pubkey-pill" id="pubkey-display">Loading key...</div> 403 + <button class="btn secondary" type="button" id="regen-key">New keypair</button> 404 + </div> 361 405 </div> 362 406 </div> 363 407 <input type="file" id="avatar-input" accept="image/*" style="display:none;" /> 364 408 <textarea id="composer-text" name="anmsg" placeholder="What are you doing in this world?"></textarea> 365 409 <div class="actions"> 366 - <button class="btn" type="submit">${loggedIn ? 'Sign & Publish' : 'Sign (login to publish)'}</button> 367 - <button class="btn secondary" type="button" id="regen-key">New keypair</button> 410 + <button class="btn" type="submit">${loggedIn ? 'Publish' : 'Sign (login to publish)'}</button> 368 411 </div> 369 - <div class="status" id="composer-status"></div> 370 412 </form> 371 413 </section> 372 414 415 + <section class="card" id="connections-card"> 416 + <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px;"> 417 + <div class="pill">wss://pub.wiredove.net/</div> 418 + <div id="conn-status" class="muted" style="display:flex; align-items:center; gap:6px;"> 419 + <span id="conn-dot" style="width:10px; height:10px; border-radius:50%; background:#c23;"></span> 420 + </div> 421 + <button class="btn secondary" type="button" id="conn-toggle">Connect</button> 422 + </div> 423 + </section> 424 + 373 425 <section class="feed"> 374 426 <div id="feed-list" class="feed-list"> 375 427 <div class="muted">Loading feed...</div> ··· 383 435 384 436 (async () => { 385 437 const form = document.getElementById('composer-form') 386 - const statusEl = document.getElementById('composer-status') 387 438 const textArea = document.getElementById('composer-text') 388 439 const pubkeyEl = document.getElementById('pubkey-display') 389 440 const regenBtn = document.getElementById('regen-key') ··· 393 444 const nameInput = document.getElementById('name-input') 394 445 const nameSave = document.getElementById('name-save') 395 446 const nameDisplay = document.getElementById('profile-name-display') 447 + let latestEntries = [] 448 + const connStatus = document.getElementById('conn-status') 449 + const connDot = document.getElementById('conn-dot') 450 + const connLog = document.getElementById('conn-log') 451 + const connToggle = document.getElementById('conn-toggle') 452 + let ws = null 453 + const PUB_URL = 'wss://pub.wiredove.net/' 396 454 397 - const setStatus = (text, isError) => { 398 - if (!statusEl) return 399 - statusEl.textContent = text 400 - statusEl.style.color = isError ? 'crimson' : 'inherit' 455 + const logEvent = (text, isError) => { 456 + if (!connLog) return 457 + connLog.textContent = text 458 + connLog.style.color = isError ? 'crimson' : 'inherit' 459 + } 460 + 461 + const setConnState = (state) => { 462 + if (connDot) { 463 + const colors = { connected: '#2d8cf0', connecting: '#f0a22d', disconnected: '#c23', error: '#c23' } 464 + connDot.style.background = colors[state] || '#c23' 465 + } 466 + if (connToggle) { 467 + const label = state === 'connected' ? 'Disconnect' : 'Connect' 468 + connToggle.textContent = label 469 + } 470 + } 471 + 472 + const isWsOpen = (sock) => sock && sock.readyState === WebSocket.OPEN 473 + 474 + const disconnectPub = (reason) => { 475 + if (ws) { 476 + try { 477 + ws.close() 478 + } catch (err) { 479 + // ignore 480 + } 481 + } 482 + ws = null 483 + setConnState('disconnected') 484 + if (reason) logEvent(reason, false) 485 + } 486 + 487 + const connectPub = () => { 488 + if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) { 489 + logEvent('Already connecting/connected', false) 490 + return 491 + } 492 + try { 493 + ws = new WebSocket(PUB_URL) 494 + } catch (err) { 495 + logEvent('WebSocket error: ' + err, true) 496 + setConnState('error') 497 + return 498 + } 499 + setConnState('connecting') 500 + logEvent('Connecting to pub…', false) 501 + 502 + ws.addEventListener('open', () => { 503 + setConnState('connected') 504 + logEvent('Connected to pub', false) 505 + }) 506 + 507 + ws.addEventListener('message', (ev) => { 508 + logEvent('Received message from pub', false) 509 + // TODO: handle pub protocol here (parse ev.data) 510 + }) 511 + 512 + ws.addEventListener('close', () => { 513 + disconnectPub('Disconnected from pub') 514 + }) 515 + 516 + ws.addEventListener('error', (err) => { 517 + console.warn('WS error', err) 518 + setConnState('error') 519 + logEvent('Connection error', true) 520 + }) 401 521 } 402 522 523 + connToggle?.addEventListener('click', () => { 524 + if (isWsOpen(ws) || (ws && ws.readyState === WebSocket.CONNECTING)) { 525 + disconnectPub('Disconnecting…') 526 + } else { 527 + connectPub() 528 + } 529 + }) 530 + 403 531 const ensureKeypair = async () => { 404 532 let pub = await apds.pubkey() 405 533 if (!pub) { ··· 449 577 } 450 578 } catch (err) { 451 579 console.warn('Failed to init profile', err) 452 - setStatus('Could not load local profile', true) 580 + logEvent('Could not load local profile', true) 453 581 } 454 582 } 455 583 ··· 457 585 const file = e.target?.files?.[0] 458 586 if (!file) return 459 587 if (!file.type.startsWith('image/')) { 460 - setStatus('Please choose an image file', true) 588 + logEvent('Please choose an image file', true) 461 589 return 462 590 } 463 591 const maxBytes = 6 * 1024 * 1024 464 592 if (file.size > maxBytes) { 465 - setStatus('Image too large (max 6MB)', true) 593 + logEvent('Image too large (max 6MB)', true) 466 594 return 467 595 } 468 596 ··· 473 601 const canvas = document.createElement('canvas') 474 602 const ctx = canvas.getContext('2d') 475 603 if (!ctx) { 476 - setStatus('Canvas unsupported', true) 604 + logEvent('Canvas unsupported', true) 477 605 return 478 606 } 479 607 const img = new Image() ··· 507 635 } 508 636 const hash = await apds.make(dataUrl) 509 637 await apds.put('image', hash) 510 - setStatus('Avatar updated', false) 638 + logEvent('Avatar updated', false) 511 639 } catch (err) { 512 640 console.warn('Avatar upload failed', err) 513 - setStatus('Avatar upload failed', true) 641 + logEvent('Avatar upload failed', true) 514 642 } 515 643 } 516 644 img.src = result ··· 521 649 nameSave?.addEventListener('click', async () => { 522 650 const val = (nameInput?.value || '').trim() 523 651 if (!val) { 524 - setStatus('Enter a name first', true) 652 + logEvent('Enter a name first', true) 525 653 return 526 654 } 527 655 try { ··· 531 659 nameInput.value = '' 532 660 nameInput.placeholder = val 533 661 } 534 - setStatus('Name saved', false) 662 + logEvent('Name saved', false) 535 663 } catch (err) { 536 664 console.warn('Name save failed', err) 537 - setStatus('Could not save name', true) 665 + logEvent('Could not save name', true) 538 666 } 539 667 }) 540 668 ··· 544 672 const kp = await apds.generate() 545 673 await apds.put('keypair', kp) 546 674 await renderPubkey() 547 - setStatus('New keypair generated', false) 675 + logEvent('New keypair generated', false) 548 676 }) 549 677 550 678 form?.addEventListener('submit', async (e) => { 551 679 e.preventDefault() 552 - setStatus('Signing and publishing...', false) 680 + logEvent('Signing and publishing...', false) 553 681 const content = (textArea?.value || '').trim() 554 682 if (!content) { 555 - setStatus('Message is required', true) 683 + logEvent('Message is required', true) 556 684 return 557 685 } 558 686 ··· 562 690 const protocolHash = await apds.compose(content) 563 691 const anmsg = await apds.get(protocolHash) 564 692 if (!anmsg) { 565 - setStatus('Could not load signed message', true) 693 + logEvent('Could not load signed message', true) 566 694 return 567 695 } 568 696 payload.anmsg = anmsg ··· 573 701 console.warn('Could not compute anhash', err) 574 702 } 575 703 } catch (err) { 576 - setStatus('Signing failed', true) 704 + logEvent('Signing failed', true) 577 705 return 578 706 } 579 707 ··· 587 715 if (!res.ok) { 588 716 throw new Error(data?.error || 'Publish failed') 589 717 } 590 - setStatus('Saved with rkey ' + data.rkey, false) 718 + logEvent('Saved with rkey ' + data.rkey, false) 591 719 if (textArea) textArea.value = '' 592 720 } catch (err) { 593 - setStatus(err?.message || 'Publish failed', true) 721 + logEvent(err?.message || 'Publish failed', true) 594 722 } 595 - await refreshFeed() 723 + await renderRoute() 596 724 }) 597 725 598 726 const renderFeedItems = (items) => { ··· 608 736 const avatarUrl = item.avatarUrl || '' 609 737 const time = item.time || '' 610 738 const bodyHtml = item.bodyHtml || '' 611 - const hash = item.hash || '' 739 + const hashLink = item.hashLink || '' 612 740 const keyHint = item.keyHint || '' 741 + const profileLink = item.author ? '#' + encodeURIComponent(item.author) : '' 613 742 return ( 614 743 '<div class="feed-item">' + 615 744 '<div class="feed-avatar">' + ··· 618 747 '<div class="feed-main">' + 619 748 '<div class="feed-head">' + 620 749 '<div class="feed-identity">' + 621 - '<div style="font-weight:600; color:#111;">' + displayName + '</div>' + 750 + (profileLink 751 + ? '<a href="' + profileLink + '" style="font-weight:600; color:var(--text); text-decoration:none;">' + displayName + '</a>' 752 + : '<div style="font-weight:600; color:var(--text);">' + displayName + '</div>') + 622 753 '</div>' + 623 754 '<div style="display:flex; align-items:center; gap:6px;">' + 624 755 (keyHint ? '<span class="key-hint">' + keyHint + '</span>' : '') + 625 - (hash 626 - ? '<a class="muted" href="' + hash + '" style="text-decoration:none; font-size:0.9rem;">' + time + '</a>' 756 + (hashLink 757 + ? '<a class="muted" href="' + hashLink + '" style="text-decoration:none; font-size:0.9rem;">' + time + '</a>' 627 758 : '<span class="muted">' + time + '</span>') + 628 759 '</div>' + 629 760 '</div>' + ··· 636 767 feedList.innerHTML = html 637 768 } 638 769 639 - async function refreshFeed() { 770 + const routeFromHash = () => { 771 + const raw = window.location.hash.replace(/^#/, '').trim() 772 + if (!raw) return { id: '' } 773 + return { id: decodeURIComponent(raw) } 774 + } 775 + 776 + const loadEntries = async () => { 777 + const entries = (await apds.query()) || [] 778 + latestEntries = entries 779 + return entries 780 + } 781 + 782 + const mapEntry = async (msg) => { 783 + let bodyForRender = msg.text || '' 784 + let displayName = msg.author || '' 785 + let avatarUrl = '' 786 + try { 787 + const parsed = await apds.parseYaml(msg.text || '') 788 + bodyForRender = parsed?.body ?? '' 789 + if (parsed?.name && typeof parsed.name === 'string') { 790 + displayName = parsed.name 791 + } 792 + if (parsed?.image && typeof parsed.image === 'string') { 793 + try { 794 + const maybeDataUrl = 795 + parsed.image.startsWith('data:') || parsed.image.startsWith('http') 796 + ? parsed.image 797 + : await apds.get(parsed.image) 798 + avatarUrl = maybeDataUrl || '' 799 + } catch (err) { 800 + console.warn('Could not load image from yaml', err) 801 + } 802 + } 803 + } catch (err) { 804 + // fall back to raw text 805 + } 806 + if ((!displayName || displayName === msg.author) && msg.author) { 807 + displayName = msg.author.slice(0, 10) 808 + } 809 + if (!avatarUrl) { 810 + try { 811 + const avatarEl = await apds.visual(msg.author || '') 812 + avatarUrl = avatarEl?.src || '' 813 + } catch (err) { 814 + // keep empty 815 + } 816 + } 817 + return { 818 + hash: msg.hash, 819 + hashLink: msg.hash ? '#' + encodeURIComponent(msg.hash) : '', 820 + bodyHtml: marked.parse(bodyForRender), 821 + author: msg.author || '', 822 + displayName, 823 + avatarUrl, 824 + keyHint: msg.author ? msg.author.slice(0, 5) : '', 825 + time: msg.ts ? await apds.human(msg.ts) : '', 826 + } 827 + } 828 + 829 + async function renderFeedView(entriesMaybe) { 640 830 if (!feedList) return 641 831 feedList.innerHTML = '<div class="muted">Loading feed...</div>' 642 832 try { 643 - const entries = (await apds.query()) || [] 833 + const entries = entriesMaybe || (await loadEntries()) 644 834 if (!entries.length) { 645 835 renderFeedItems([]) 646 836 return 647 837 } 648 - const mapped = await Promise.all( 649 - [...entries].reverse().map(async (msg) => { 650 - let bodyForRender = msg.text || '' 651 - let displayName = msg.author || '' 652 - let avatarUrl = '' 653 - try { 654 - const parsed = await apds.parseYaml(msg.text || '') 655 - bodyForRender = parsed?.body ?? '' 656 - if (parsed?.name && typeof parsed.name === 'string') { 657 - displayName = parsed.name 658 - } 659 - if (parsed?.image && typeof parsed.image === 'string') { 660 - try { 661 - const maybeDataUrl = 662 - parsed.image.startsWith('data:') || parsed.image.startsWith('http') 663 - ? parsed.image 664 - : await apds.get(parsed.image) 665 - avatarUrl = maybeDataUrl || '' 666 - } catch (err) { 667 - console.warn('Could not load image from yaml', err) 668 - } 669 - } 670 - } catch (err) { 671 - // fall back to raw text 672 - } 673 - if (!avatarUrl) { 674 - try { 675 - const avatarEl = await apds.visual(msg.author || '') 676 - avatarUrl = avatarEl?.src || '' 677 - } catch (err) { 678 - // keep empty 679 - } 680 - } 681 - return { 682 - hash: msg.hash, 683 - bodyHtml: marked.parse(bodyForRender), 684 - author: msg.author || '', 685 - displayName, 686 - avatarUrl, 687 - keyHint: msg.author ? msg.author.slice(0, 5) : '', 688 - time: msg.ts ? await apds.human(msg.ts) : '', 689 - } 690 - }), 691 - ) 838 + const mapped = await Promise.all([...entries].reverse().map(mapEntry)) 692 839 renderFeedItems(mapped) 693 840 } catch (err) { 694 841 console.warn('Failed to render feed', err) ··· 696 843 } 697 844 } 698 845 699 - await refreshFeed() 700 - setInterval(refreshFeed, 20000) 846 + async function renderProfileView(author, entriesMaybe) { 847 + if (!feedList) return 848 + feedList.innerHTML = '<div class="muted">Loading profile...</div>' 849 + try { 850 + const entries = entriesMaybe || (latestEntries.length ? latestEntries : await loadEntries()) 851 + const filtered = entries.filter((e) => e.author === author) 852 + if (!filtered.length) { 853 + feedList.innerHTML = '<div class="muted">No posts for this profile.</div>' 854 + return 855 + } 856 + const mapped = await Promise.all([...filtered].reverse().map(mapEntry)) 857 + renderFeedItems(mapped) 858 + } catch (err) { 859 + console.warn('Failed to render profile', err) 860 + feedList.innerHTML = '<div class="muted">Could not load profile.</div>' 861 + } 862 + } 863 + 864 + async function renderPostView(targetHash, entriesMaybe) { 865 + if (!feedList) return 866 + feedList.innerHTML = '<div class="muted">Loading post...</div>' 867 + try { 868 + const entries = entriesMaybe || (latestEntries.length ? latestEntries : await loadEntries()) 869 + const found = entries.find((e) => e.hash === targetHash) 870 + if (!found) { 871 + feedList.innerHTML = '<div class="muted">Post not found.</div>' 872 + return 873 + } 874 + const mapped = await mapEntry(found) 875 + renderFeedItems([mapped]) 876 + } catch (err) { 877 + console.warn('Failed to render post', err) 878 + feedList.innerHTML = '<div class="muted">Could not load post.</div>' 879 + } 880 + } 881 + 882 + async function renderRoute() { 883 + const route = routeFromHash() 884 + const entries = latestEntries.length ? latestEntries : await loadEntries() 885 + if (!route.id) { 886 + await renderFeedView(entries) 887 + return 888 + } 889 + const foundHash = entries.find((e) => e.hash === route.id) 890 + if (foundHash) { 891 + await renderPostView(route.id, entries) 892 + return 893 + } 894 + const authorMatches = entries.filter((e) => e.author === route.id) 895 + if (authorMatches.length) { 896 + await renderProfileView(route.id, entries) 897 + return 898 + } 899 + feedList.innerHTML = '<div class="muted">Nothing found for this route.</div>' 900 + } 901 + 902 + window.addEventListener('hashchange', renderRoute) 903 + await renderRoute() 904 + setInterval(async () => { 905 + const route = routeFromHash() 906 + if (!route.id) { 907 + await renderFeedView() 908 + } 909 + }, 20000) 701 910 })() 702 911 </script> 703 912 </body>