aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js4
-rw-r--r--src/app.js18
-rw-r--r--src/features/index.js8
-rw-r--r--src/features/messages.js39
-rw-r--r--src/thread-cache.js14
-rw-r--r--tools/boot-signin-flash-test.js26
-rw-r--r--tools/thread-cache-test.js7
-rw-r--r--tools/thread-reply-test.js15
8 files changed, 111 insertions, 20 deletions
diff --git a/build.js b/build.js
index dcb7870..98ba69c 100644
--- a/build.js
+++ b/build.js
@@ -262,7 +262,9 @@ const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'no
// NOT deferrable: ark (balance on the home screen), messages (deep-linked
// profiles + the boot shell), gifts is deferrable because app.js awaits the
// deferred load before the bootUrl check when the path looks like a gift.
-const DEFERRED_FEATURES = ['gifts', 'nwc', 'hats', 'pos'];
+// Hats decorate the header avatar on the first frame. Loading their art in a
+// later chunk makes the header change after the rest of the page has painted.
+const DEFERRED_FEATURES = ['gifts', 'nwc', 'pos'];
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_FEATURES);
diff --git a/src/app.js b/src/app.js
index 4e2d039..5087def 100644
--- a/src/app.js
+++ b/src/app.js
@@ -772,7 +772,7 @@ const BOOT_NAV = (() => { try { return (history.state && history.state.nav) || n
// seconds pass on the unlock screen), boot renders share one history slot.
let bootPending = !!BOOT_NAV;
if (bootPending) setTimeout(() => { bootPending = false; }, 5000);
-function restoreNavFromHistory() {
+function restoreNavFromHistory(renderNow = true) {
try {
const nav = BOOT_NAV;
if (!nav || ui.screen !== 'wallet') return;
@@ -781,8 +781,12 @@ function restoreNavFromHistory() {
ui.lightbox = null; nav.lightbox = null; // an object URL doesn't survive a reload
navStack = [nav];
navIndex = 0;
+ // A reload is a continuation of the same page, not a navigation. Do not
+ // replay the full-page entrance animation as boot state settles.
+ ui.navAnimSkip = true;
+ _navAt = -1e9;
history.replaceState({ nav, i: 0 }, '', navUrl(nav)); // undo any boot-render clobber (and the boot's path tidy)
- render();
+ if (renderNow) render();
} catch {}
}
@@ -1470,6 +1474,9 @@ async function activateAccount(acc, opts = {}) {
ui.sendResult = null;
ui.giftMode = false;
ui.offlineFallback = false;
+ // Restore the target page before the wallet's first paint. Painting home
+ // first and then replacing it with a thread made refresh visibly flash.
+ if (opts.restoreNav) restoreNavFromHistory(false);
render();
if (ui.txDetail) openTx(ui.txDetail); // restored a tx detail — fill in fee/details if missing
@@ -1800,14 +1807,13 @@ function restoreAccountsState() {
if (sess && Array.isArray(sess.accounts) && sess.accounts.length) {
accounts = sess.accounts;
const active = accounts.find((a) => a.id === sess.activeId) || accounts[0];
- activateAccount(active);
- restoreNavFromHistory();
+ activateAccount(active, { restoreNav: true });
return true;
}
if (hasVault()) {
// A blank (optional) vault password unlocks seamlessly with no prompt.
if (attemptVaultUnlock('')) {
- if (accounts.length) activateAccount(preferredAccount(), { fresh: true });
+ if (accounts.length) activateAccount(preferredAccount(), { fresh: true, restoreNav: true });
else { ui.screen = 'unlock'; render(); }
return true;
}
@@ -1816,7 +1822,7 @@ function restoreAccountsState() {
const watch = loadWatchAccounts();
if (watch.length) {
accounts = watch.slice();
- activateAccount(preferredAccount(), { fresh: true });
+ activateAccount(preferredAccount(), { fresh: true, restoreNav: true });
return true;
}
return false;
diff --git a/src/features/index.js b/src/features/index.js
index 73369c3..26324dc 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -6,7 +6,7 @@
//
// This checked-in copy must stay byte-equivalent (comments aside) to
// featureIndexSource(all features) — tools/feature-registry-check.js enforces
-// it. Deferred features (gifts, nwc, hats) hold their array position with an
+// it. Deferred features (gifts, nwc, pos) hold their array position with an
// empty placeholder — hook precedence is position — and are filled in place
// by loadDeferredFeatures right after boot, as separate chunks in the split
// build. NB order is meaningful where hooks stack: gifts' receive takeover
@@ -18,19 +18,19 @@ import { arkFeature } from './ark.js';
import { nostrLoginFeature } from './nostrlogin.js';
import { namesFeature } from './names.js';
import { zapsFeature } from './zaps.js';
+import { hatsFeature } from './hats.js';
import { syncFeature } from './sync.js';
import { messagesFeature } from './messages.js';
export function buildFeatures(ctx) {
- return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx), {}];
+ return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, hatsFeature(ctx), syncFeature(ctx), messagesFeature(ctx), {}];
}
export async function loadDeferredFeatures(ctx, features) {
const late = [];
await Promise.all([
import('./gifts.js').then((m) => { late.push(features[0] = m.giftsFeature(ctx)); }),
import('./nwc.js').then((m) => { late.push(features[5] = m.nwcFeature(ctx)); }),
- import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); }),
- import('./pos.js').then((m) => { late.push(features[9] = m.posFeature(ctx)); }),
+ import('./pos.js').then((m) => { late.push(features[9] = m.posFeature(ctx)); })
]);
return late;
}
diff --git a/src/features/messages.js b/src/features/messages.js
index 35cf19c..9cc0175 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2975,6 +2975,7 @@ export function messagesFeature(ctx) {
// for different pictures, and an unrelated event must not change its face.
const postReactNode = ({ emoji, url }) => url ? emojiImg(shortcodeOf(emoji), url) : emoji;
const boosts = new Map(); // note id -> Set<pubkey>
+ const noteCountsReady = new Set(); // a relay batch is complete for this note
const seenNoteEv = new Set(); // event ids already counted
const myReactEv = new Map(); // note id -> the id of OUR reaction, so it can be withdrawn
const zapTotals = new Map(); // id -> { sats, seen: Set<receipt id>, mine }
@@ -2982,6 +2983,23 @@ export function messagesFeature(ctx) {
const zapAsked = new Set();
let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = [];
const zapRelays = () => [...new Set([...NOTE_RELAYS, ...((wallet.nostrRelays && wallet.nostrRelays()) || [])])];
+ function noteCountSnapshot(id) {
+ const rm = reacts.get(id);
+ return { likes: rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0,
+ boosts: visiblePks(boosts.get(id) || new Set()).length,
+ sats: zapTotals.get(id)?.sats || 0 };
+ }
+ function refreshThreadCount(id, persist = true) {
+ if (!noteCountsReady.has(id)) return;
+ const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
+ if (!c?.root || !(c.root.id === id || c.replies.some((e) => e.id === id))) return;
+ const live = noteCountSnapshot(id), old = c.counts[id];
+ // Relay answers can be incomplete. A short/empty answer must not erase
+ // numbers that were already visible when this conversation opened.
+ c.counts[id] = old ? { likes: Math.max(old.likes, live.likes), boosts: Math.max(old.boosts, live.boosts),
+ sats: Math.max(old.sats, live.sats) } : live;
+ if (persist) persistThread(c);
+ }
const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1];
function receiptSats(ev) {
if (ev.kind === 9737) {
@@ -3041,6 +3059,7 @@ export function messagesFeature(ctx) {
// ours, wherever it was sent from — so it can be taken back here
if (myPubkeys().includes(ev.pubkey)) myReactEv.set(id, ev.id);
}
+ refreshThreadCount(id);
scheduleRepaint();
return;
}
@@ -3067,6 +3086,7 @@ export function messagesFeature(ctx) {
// amount the chip has been carrying since the tap steps aside
if (from && my.includes(from)) { cur.mine = true; voidPending(x[1], ev.created_at * 1000, sats); }
changed = true;
+ refreshThreadCount(x[1]);
}
if (changed) { saveZapTotals(); scheduleRepaint(); }
}
@@ -3090,6 +3110,12 @@ export function messagesFeature(ctx) {
// truth now, remembered or not (a zap that was deleted has to be
// able to disappear)
for (const id of slice) zapSeeds().delete(id);
+ const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
+ for (const id of slice) {
+ noteCountsReady.add(id);
+ if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false);
+ }
+ if (c?.root) persistThread(c);
saveZapTotals();
scheduleRepaint();
}).catch(() => {});
@@ -5517,8 +5543,10 @@ export function messagesFeature(ctx) {
if (canZap && zapSoundOn()) warmZapSound(); // the clip is decoded before the first tap
const mineReact = myReactOn(ev.id);
const rm = reacts.get(ev.id);
- const likeN = rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0;
- const boostN = visiblePks(boosts.get(ev.id) || new Set()).length;
+ const cached = ui.noteThread
+ ? threadCache.get(ui.noteThread.rootId)?.counts?.[ev.id] : null;
+ const likeN = cached ? cached.likes : rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0;
+ const boostN = cached ? cached.boosts : visiblePks(boosts.get(ev.id) || new Set()).length;
const btn = (icon, label, count, on, onClick, cls = '', extra = null) => h('button', {
class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label,
'aria-disabled': onClick ? undefined : 'true',
@@ -5534,7 +5562,7 @@ export function messagesFeature(ctx) {
const z = zapTotals.get(ev.id) || zapSeeds().get(ev.id);
const zp = pendingOf(ev.id);
const optimistic = zp ? zp.sats : 0;
- const zapSats = (z ? z.sats : 0) + optimistic;
+ const zapSats = (cached ? cached.sats : z ? z.sats : 0) + optimistic;
const zapFlying = !!zp && zp.flying;
// tap pays the default amount; hold opens the screen that sets it
const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
@@ -5694,7 +5722,7 @@ export function messagesFeature(ctx) {
const p = profiles.get(ev.pubkey);
if (p && (p.name || p.picture)) faces[ev.pubkey] = p;
}
- threadStore.save(c, c.focusId, faces);
+ threadStore.save(c, c.focusId, faces, c.counts);
};
function persistThreadProfile(pk) {
const c = ui.noteThread && threadCache.get(ui.noteThread.rootId);
@@ -5758,7 +5786,7 @@ export function messagesFeature(ctx) {
}
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 || [] };
+ root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} };
if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed);
c.replies.sort((a, b) => a.created_at - b.created_at);
threadCache.set(requestedRootId, c);
@@ -8271,6 +8299,7 @@ export function messagesFeature(ctx) {
follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null;
mutes = null; mutesAt = 0;
reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
+ noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key
stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear();
diff --git a/src/thread-cache.js b/src/thread-cache.js
index f255e3a..e11660e 100644
--- a/src/thread-cache.js
+++ b/src/thread-cache.js
@@ -23,6 +23,11 @@ const savedProfile = (p) => {
}
return out;
};
+const savedCounts = (c) => {
+ if (!c || typeof c !== 'object') return null;
+ const count = (n) => Number.isSafeInteger(n) && n >= 0 && n <= 1_000_000_000 ? n : 0;
+ return { likes: count(c.likes), boosts: count(c.boosts), sats: count(c.sats) };
+};
export function createThreadStore(storage) {
if (storage === undefined) { try { storage = globalThis.localStorage; } catch {} }
@@ -41,7 +46,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, profiles = {}) {
+ save(thread, focusId, profiles = {}, counts = {}) {
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]]);
@@ -71,8 +76,13 @@ export function createThreadStore(storage) {
}
if (face) faces[pk] = face;
}
+ const tallies = {};
+ for (const id of keep.keys()) {
+ const tally = savedCounts(counts[id] || previous?.counts?.[id]);
+ if (tally) tallies[id] = tally;
+ }
const entry = { rootId: thread.root.id, root: savedNote(thread.root),
- profiles: faces,
+ profiles: faces, counts: tallies,
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/boot-signin-flash-test.js b/tools/boot-signin-flash-test.js
index b3460ca..6e22ebb 100644
--- a/tools/boot-signin-flash-test.js
+++ b/tools/boot-signin-flash-test.js
@@ -62,6 +62,32 @@ try {
console.log(` ${clean ? '✓' : '✗'} reload ${i}: wallet at ${r.wallet}ms${r.signin != null ? `, sign-in page flashed at ${r.signin}ms` : ', no sign-in flash'}`);
if (!clean) ok = false;
}
+ const rootId = 'c'.repeat(64), replyId = 'd'.repeat(64), author = 'a'.repeat(64);
+ await page.evaluate(({ rootId, replyId, author }) => {
+ const root = { id: rootId, pubkey: author, kind: 1, content: 'Cached thread root', tags: [], created_at: 1 };
+ const reply = { ...root, id: replyId, content: 'Cached thread reply', tags: [['e', rootId, '', 'root']], created_at: 2 };
+ localStorage.setItem('btc-wallet-note-threads', JSON.stringify([{ rootId, root, replies: [reply], profiles: {}, counts: {} }]));
+ const nav = { ...history.state.nav, screen: 'wallet', noteThread: { rootId, focusId: replyId, seed: reply } };
+ history.replaceState({ nav, i: 0 }, '', location.href);
+ }, { rootId, replyId, author });
+ await page.evaluateOnNewDocument(() => {
+ window.__threadBoot = { homeFrames: 0, animatedFrames: 0, threadAt: null };
+ const tick = () => {
+ const state = window.__threadBoot;
+ if (document.querySelector('.balance')) state.homeFrames++;
+ const thread = document.querySelector('.thread-page');
+ if (thread?.classList.contains('anim-page')) state.animatedFrames++;
+ if (thread) state.threadAt ??= Math.round(performance.now());
+ if (state.threadAt == null || performance.now() - state.threadAt < 500) requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(1200);
+ const thread = await page.evaluate(() => window.__threadBoot);
+ const steady = thread.threadAt != null && !thread.homeFrames && !thread.animatedFrames;
+ console.log(` ${steady ? '✓' : '✗'} thread reload: ${thread.homeFrames} home frames, ${thread.animatedFrames} page animation frames`);
+ if (!steady) ok = false;
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ no sign-in flash' : '\n❌ the sign-in page flashed');
process.exit(ok ? 0 : 1);
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
index 14e5da6..485e874 100644
--- a/tools/thread-cache-test.js
+++ b/tools/thread-cache-test.js
@@ -19,7 +19,7 @@ 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, profiles: {}, replies: [long, parent] });
+ assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, 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', () => {
@@ -31,6 +31,11 @@ check('thread authors keep their names and local avatar thumbnails across reload
assert.deepEqual(createThreadStore(storage).find(reply.id).profiles[root.pubkey],
{ ...face, t: 0 });
});
+check('thread action totals are present before relay refresh and survive a context save', () => {
+ store.save({ root, replies: [parent, reply] }, reply.id, {}, { [reply.id]: { likes: 29, boosts: 7, sats: 210 } });
+ store.save({ root, replies: [parent, reply] }, reply.id);
+ assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 });
+});
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 39f2894..3dba87f 100644
--- a/tools/thread-reply-test.js
+++ b/tools/thread-reply-test.js
@@ -28,7 +28,7 @@ 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 },
- }),
+ }, { [reply.id]: { likes: 29, boosts: 7, sats: 210 } }),
release: () => { for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [root] : []); },
overlay: () => {
ui.profilePk = author; ui.profOverThread = true; render();
@@ -101,6 +101,19 @@ try {
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');
}));
+ check('cached likes, boosts and sats paint before relay replies', await page.evaluate(() => {
+ const row = [...document.querySelectorAll('.thread-page [data-zap-post]')]
+ .find((el) => el.dataset.zapPost === test.reply.id);
+ const buttons = row?.querySelectorAll('.note-acts > button');
+ return buttons?.[1].textContent === '7' && buttons?.[3].textContent === '29' && buttons?.[4].textContent === '210';
+ }));
+ await new Promise((r) => setTimeout(r, 450));
+ check('an incomplete relay tally cannot erase the cached numbers', await page.evaluate(() => {
+ const row = [...document.querySelectorAll('.thread-page [data-zap-post]')]
+ .find((el) => el.dataset.zapPost === test.reply.id);
+ const buttons = row?.querySelectorAll('.note-acts > button');
+ return buttons?.[1].textContent === '7' && buttons?.[3].textContent === '29' && buttons?.[4].textContent === '210';
+ }));
assert.deepEqual(errors, []);
console.log('✓ no browser errors');
} finally { await browser.close(); server.stop(true); }