diff options
Diffstat (limited to 'src')
| -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 |
4 files changed, 62 insertions, 17 deletions
@@ -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); |
