aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
4 files changed, 62 insertions, 17 deletions
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);