From d57962cb896b360295ec107afd50d2ce8701c401 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 12 Sep 2026 22:15:10 -0700 Subject: The feed was showing half the posts it should. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared against a wider net over the same six hours and the same follow list — 862 people, read off this wallet: before: 165 notes from 38 people after: 327 notes from 86 people, and nothing the wider net still finds Four things were losing posts, in order of how much: THE AUTHOR CAP. feedAuthors() stopped at 500. On an 862-follow list that silently dropped 362 people, and 164 of the 166 posts the wider net found were theirs. Gone: it takes everyone now, up to a couple of thousand, with each REQ carrying at most 400 authors so no single filter is absurd. THE DEFAULTS. relay.damus.io is the second-biggest relay this follow list writes to — 249 of them — and it wasn't in our set. 232 follows publish no relay list at all and lean entirely on those defaults, so they were being asked for on two relays instead of three. THE SOCKET CAP. Ten relays covered all but 66 follows; twenty-five covered all but 26. It's 24 now — Amethyst runs near a hundred, and this is the same idea kept to a number a phone can hold. MALFORMED RELAY LISTS. One list in these follows crams three URLs into a single r tag, space-separated. It passed our "starts with wss://" check, reached the pool, threw Invalid URL — and every author covered by that relay quietly returned nothing. Each URL has to parse now. And a quote-post is no longer mistaken for a reply. NIP-10 marks a reply's e tags 'root' or 'reply'; a quote's are 'mention', and NIP-18 quotes carry a q tag. We were hiding every note with an e tag, which included the quote-posts that now render as cards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/features/messages.js | 53 ++++++++++++++++++++++++++++++++++++++++-------- src/nostr.js | 6 +++++- 2 files changed, 49 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index aa74387..cd601d9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2452,7 +2452,10 @@ export function messagesFeature(ctx) { // sockets that between them reach everyone rather than one socket each. const RELAY_LISTS = 'relayLists'; const RELAY_LIST_TTL = 7 * 24 * 3600_000; - const OUTBOX_MAX = 10; // sockets we'll open for one pass + // Sockets we'll open for one pass. Ten covered all but 66 of a real + // 862-follow list; twenty-five covered all but 26. Amethyst runs near a + // hundred — this is the same idea, kept to a number a phone can hold. + const OUTBOX_MAX = 24; const PER_AUTHOR = 3; // write relays taken from any one list let relayLists = null; // pk -> { r: [url], t } @@ -2480,8 +2483,17 @@ export function messagesFeature(ctx) { wallet.saveFeatureState(RELAY_LISTS, out); } catch {} } + // A relay list is written by whoever owns it, and some of them are wrong: + // one in this wallet's follows has three URLs crammed into a single r tag, + // space-separated. Fed to the pool that throws, and every author covered by + // that relay quietly returns nothing. So each one has to parse as a URL, + // and anything else is dropped rather than trusted. + const okRelay = (u) => { + if (typeof u !== 'string' || /\s/.test(u) || u.length > 200) return false; + try { const p2 = new URL(u); return p2.protocol === 'wss:' && !!p2.host; } catch { return false; } + }; const writeRelaysIn = (ev) => [...new Set((ev.tags || []) - .filter((x) => x[0] === 'r' && x[1] && x[2] !== 'read' && /^wss:\/\//i.test(x[1])) + .filter((x) => x[0] === 'r' && x[2] !== 'read' && okRelay(x[1])) .map((x) => String(x[1]).replace(/\/+$/, '')))].slice(0, PER_AUTHOR); // One REQ per hundred authors, against our own relays — a relay list is @@ -2561,8 +2573,26 @@ export function messagesFeature(ctx) { let feedUnsubs = []; let feedAt = 0; - const isReply = (ev) => (ev.tags || []).some((x) => x[0] === 'e'); - const feedAuthors = () => [...followsNow().set].slice(0, 500); + // A reply, as against a post that merely POINTS at another one. NIP-10 + // marks a reply's e tags 'root' or 'reply'; a quote's are 'mention', and + // NIP-18 quotes carry a q tag instead. Treating every e tag as a reply hid + // quote-posts from the feed — the thing Amethyst shows as a card with the + // quoted note inside it. + const isReply = (ev) => { + const es = (ev.tags || []).filter((x) => x[0] === 'e'); + if (!es.length) return false; + if (es.some((x) => x[3] === 'root' || x[3] === 'reply')) return true; + if (es.every((x) => x[3] === 'mention')) return false; + // unmarked (the deprecated positional form): a q tag means it's a quote, + // otherwise assume the older convention, where e meant reply + return !(ev.tags || []).some((x) => x[0] === 'q'); + }; + // Everyone you follow. This was capped at 500, which on an 862-follow list + // meant 362 people were silently missing from the feed — 164 of the 166 + // posts a wider net found in one six-hour window were theirs. + const FEED_AUTHORS_MAX = 2000; + const REQ_AUTHORS = 400; // authors per REQ, so one filter stays a sane size + const feedAuthors = () => [...followsNow().set].slice(0, FEED_AUTHORS_MAX); function feedNow() { if (!feed) { @@ -2600,9 +2630,13 @@ export function messagesFeature(ctx) { await fetchRelayLists(authors); const plan = outboxPlan(authors); let got = false; - await Promise.all(plan.map(async ({ relays, authors: a }) => { - const evs = await queryOn(relays, { kinds: [1], authors: a, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); - if (mergeFeed(evs)) { got = true; scheduleRepaint(); } + 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(async (chunk) => { + const evs = await queryOn(relays, { kinds: [1], authors: chunk, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []); + if (mergeFeed(evs)) { got = true; scheduleRepaint(); } + }); })); return got; } @@ -2623,8 +2657,9 @@ export function messagesFeature(ctx) { if (!feedAuthors().length || !ui.chatOpen || ui.msgView !== 'feed') return; const since = Math.floor(Date.now() / 1000) - 60; for (const { relays, authors } of outboxPlan(feedAuthors())) - feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors, since }, - (ev) => { if (mergeFeed([ev])) scheduleRepaint(); })); + for (let i = 0; i < authors.length; i += REQ_AUTHORS) + feedUnsubs.push(subscribeOn(relays, { kinds: [1], authors: authors.slice(i, i + REQ_AUTHORS), since }, + (ev) => { if (mergeFeed([ev])) scheduleRepaint(); })); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } diff --git a/src/nostr.js b/src/nostr.js index 828fde3..74bf0f2 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -229,7 +229,11 @@ export function nsecOf(sk) { try { return nsecEncode(sk); } catch { return null; // Profiles live across the network, so look them up on popular relays (broader // than the sync relays). -export const PROFILE_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net']; +// Where we look for people who haven't said where they write. Measured +// against a real 862-follow list: nos.lol carries 267 of them, damus.io 249, +// primal 170 — and damus wasn't here, which left 232 follows with no relay +// list of their own leaning on two relays instead of three. +export const PROFILE_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.damus.io', 'wss://relay.primal.net']; // Fetch a recipient's profile (name + picture) for a pubkey, newest across relays. export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { let events; -- cgit v1.2.3