aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-02 09:26:55 -0700
committerAdam Soltys <[email protected]>2026-11-02 09:26:55 -0700
commit42a76a883d23221cd73e10d8649d8ca28f6aed39 (patch)
treef0d60ed755dc45e51312a3c5f8e8e0207618a284 /src/features
parent84460457619857ea6cb678bb0a7b2918c222014a (diff)
downloadcoinosv3-42a76a883d23221cd73e10d8649d8ca28f6aed39.tar.gz
coinosv3-42a76a883d23221cd73e10d8649d8ca28f6aed39.zip
The feed asks the relays for less, and asks it in fewer REQs. Measured from the laptop (2026-11-02, after the resume fix), an open of Following sent some 560 REQs in twenty seconds across 57 relays, over a hundred each to damus and primal, and damus answered the home IP "banned: too many rate-limit violations". Four sources, all cut: a quoted note's author (a face tapped, a reply's target) had their relay list asked for one REQ per author per relay — six quotes on screen sent eighteen — and those asks now wait 80 ms and go out as one REQ through the feed's batched fetch, which also remembers who has no list; the live subscriptions were closed and reopened at the end of every refresh pass (seventeen REQs over eight relays, twice per open) and now stay up while the relays and authors they cover are unchanged, the head they count fresh posts against read at each event; the follows' reports were fetched afresh at every reload (an in-memory clock) and now honour the clock the device saved, asking only for what came since; and every one-shot REQ was held open on our own relay until the slowest relay in the ask had answered — the library closes them all together — so the fast one carried each for up to five seconds. One-shot queries now go out per relay (queryOn, queryStreamOn, queryManyOn, the profile and inbox lookups), each closed on that relay's own EOSE, with no more than ten open on any relay at once: the rest wait their turn, and a turn that comes after the ask's own wait is skipped. tools/relay-budget-test.js runs the feed against a fast and a slow fake relay and checks each of these
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js67
1 files changed, 49 insertions, 18 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index e1468ce..836966f 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4923,25 +4923,38 @@ export function messagesFeature(ctx) {
const notesCache = new Map(); // pk -> { status: 'loading'|'ready', notes: [kind-1 events] }
// NIP-65: where this author actually writes. Their notes and threads live
// there first — our default relays are just the common ground.
+ // Asked one at a time — a quoted note's author, a face tapped, a reply's
+ // target — these went out as one REQ per author per relay: a feed with
+ // six quotes on screen sent eighteen. Asks now wait a beat and go out
+ // together, one REQ with every author wanted, through the feed's own
+ // batched fetch, which also remembers who has no list so nobody is asked
+ // twice.
const relayListCache = new Map(); // pk -> Promise<string[]>
+ const RELAY_ASK_MS = 80;
+ let relayAskQ = new Map(), relayAskTimer = null; // pk -> [resolve]
function relaysOf(pk) {
let p = relayListCache.get(pk);
if (p) return p;
// the feed's batched fetch has probably already asked for this one
const known = relayListsNow().get(pk);
if (known) { p = Promise.resolve(known.r || []); relayListCache.set(pk, p); return p; }
- p = queryOn([...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [10002], authors: [pk] }, 3500)
- .then((evs) => {
- const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
- if (!newest) return [];
- return [...new Set(newest.tags
- .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//.test(x[1]))
- .map((x) => x[1].replace(/\/$/, '')))].slice(0, 4);
- })
- .catch(() => []);
+ p = new Promise((resolve) => {
+ if (!relayAskQ.has(pk)) relayAskQ.set(pk, []);
+ relayAskQ.get(pk).push(resolve);
+ if (!relayAskTimer) relayAskTimer = setTimeout(flushRelayAsks, RELAY_ASK_MS);
+ });
relayListCache.set(pk, p);
return p;
}
+ async function flushRelayAsks() {
+ relayAskTimer = null;
+ const batch = relayAskQ; relayAskQ = new Map();
+ try { await fetchRelayLists([...batch.keys()], [...new Set([...PROFILE_RELAYS, ...NOTE_RELAYS])]); } catch {}
+ for (const [pk, waiters] of batch) {
+ const r = ((relayListsNow().get(pk) || {}).r || []).slice(0, 4);
+ for (const w of waiters) w(r);
+ }
+ }
// Plus relays that keep history: the big ones prune within days, and an
// author without a NIP-65 list (Joe Nakamoto, 2026-10-01) otherwise shows
// only whatever primal still holds — 2 posts of 22.
@@ -5385,12 +5398,12 @@ export function messagesFeature(ctx) {
// small, widely mirrored, and worth having before anything else is asked.
// Authors with no list at all are remembered as such, so we don't ask again
// every time the feed refreshes.
- async function fetchRelayLists(pks) {
+ async function fetchRelayLists(pks, relays = NOTE_RELAYS) {
const want = [...new Set(pks)].filter((pk) => !relayListsNow().has(pk));
if (!want.length) return;
for (let i = 0; i < want.length; i += 100) {
const batch = want.slice(i, i + 100);
- const evs = await queryOn(NOTE_RELAYS, { kinds: [10002], authors: batch }, 4500).catch(() => []);
+ const evs = await queryOn(relays, { kinds: [10002], authors: batch }, 4500).catch(() => []);
const newest = new Map();
for (const ev of evs || []) {
const cur = newest.get(ev.pubkey);
@@ -7770,22 +7783,33 @@ export function messagesFeature(ctx) {
}
// While the feed is what's on screen, new posts arrive by themselves — on
// the same relays the pass above reads, one subscription each.
+ // The live subscriptions stay up across refreshes. Every refresh pass
+ // used to end by closing and reopening all of them — seventeen REQs over
+ // eight relays, twice per opening of Following — for subscriptions whose
+ // relays and authors hadn't changed; only the head they count fresh posts
+ // against moves, and that is read at each event.
+ let feedWatchKey = '', feedWatchHead = { t: Infinity };
function watchFeed() {
- stopFeedWatch();
const c = feedNow();
const def = feedDef(c.id); // the Conversations view has its own
- if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
+ if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') { stopFeedWatch(); return; }
const newerThan = c.notes.length ? c.notes[0].created_at : Infinity;
const since = Math.floor(Date.now() / 1000) - 60;
const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {};
const kinds = feedKinds(def);
- const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
+ const orT = orTopicsOf(def);
+ const plan = def.curated || !authors.length ? [] : feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
+ const key = [c.id, !!def.curated, !!def.all, !!def.replies, kinds.join(), topics.join(), orT.join(), (feedRelays(def) || []).join(),
+ ...plan.map(({ relays, authors: a }) => relays.join() + '>' + a.join())].join('\n');
+ if (feedUnsubs.length && key === feedWatchKey) { feedWatchHead.t = newerThan; return; }
+ stopFeedWatch();
+ feedWatchKey = key;
+ const head = feedWatchHead = { t: newerThan };
+ const on = (ev) => { mergeFeed([ev], { live: true, newerThan: head.t }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; }
if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; }
- const orT = orTopicsOf(def);
if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on));
- const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
for (const { relays, authors: a } of plan)
for (let i = 0; i < a.length; i += REQ_AUTHORS)
feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
@@ -7793,6 +7817,7 @@ export function messagesFeature(ctx) {
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
feedUnsubs = [];
+ feedWatchKey = '';
}
// Prepare older pages several screens ahead of the reader. A scroll while
// preparation is underway shares the same work instead of exposing it early.
@@ -11270,10 +11295,16 @@ export function messagesFeature(ctx) {
}
}
const REPORTS_MS = 30 * 60_000;
+ // The half hour counts from the last sync the device SAVED, so a reload
+ // (every open of the installed app is one) doesn't ask the outbox relays
+ // again — some forty REQs with every follow's reports — and a sync after
+ // one asks only for what came since. (The index is kept.)
async function syncReports({ force = false } = {}) {
if (!modOn('reports') || !myPubkeys().length) return;
- if (!force && Date.now() - reportsAt < REPORTS_MS) return;
+ const last = Math.max(reportsAt, reportsNow().at || 0);
+ if (!force && Date.now() - last < REPORTS_MS) return;
reportsAt = Date.now();
+ const since = last ? { since: Math.floor(last / 1000) - 3600 } : {};
const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX);
const from = new Set(authors);
try {
@@ -11282,7 +11313,7 @@ export function messagesFeature(ctx) {
const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => {
const chunks = [];
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
- return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => []));
+ return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500, ...since }, 5000).catch(() => []));
}));
for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev);
reportsNow().at = Date.now();