+332
-123
src/index.ts
+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>