aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js58
-rw-r--r--src/features/nostrlogin.js18
-rw-r--r--src/nostr.js11
3 files changed, 77 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 94a5173..278e66f 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4193,11 +4193,19 @@ export function messagesFeature(ctx) {
// language); unknown is never translated unasked
// A post that is only hashtags is judged by them: its tags in a script
// the reader can't read are what wants translating (as glosses).
+ // Asked per row per render; the guess reads the whole text, and a post's
+ // text and the target language don't change, so remember it.
+ const foreignMemo = new Map();
const looksForeign = (ev) => {
const to = transLang();
if (!translatable(ev) || !(deeplKey() || canTranslateTo(to))) return false;
+ const mk = transKey(ev) + '|' + to;
+ if (foreignMemo.has(mk)) return foreignMemo.get(mk);
const text = noteText(ev), { prose, tags } = transParts(text, to);
- return prose.length ? !inLanguage(text, to) : tags.length > 0;
+ const v = prose.length ? !inLanguage(text, to) : tags.length > 0;
+ if (foreignMemo.size > 3000) foreignMemo.clear();
+ foreignMemo.set(mk, v);
+ return v;
};
const langName = (code) => { try { return new Intl.DisplayNames([getLang()], { type: 'language' }).of(code) || code; } catch { return code; } };
// the translation to SHOW for this post now, or null
@@ -4715,8 +4723,14 @@ export function messagesFeature(ctx) {
// (a /username deep link over the unlock screen) scrolls too, and init
// only runs once a wallet opens.
let lastFeedY = 0, pillFrame = 0;
+ // When the reader last moved the page themselves (a wheel notch coasts for
+ // a while after the last one), and when it last scrolled at all: a batch of
+ // arriving posts waits for a quiet moment (see queueAdmit).
+ let lastInputAt = 0, lastScrollAt = 0;
if (typeof window !== 'undefined') {
+ for (const n of ['wheel', 'touchstart', 'touchmove', 'keydown', 'pointerdown']) window.addEventListener(n, () => { lastInputAt = Date.now(); }, { passive: true, capture: true });
window.addEventListener('scroll', () => {
+ lastScrollAt = Date.now();
if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return;
windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed);
// back at the top by yourself: the new posts are under your eyes, so
@@ -6707,12 +6721,37 @@ export function messagesFeature(ctx) {
// Cached rows can still be warming. This new row is already prepared
// and can take its stable presentation without waiting for that page.
if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); }
- admitFeed([...(c.deferred || []), e], c, false, opts.newerThan);
- scheduleRepaint();
+ queueAdmit(e, c, opts.newerThan);
return true;
}));
return ready.some(Boolean);
}
+ // Ready posts go in together: one admission — one render with the reader's
+ // row held still — per short interval, not one per post. A Popular switch
+ // used to run dozens of full renders in a few seconds, 200–450 ms each,
+ // and a wheel scroll through them stuttered on a laptop (the wheel coast
+ // is driven from the main thread, so every long render froze it). While
+ // the reader is moving the page the batch waits for a quiet moment, up to
+ // a cap, so nothing much changes under a coasting scroll.
+ const ADMIT_MS = 120, ADMIT_QUIET_MS = 160, ADMIT_INPUT_MS = 2500, ADMIT_MAX_WAIT_MS = 1500;
+ const admitQueues = new Map(); // c → { evs, newerThan, since, timer }
+ function queueAdmit(e, c, newerThan) {
+ let q = admitQueues.get(c);
+ if (!q) admitQueues.set(c, q = { evs: [], newerThan, since: Date.now(), timer: 0 });
+ q.evs.push(e);
+ if (!q.timer) q.timer = setTimeout(() => flushAdmit(c), ADMIT_MS);
+ }
+ const readerMoving = () => Date.now() - lastScrollAt < ADMIT_QUIET_MS && Date.now() - lastInputAt < ADMIT_INPUT_MS;
+ function flushAdmit(c) {
+ const q = admitQueues.get(c);
+ if (!q) return;
+ q.timer = 0;
+ if (c === feed && readerMoving() && Date.now() - q.since < ADMIT_MAX_WAIT_MS) { q.timer = setTimeout(() => flushAdmit(c), ADMIT_QUIET_MS); return; }
+ admitQueues.delete(c);
+ if (c.stopped) return;
+ admitFeed([...(c.deferred || []), ...q.evs], c, false, q.newerThan);
+ scheduleRepaint();
+ }
// Never insert between the rows currently under the reader's eyes. Gap
// fills wait until that part of the feed is offscreen; new posts above
@@ -8706,7 +8745,10 @@ export function messagesFeature(ctx) {
// rooms, DMs, quote cards. Two rules are Amethyst's, and are what makes
// its feeds feel clean: repeated content from strangers is spam, and a
// post your follows reported is folded (not hidden) behind who said so.
- const modOn = (k) => (st().mod || {})[k] !== false;
+ // Per row per check; the cached copy serves (a Settings toggle writes
+ // through save(), which refreshes it) rather than re-parsing the blob
+ // every 50 ms through a long render.
+ const modOn = (k) => ((stCache || st()).mod || {})[k] !== false;
// ---- sensitive posts (NIP-36 content warnings, NSFW accounts) -------------
// A post its author marked sensitive carries a content-warning tag — what a
// Misskey artist's "sensitive" media becomes on the mostr bridge, and what
@@ -11863,7 +11905,13 @@ export function messagesFeature(ctx) {
// reconciles the list by post: a new one at the top is inserted as its
// own node; the rest keep theirs.
const list = visible.slice(0, c.shown || FEED_PAGE);
- c.winList = () => c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE);
+ // The window's list getter runs on every scroll frame and inside
+ // holdScroll; filtering every note through the mute/spam/report checks
+ // each time was the main thread's single largest cost while a feed
+ // loaded. Anything that changes who is shown goes through render(), which
+ // lands here again; between renders the same notes give the same list.
+ const visNotes = c.notes, visShown = c.shown;
+ c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE));
const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id)
? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev)));
// Posts that went in above you while you were reading. A floating pill
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index f18b075..f7f9e16 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -28,7 +28,13 @@ export function nostrLoginFeature(ctx) {
const { h, ui, render, wallet, toast, copyBtn } = ctx;
const load = () => wallet.loadFeatureState('nostrlogin', {});
- const save = (st) => wallet.saveFeatureState('nostrlogin', st);
+ const save = (st) => { idMemo = null; wallet.saveFeatureState('nostrlogin', st); };
+ // nostrLoginIdentity() is asked per feed row per moderation check (isMe,
+ // mePk, trusted…): thousands of times per scroll frame. Each answer parsed
+ // the stored state twice and bech32-encoded the npub. Keep the last answer
+ // briefly; a save, a signer change or another wallet's key drops it.
+ let idMemo = null;
+ const ID_MEMO_MS = 250;
// The signer that opened this session, kept so the names feature can claim
// the user's real npub as their payment address (and nominate the wallet
@@ -765,10 +771,14 @@ export function nostrLoginFeature(ctx) {
// wallet key's face in the gap.
// `via`: how the account came in, known before its signer wakes (an
// Amber account is one whether or not Amber has been asked anything yet)
+ const key = wallet.featureStateKey('nostrlogin');
+ if (idMemo && idMemo.live === live && idMemo.key === key && Date.now() - idMemo.at < ID_MEMO_MS) return idMemo.value;
const st = load();
- if (liveSigner()) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live, via: st.pubkey === live.pubkey ? reconnectViaOf(st) : viaOf(live) };
- if (!st.pubkey) return null;
- return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live, via: reconnectViaOf(st) };
+ let value = null;
+ if (liveSigner()) value = { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live, via: st.pubkey === live.pubkey ? reconnectViaOf(st) : viaOf(live) };
+ else if (st.pubkey) value = { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live, via: reconnectViaOf(st) };
+ idMemo = { at: Date.now(), live, key, value };
+ return value;
},
// A page reload loses the signer. A stored passkey key or an installed
// extension reattaches without prompting, which is what lets the payment
diff --git a/src/nostr.js b/src/nostr.js
index e414d57..b76e865 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -466,7 +466,16 @@ export function parseNostrPubkey(input) {
return null;
}
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
-export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } }
+// Memoized: the feed asks for the reader's npub thousands of times while it
+// filters rows, and bech32 encoding was the top self time of a scroll.
+const npubMemo = new Map();
+export function npubOf(pkHex) {
+ if (npubMemo.has(pkHex)) return npubMemo.get(pkHex);
+ let v = null; try { v = npubEncode(pkHex); } catch {}
+ if (npubMemo.size > 4000) npubMemo.clear();
+ npubMemo.set(pkHex, v);
+ return v;
+}
// An event reference other clients can resolve: the id plus who wrote it, so
// a quote can be fetched even by someone who doesn't have it yet.
export function neventOf(id, author) { try { return neventEncode({ id, author }); } catch { return null; } }