aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js60
1 files changed, 43 insertions, 17 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a7d22c0..b1c78d3 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -73,8 +73,17 @@ export function messagesFeature(ctx) {
// ---- persisted state ----------------------------------------------------
+ // The messages state is a large blob (chat caches, DMs, feeds) and reading
+ // it means parsing all of it. A repaint asks for it dozens of times (every
+ // row's mute and spam checks go through it), so within a tick every caller
+ // shares one parse; a write refreshes the copy. Another tab's write shows
+ // up on the next tick.
+ let stCache = null, stAt = 0;
+ const ST_MEMO_MS = 50;
const st = () => {
+ if (stCache && Date.now() - stAt < ST_MEMO_MS) return stCache;
const s = wallet.loadFeatureState('messages', {});
+ stCache = s; stAt = Date.now();
s.joined ||= {}; // { [cid]: { [pubkey]: true } }
s.cache ||= {}; // { [channelId]: [msgs] }
s.communities ||= []; // join material beyond the built-in
@@ -93,7 +102,7 @@ export function messagesFeature(ctx) {
if (s.joined[k] === true) { (s.joined[COMMUNITY.community_id] ||= {})[k] = true; delete s.joined[k]; }
return s;
};
- const save = (s) => wallet.saveFeatureState('messages', s);
+ const save = (s) => { stCache = s; stAt = Date.now(); wallet.saveFeatureState('messages', s); };
const communities = () => [COMMUNITY, ...st().communities];
@@ -539,31 +548,31 @@ export function messagesFeature(ctx) {
// warms again once they're there.
if (!wallet.mnemonic && !wallet.xprv && !wallet.xpub) return;
profilesWarmed = true;
- const cached = wallet.loadFeatureState('profiles', {});
+ const cached = profilesDisk();
for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p);
}
- function persistProfile(pk, p) {
- // A faceless answer is not a fact worth writing down — persisting
- // {name:null, picture:null} rows only evicts real faces from the cap and
- // spreads a cold-relay miss across sessions.
- if (!p || (!p.name && !p.picture)) return;
- const s = wallet.loadFeatureState('profiles', {});
- s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null,
- about: p.about || null, banner: p.banner || null, eventAt: p.eventAt || 0, t: Date.now(),
- ...(p.thumbFor === p.picture && p.thumb
- ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
- ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
- thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
+ // The persisted profile blob (names, faces, thumbnails) lives parsed in
+ // memory and is written back once per burst: a batch of thirty profiles
+ // landing during a scroll used to parse and re-serialize the whole blob
+ // thirty times over, on the main thread, mid-scroll.
+ let profBlob = null, profFlush = null;
+ const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {}));
+ function flushProfiles() {
+ profFlush = null;
+ const s = profBlob;
+ if (!s) return;
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen faces give theirs up first. The row itself stays —
// that face just paints the way it used to.
- if (JSON.stringify(s).length > THUMB_BUDGET) {
+ let size = JSON.stringify(s).length;
+ if (size > THUMB_BUDGET) {
// never our own: the header face is the one that must paint offline
const mine = new Set(myPubkeys());
const oldestFirst = Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)).sort((a, b) => (s[a].t || 0) - (s[b].t || 0));
for (const k of oldestFirst) {
+ size -= (s[k].thumb || '').length;
delete s[k].thumb; delete s[k].thumbFor;
- if (JSON.stringify(s).length <= THUMB_BUDGET) break;
+ if (size <= THUMB_BUDGET) break;
}
}
const keys = Object.keys(s);
@@ -577,6 +586,20 @@ export function messagesFeature(ctx) {
}
wallet.saveFeatureState('profiles', s);
}
+ function persistProfile(pk, p) {
+ // A faceless answer is not a fact worth writing down — persisting
+ // {name:null, picture:null} rows only evicts real faces from the cap and
+ // spreads a cold-relay miss across sessions.
+ if (!p || (!p.name && !p.picture)) return;
+ const s = profilesDisk();
+ s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null,
+ about: p.about || null, banner: p.banner || null, eventAt: p.eventAt || 0, t: Date.now(),
+ ...(p.thumbFor === p.picture && p.thumb
+ ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
+ ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
+ thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
+ if (!profFlush) profFlush = setTimeout(flushProfiles, 1500);
+ }
// A punk picture is OUR OWN art. Every coinos user who keeps the default
// publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and
// the legacy coinos.io copies 502 for a third of the set — so painting
@@ -4645,7 +4668,7 @@ export function messagesFeature(ctx) {
// on screen. Once it has played, the box is the viewer's (no morph).
function videoNode(url, { stable = false } = {}) {
const v = h('video', { src: url, class: 'note-video', controls: true,
- preload: autoplayOk() ? 'auto' : 'metadata', playsinline: true, muted: true,
+ preload: 'metadata', playsinline: true, muted: true, // play() in view fetches; a window of videos must not all stream
style: stable ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined,
onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
v.muted = true; // the property, not just the attribute: a script-made element autoplays only muted
@@ -8507,6 +8530,9 @@ export function messagesFeature(ctx) {
threadCache.clear();
follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null;
mutes = null; mutesAt = 0;
+ stCache = null; stAt = 0; // the next account's state, not this one's
+ if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
+ profBlob = null;
reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity