aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-24 07:35:56 -0700
committerAdam Soltys <[email protected]>2026-09-24 07:35:56 -0700
commit7fae8198ef36396e4c65e66597d8e6e6f74ab1d1 (patch)
treec651eccbde76ec2bea34793993da71e0850ea3a8 /src
parentf6fd9f998831330e3a8819e294cdd8d15c1a87f6 (diff)
downloadcoinosv3-7fae8198ef36396e4c65e66597d8e6e6f74ab1d1.tar.gz
coinosv3-7fae8198ef36396e4c65e66597d8e6e6f74ab1d1.zip
Keep thread avatars and viewport stable on refresh
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js57
-rw-r--r--src/style.css3
-rw-r--r--src/thread-cache.js27
3 files changed, 80 insertions, 7 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);