diff options
| author | Adam Soltys <[email protected]> | 2026-09-24 07:35:56 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-24 07:35:56 -0700 |
| commit | 7fae8198ef36396e4c65e66597d8e6e6f74ab1d1 (patch) | |
| tree | c651eccbde76ec2bea34793993da71e0850ea3a8 | |
| parent | f6fd9f998831330e3a8819e294cdd8d15c1a87f6 (diff) | |
| download | coinosv3-7fae8198ef36396e4c65e66597d8e6e6f74ab1d1.tar.gz coinosv3-7fae8198ef36396e4c65e66597d8e6e6f74ab1d1.zip | |
Keep thread avatars and viewport stable on refresh
| -rw-r--r-- | src/features/messages.js | 57 | ||||
| -rw-r--r-- | src/style.css | 3 | ||||
| -rw-r--r-- | src/thread-cache.js | 27 | ||||
| -rw-r--r-- | tools/thread-cache-test.js | 11 | ||||
| -rw-r--r-- | tools/thread-reply-test.js | 13 |
5 files changed, 103 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 89f3bf4..35cf19c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -519,7 +519,7 @@ export function messagesFeature(ctx) { if (repaintTimer) return; repaintTimer = setTimeout(() => { repaintTimer = null; - if (ui.screen === 'wallet') render(); + if (ui.screen === 'wallet') renderThreadStable(); }, 80); }; @@ -680,6 +680,7 @@ export function messagesFeature(ctx) { } profiles.set(pk, entry); persistProfile(pk, entry); + persistThreadProfile(pk); if (patch.thumb) scheduleRepaint(); // Every warmed face gets a turn; the old concurrency guard silently // discarded everyone after the first three in a batch. @@ -812,6 +813,7 @@ export function messagesFeature(ctx) { persistPage('full', pk, m); } persistProfile(pk, entry); + persistThreadProfile(pk); preloadPicture(entry); return true; } @@ -2813,6 +2815,8 @@ export function messagesFeature(ctx) { ? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2)) : p && p.loading && !p.picture ? h('div', { class: cls + ' fallback loading' }) + : p === null && ui.noteThread + ? h('div', { class: cls + ' fallback loading' }) : p === null ? fallbackAvatar(h, pk, null, cls) : p.picture @@ -5683,8 +5687,39 @@ export function messagesFeature(ctx) { // ---- thread view: a note in its conversation ---------------------------- const threadCache = new Map(); // root id -> { status, root, replies } const threadStore = createThreadStore(); - const persistThread = (c) => threadStore.save(c, c.focusId); + const persistThread = (c) => { + if (!c.root) return; + const faces = {}; + for (const ev of [c.root, ...c.replies]) { + const p = profiles.get(ev.pubkey); + if (p && (p.name || p.picture)) faces[ev.pubkey] = p; + } + threadStore.save(c, c.focusId, faces); + }; + function persistThreadProfile(pk) { + const c = ui.noteThread && threadCache.get(ui.noteThread.rootId); + if (c?.root && [c.root, ...c.replies].some((ev) => ev.pubkey === pk)) persistThread(c); + } const noteSep = () => h('div', { style: 'height:1px;background:var(--border,rgba(128,128,128,.18));margin:0 -14px' }); + function renderThreadStable() { + if (!ui.noteThread || ui.noteThread.scrollPending || typeof document === 'undefined') { render(); return; } + const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')]; + const anchor = rows.find((el) => el.getAttribute('data-focus-note') === '1' + && el.getBoundingClientRect().top >= 0 && el.getBoundingClientRect().top < innerHeight) + || rows.find((el) => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; }); + if (!anchor) { render(); return; } + const id = anchor.getAttribute('data-zap-post'); + const top = anchor.getBoundingClientRect().top; + render(); + const settle = () => { + if (!ui.noteThread || ui.noteThread.scrollPending) return; + const next = [...document.querySelectorAll('.thread-page [data-zap-post]')] + .find((el) => el.getAttribute('data-zap-post') === id); + if (next) window.scrollBy(0, next.getBoundingClientRect().top - top); + }; + settle(); + requestAnimationFrame(settle); + } function rootIdOf(ev) { const es = ev.tags.filter((x) => x[0] === 'e'); const marked = es.find((x) => x[3] === 'root'); @@ -5709,6 +5744,18 @@ export function messagesFeature(ctx) { // conversation synchronously, including the canonical root discovered // by climbing a reply-only chain on the previous visit. const stored = threadStore.find(seed.id) || threadStore.find(requestedRootId); + // The event cache is public and survives profile-cache eviction. Restore + // its author faces before the first thread row is painted on refresh. + if (stored?.profiles) { + warmProfiles(); + for (const [pk, face] of Object.entries(stored.profiles)) { + const current = profiles.get(pk); + if (!current || (current.eventAt || 0) < (face.eventAt || 0) + || (current.eventAt === face.eventAt && !current.thumb && face.thumb)) { + profiles.set(pk, { ...current, ...face }); + } + } + } const rootId = stored?.rootId || requestedRootId; c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [] }; @@ -5765,7 +5812,7 @@ export function messagesFeature(ctx) { c.status = 'ready'; persistThread(c); if (ui.noteThread && ui.noteThread.rootId === c.rootId) { - render(); + renderThreadStable(); // The inline reply box may have MOVED on this render (it slots under // the focused note once that note exists) — a focus taken before the // load finished died with the old position. Re-take it if the reply @@ -5778,7 +5825,7 @@ export function messagesFeature(ctx) { } })().catch(() => { c.status = 'ready'; - if (ui.noteThread && ui.noteThread.rootId === c.rootId) render(); + if (ui.noteThread && ui.noteThread.rootId === c.rootId) renderThreadStable(); }); return c; } @@ -6016,7 +6063,7 @@ export function messagesFeature(ctx) { try { el.scrollIntoView({ block: 'center' }); } catch {} }, 60); } - return h('div', { class: 'col', style: 'gap:16px' }, + return h('div', { class: 'col thread-page', style: 'gap:16px' }, // full header: search/chat/settings stay reachable mid-thread (only // the public no-wallet surface drops the action row) ctx.brandHeader(!ui.pubProf && wallet.loaded), diff --git a/src/style.css b/src/style.css index 311f6a2..d9f202e 100644 --- a/src/style.css +++ b/src/style.css @@ -607,7 +607,8 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } /* The row under a post: reply, boost, quote, react, zap — spread across the post's full width so each is a comfortable target, quiet until one carries a count or you've used it. */ -.note-acts { margin-top: 8px; justify-content: space-between; gap: 4px; max-width: 340px; } +.note-acts { margin-top: 8px; gap: 4px; width: min(100%, 340px); } +.note-acts > .note-act { flex: 1 1 0; min-width: 0; justify-content: center; padding-inline: 2px; } /* server comparison table (Settings → Advanced → Ark server) */ .cmp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } .cmp-table th { text-align: left; font-weight: 600; padding: 4px 6px; border-bottom: 1px solid var(--line); } diff --git a/src/thread-cache.js b/src/thread-cache.js index 6f27332..f255e3a 100644 --- a/src/thread-cache.js +++ b/src/thread-cache.js @@ -11,6 +11,18 @@ const validNote = (e) => e?.kind === 1 && typeof e.id === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags) && e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string')); const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); +const savedProfile = (p) => { + if (!p || typeof p !== 'object') return null; + const name = typeof p.name === 'string' ? p.name.slice(0, 120) : null; + const picture = typeof p.picture === 'string' ? p.picture.slice(0, 1000) : null; + if (!name && !picture) return null; + const out = { name, picture, eventAt: Number.isFinite(p.eventAt) ? p.eventAt : 0, t: Number.isFinite(p.t) ? p.t : 0 }; + if (picture && p.thumbFor === picture && typeof p.thumb === 'string' + && p.thumb.length <= 16000 && /^data:image\/(?:webp|jpeg|png);base64,/.test(p.thumb)) { + out.thumb = p.thumb; out.thumbFor = picture; out.thumbPx = p.thumbPx || 0; + } + return out; +}; export function createThreadStore(storage) { if (storage === undefined) { try { storage = globalThis.localStorage; } catch {} } @@ -29,7 +41,7 @@ export function createThreadStore(storage) { find(id) { return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null; }, - save(thread, focusId) { + save(thread, focusId, profiles = {}) { if (!validNote(thread.root)) return; const events = new Map([thread.root, ...thread.replies.filter(validNote)].map((e) => [e.id, e])); const keep = new Map([[thread.root.id, thread.root]]); @@ -47,7 +59,20 @@ export function createThreadStore(storage) { if (keep.size >= MAX_EVENTS) break; visit(e.id); } + const previous = read().find((c) => c.rootId === thread.root.id); + const authors = [...new Set([...keep.values()].map((e) => e.pubkey))].slice(0, 24); + const faces = {}; + for (const pk of authors) { + const fresh = savedProfile(profiles[pk]); + const old = savedProfile(previous?.profiles?.[pk]); + const face = !fresh || (old && old.eventAt > fresh.eventAt) ? old : fresh; + if (face === fresh && old?.thumb && !fresh.thumb && old.picture === fresh.picture) { + face.thumb = old.thumb; face.thumbFor = old.thumbFor; face.thumbPx = old.thumbPx; + } + if (face) faces[pk] = face; + } const entry = { rootId: thread.root.id, root: savedNote(thread.root), + profiles: faces, replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote) }; const entries = [entry, ...read().filter((c) => c.rootId !== entry.rootId)].slice(0, MAX_THREADS); let json = JSON.stringify(entries); diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index fd3f5c8..14e5da6 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -19,9 +19,18 @@ check('a fresh store restores full content and tags by any viewed event', () => const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] }; store.save({ root, replies: [parent, long] }, long.id); const restored = createThreadStore(storage).find(long.id); - assert.deepEqual(restored, { rootId: root.id, root, replies: [long, parent] }); + assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, replies: [long, parent] }); assert.equal(store.find(parent.id).root.id, root.id); }); +check('thread authors keep their names and local avatar thumbnails across reloads', () => { + const thumb = 'data:image/webp;base64,AAAA'; + const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50, + thumbFor: 'https://example.com/face.webp', thumb, thumbPx: 144 }; + store.save({ root, replies: [parent, reply] }, reply.id, { [root.pubkey]: face }); + store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh must keep the face + assert.deepEqual(createThreadStore(storage).find(reply.id).profiles[root.pubkey], + { ...face, t: 0 }); +}); check('a large thread retains the selected reply and its ancestors', () => { const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']])); store.save({ root, replies: [...siblings, parent, reply] }, reply.id); diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js index c125ef8..39f2894 100644 --- a/tools/thread-reply-test.js +++ b/tools/thread-reply-test.js @@ -6,6 +6,7 @@ const app = await Bun.file('src/app.js').text(); const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); const entry = ` import { messagesFeature } from './src/features/messages.js'; +import { createThreadStore } from './src/thread-cache.js'; ${dom} const author = 'a'.repeat(64), mine = 'b'.repeat(64), rootId = 'c'.repeat(64); const root = { id: rootId, pubkey: author, kind: 1, content: 'Root post', tags: [], created_at: 1 }; @@ -24,6 +25,10 @@ function render() { feature = messagesFeature({ h, wallet, ui, render, toast() {}, brandHeader: () => null, hook: k => k === 'nostrLoginIdentity' ? { pubkey: mine, signer } : null }); window.test = { ui, reply, other, root, render, waiting, + seedFace: () => createThreadStore().save({ root, replies: [reply] }, reply.id, { + [author]: { name: 'Nostrich', picture: 'https://example.com/nostrich.webp', eventAt: 10, t: Date.now(), + thumbFor: 'https://example.com/nostrich.webp', thumb: 'data:image/png;base64,iVBORw0KGgo=', thumbPx: 144 }, + }), release: () => { for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [root] : []); }, overlay: () => { ui.profilePk = author; ui.profOverThread = true; render(); @@ -88,6 +93,14 @@ try { await page.evaluate(() => { test.ui.noteThread = null; test.ui.profilePk = null; test.render(); document.querySelector('#elsewhere').focus(); }); await new Promise(r => setTimeout(r, 120)); check('deferred focus does not steal focus after leaving the thread', await page.evaluate(() => document.activeElement.id === 'elsewhere')); + await page.evaluate(() => test.seedFace()); + await page.reload(); + await page.waitForSelector('#app .note-act'); + await page.click('#app .note-act'); + check('cached thread author paints with name and local face on first frame', await page.evaluate(() => { + const row = document.querySelector('.thread-page [data-zap-post]'); + return row?.textContent.includes('Nostrich') && row.querySelector('.note-avatar.ava-img')?.style.backgroundImage.includes('data:image/png;base64'); + })); assert.deepEqual(errors, []); console.log('✓ no browser errors'); } finally { await browser.close(); server.stop(true); } |
