aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-30 09:42:54 -0700
committerAdam Soltys <[email protected]>2026-09-30 09:42:54 -0700
commit93c07bec934e8e8d847349b7792f4cc9089adbb7 (patch)
tree098864eccfe77502ae76181aad9f095260bf3771
parente96c445e73d26e946cf2a3d515b5f0ab697ef5f0 (diff)
downloadcoinosv3-93c07bec934e8e8d847349b7792f4cc9089adbb7.tar.gz
coinosv3-93c07bec934e8e8d847349b7792f4cc9089adbb7.zip
Topic feeds go back as far as the relays remember, instead of "all caught up" after a handful.
#gardenstr: 5 posts across damus/primal/nos.lol/ours (they prune), 774 once archive-keeping relays are asked too (ditto, nostr.mom, oxtr, nostrplebs, nostr21, offchain) — topic feeds now read those. And a page whose posts were all filtered out (replies, muted, spam) no longer ends the feed: paging goes on from the oldest event the relays returned, up to three asks per scroll. The quoted-note wide net gains ditto/nostrplebs/nostr21. topic-depth-test (/feed/t/gardenstr): 9 posts then "caught up" before, 73+ and still going after. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
-rw-r--r--src/features/messages.js34
-rw-r--r--tools/poll-test.js2
-rw-r--r--tools/topic-depth-test.js36
3 files changed, 66 insertions, 6 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index b14cb75..6a68c38 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4093,7 +4093,13 @@ export function messagesFeature(ctx) {
};
// posts on a topic come from the big public relays as well as ours: a
// hashtag has no author whose outbox we could read
- const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol'])];
+ // A topic has no authors whose relays to read, and the big relays prune:
+ // #gardenstr was 5 posts across damus/primal/nos.lol/ours, 774 once these
+ // archive-keeping relays were asked too (ditto 500+, nostr.mom 448,
+ // oxtr 247, nostrplebs 175, nostr21 102, offchain 74 — 2026-09-30).
+ const TOPIC_ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr.mom', 'wss://nostr.oxtr.dev',
+ 'wss://relay.nostrplebs.com', 'wss://nostr21.com', 'wss://offchain.pub'];
+ const TOPIC_RELAYS = [...new Set([...NOTE_RELAYS, 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', ...TOPIC_ARCHIVE_RELAYS])];
// ---- lists (NIP-51 follow sets, kind 30000) ------------------------------
// A feed of hand-picked people IS a nostr list. Saved here it is published
@@ -4948,6 +4954,13 @@ export function messagesFeature(ctx) {
// as disruptive as a live arrival if you were reading halfway down, so it
// goes behind the pill too. A first load, or paging older posts onto the
// bottom, does not.
+ // The oldest event the relays handed back, admitted or not: a page of
+ // replies, muted or spam posts is not the end of the feed — the next ask
+ // goes on from there.
+ function sawBack(c, evs) {
+ for (const e of evs || []) if (e && e.created_at && !(c.seenBack <= e.created_at)) c.seenBack = e.created_at;
+ c.sawAny = (c.sawAny || 0) + ((evs || []).length);
+ }
async function feedPass(extra = {}, merge = {}, c = feedNow()) {
const def = feedDef(c.id);
if (!def) return false;
@@ -4961,6 +4974,7 @@ export function messagesFeature(ctx) {
if (!topics.length && !def.all) return false;
const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
const evs = await queryOn(relays, { kinds: FEED_KINDS, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ sawBack(c, evs);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
return got;
}
@@ -4972,6 +4986,7 @@ export function messagesFeature(ctx) {
for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
return chunks.map(async (chunk) => {
const evs = await queryOn(relays, { kinds: FEED_KINDS, authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ sawBack(c, evs);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
});
}));
@@ -5182,8 +5197,14 @@ export function messagesFeature(ctx) {
if (c.status !== 'ready' || c.end) return;
const oldest = c.notes.at(-1);
if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
- if (await feedPass({ until: oldest.created_at - 1 }, {}, c)) c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length);
- else c.end = true;
+ let until = oldest.created_at - 1;
+ for (let tries = 0; tries < 3; tries++) {
+ c.sawAny = 0;
+ if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; }
+ // nothing older at all (or no step back): that really is the end
+ if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; }
+ until = c.seenBack - 1;
+ }
} finally {
c.loadingMore = false;
if (c === feed) render();
@@ -5406,8 +5427,11 @@ export function messagesFeature(ctx) {
// relays, ours — and when none of those has it, a wider net of big public
// relays (a quoted note in the wild sat only on nostr.mom, which neither
// the reference nor its author's relay list named).
- const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.nostr.band', 'wss://nostr.wine', 'wss://relay.snort.social',
- 'wss://offchain.pub', 'wss://relay.nostr.bg', 'wss://nostr.oxtr.dev', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+ // (the archive-keeping ones hold what the big relays have pruned — a poll
+ // two days old was only on ditto and nostrplebs by 2026-09-30)
+ const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com', 'wss://nostr21.com',
+ 'wss://nostr.wine', 'wss://relay.snort.social', 'wss://offchain.pub', 'wss://nostr.oxtr.dev',
+ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// All at once, first answer wins: asked in turn, a note on a far relay
// took longer than the feed waits for a row.
function findNote(ref) {
diff --git a/tools/poll-test.js b/tools/poll-test.js
index e9cb0d6..b88a4db 100644
--- a/tools/poll-test.js
+++ b/tools/poll-test.js
@@ -15,7 +15,7 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? '
const POLL_ID = '000002f7c6a38223a9843d6e93ce8c15a3048bc95003690d74341cf9de372bfc';
const pool = new SimplePool();
-const [poll] = await pool.querySync(['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'], { ids: [POLL_ID] }, { maxWait: 6000 });
+const [poll] = await pool.querySync(['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com'], { ids: [POLL_ID] }, { maxWait: 6000 });
if (!poll) { console.log(' ✗ the poll is not on the relays'); process.exit(1); }
const labels = poll.tags.filter((x) => x[0] === 'option').map((x) => x[2]);
diff --git a/tools/topic-depth-test.js b/tools/topic-depth-test.js
new file mode 100644
index 0000000..9876224
--- /dev/null
+++ b/tools/topic-depth-test.js
@@ -0,0 +1,36 @@
+// A topic feed (#gardenstr) goes back as far as the relays remember: the
+// big relays prune, so topics also ask archive-keeping ones, and a page
+// whose posts were all filtered out (replies, muted, spam) is not the end.
+// Before: ~5 posts, then "You're all caught up".
+//
+// Run: bun tools/topic-depth-test.js
+import puppeteer from 'puppeteer-core';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5285, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5285/feed/t/gardenstr', { waitUntil: 'domcontentloaded' });
+ const count = () => page.evaluate(() => new Set([...document.querySelectorAll('.notes-feed [data-key]')].map((n) => n.getAttribute('data-key'))).size);
+ for (let i = 0; i < 60 && (await count()) < 3; i++) await sleep(500);
+ const first = await count();
+ check('the topic opens with posts', first >= 3, first + ' posts');
+ let seen = new Set(), ended = false;
+ const t0 = Date.now();
+ while (Date.now() - t0 < 90_000 && seen.size < 60) {
+ for (const k of await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].map((n) => n.getAttribute('data-key')))) seen.add(k);
+ ended = await page.evaluate(() => document.body.innerText.includes('all caught up'));
+ if (ended) break;
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(1500);
+ }
+ check('scrolling keeps finding older posts', seen.size >= 40, seen.size + ' posts' + (ended ? ', then "caught up"' : ''));
+ check('...without claiming to be caught up after a handful', !ended || seen.size >= 40);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a topic goes back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);