diff options
| -rw-r--r-- | build.js | 4 | ||||
| -rw-r--r-- | src/app.js | 18 | ||||
| -rw-r--r-- | src/features/index.js | 8 | ||||
| -rw-r--r-- | src/features/messages.js | 39 | ||||
| -rw-r--r-- | src/thread-cache.js | 14 | ||||
| -rw-r--r-- | tools/boot-signin-flash-test.js | 26 | ||||
| -rw-r--r-- | tools/thread-cache-test.js | 7 | ||||
| -rw-r--r-- | tools/thread-reply-test.js | 15 |
8 files changed, 111 insertions, 20 deletions
@@ -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); @@ -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); } |
