aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-20 12:24:35 -0700
committerAdam Soltys <[email protected]>2026-10-20 12:24:35 -0700
commitd0752d70b54e9d58bba5e11aea06c9ba1678e89b (patch)
treeaf890d6fd9da9cb78b94a4013607ffee7cefe58f
parent1e0d551daae8d09f86dbf54ab67bf52610c0c9ac (diff)
downloadcoinosv3-d0752d70b54e9d58bba5e11aea06c9ba1678e89b.tar.gz
coinosv3-d0752d70b54e9d58bba5e11aea06c9ba1678e89b.zip
Replies whose post can't be found leave the Conversations view, and fewer go missingHEADmaster
A reply under "Couldn't find that note on the relays" told nobody anything (Adam, 2026-10-20), so once every lookup comes back without its post it isn't shown in a feed's Conversations view (its thread, notifications and the replier's own Replies tab keep it; a lookup that later finds the post brings it back). The lookups miss less: - A NIP-22 comment (kind 1111) names its parent's author at e-tag [3], not NIP-10's [4], so every comment's parent lost its author and the author's outbox went unasked. fiatjaf's comment on Kieran's routstr post lives on relay.snort.social (its hint), Kieran's pyramid.fiatjaf.com and ditto: none of ours. - A reply that names no author asks the outboxes of the people it's addressed to (its p tags, up to three). - The relay the reference itself names is asked even when this device has marked it sick. - A post read in Following (or the feed in use) is found in that feed's disk cache too, so a Conversations view opened right after a reload doesn't take its replies for orphans. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--src/features/messages.js47
-rw-r--r--src/feed-cache.js2
-rw-r--r--tools/feed-conversations-test.js9
3 files changed, 47 insertions, 11 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 7824a6f..d09ad83 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4960,12 +4960,20 @@ export function messagesFeature(ctx) {
else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1];
if (!tag) return null;
const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] };
- if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase();
+ // the parent's author: NIP-22 carries it at [3], NIP-10 at [4] (read at
+ // [4] only, every comment's parent lost its author, and its outbox went
+ // unasked: fiatjaf's comment on Kieran's, 2026-10-20). Without one, the
+ // people the reply is addressed to: their outboxes are where it was written.
+ const hex = (x) => (/^[0-9a-f]{64}$/i.test(x || '') ? x.toLowerCase() : null);
+ ref.author = hex(ev.kind === 1111 ? tag[3] : tag[4]) || hex(tag[3]);
+ if (!ref.author) ref.authors = [...new Set((ev.tags || []).filter((x) => x[0] === 'p').map((x) => hex(x[1])).filter((p) => p && p !== ev.pubkey))].slice(0, 3);
return ref;
}
// A note already in hand — in a feed, a thread, someone's page — before
// asking any relay for it: the post a reply answers is usually right
// there in the Following feed.
+ // a reply whose parent every lookup came back without
+ const orphaned = (ev) => { const p = parentRefOf(ev); const q = p && quoted.get(p.id); return !!q && q.status === 'missing' && !q.ev; };
function noteInHand(id) {
for (const c of feedStates.values()) for (const e of c.notes) {
if (e.id === id) return e;
@@ -4976,6 +4984,24 @@ export function messagesFeature(ctx) {
for (const e of c.replies || []) if (e.id === id) return e;
}
for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e;
+ return noteOnDisk(id);
+ }
+ // ...or on disk with the feed it was read in: a Conversations view opened
+ // straight after a reload has the posts its replies answer in Following's
+ // cache, not in any feed state yet (and without them, those replies were
+ // taken for replies to nothing and left out)
+ const diskHeld = new Map(); // feed id -> Map(note id -> note), read once a session
+ function noteOnDisk(id) {
+ for (const f of new Set([FOLLOWING, curFeedId])) {
+ let m = diskHeld.get(f);
+ if (!m) {
+ let notes = null;
+ try { notes = feedDisk()?.peekNotes(f) || wallet.loadFeatureState(feedCacheKey(f), []); } catch {}
+ m = new Map((Array.isArray(notes) ? notes : []).filter((e) => e && e.id).map((e) => [e.id, e]));
+ diskHeld.set(f, m);
+ }
+ if (m.has(id)) return m.get(id);
+ }
return null;
}
function notesFor(pk) {
@@ -7222,6 +7248,7 @@ export function messagesFeature(ctx) {
await noteReady(e, undefined, 0, { parent: feedReplies(c) });
unstage(e);
if (c.stopped) return false;
+ if (feedReplies(c) && orphaned(e)) return false; // a reply to nothing anyone can find
// 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); }
@@ -8328,8 +8355,10 @@ export function messagesFeature(ctx) {
// dozen relays a fresh REQ for each quote it met).
const findQ = new Map(); // relay -> Map(id -> waiters)
let findTimer = null;
- function askNote(relays, id) {
- relays = readRelayList(relays);
+ function askNote(relays, id, hints = []) {
+ // (a relay the reference itself names is asked even when this device has
+ // marked it sick: it's the strongest clue there is to where the note lives)
+ relays = [...new Set([...readRelayList(relays), ...hints.filter((r) => /^wss:\/\/[^\s]+$/.test(r || '')).map(normRelay)])];
if (!relays.length) return Promise.resolve(null);
return new Promise((resolve) => {
const w = { left: relays.length, resolve, done: false };
@@ -8366,9 +8395,10 @@ export function messagesFeature(ctx) {
function findNote(ref) {
const ask = (relays) => askNote(relays, ref.id);
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
+ const authors = [...new Set([ref.author, ...(ref.authors || [])].filter(Boolean))];
const asks = [
- ask(near),
- ref.author ? relaysOf(ref.author).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r)))) : Promise.resolve(null),
+ askNote(near, ref.id, ref.relays || []),
+ ...authors.map((a) => relaysOf(a).catch(() => []).then((rs) => ask(rs.filter((r) => !near.includes(r))))),
ask(WIDE_RELAYS.filter((r) => !near.includes(r))),
];
return new Promise((resolve) => {
@@ -15693,7 +15723,10 @@ export function messagesFeature(ctx) {
// their settled presentation once the warm-up lands. The spinner is
// for a feed with nothing cached at all.
const curatedFeed = !!(feedDef(c.id) || {}).curated;
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev));
+ // (a reply whose post every lookup came back without isn't shown: a
+ // "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
+ const repliesHere = feedReplies(c);
+ const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -18220,7 +18253,7 @@ export function messagesFeature(ctx) {
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;
- timedActivity.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
+ timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
// what happened to THEIR posts stays with them — the next identity
// starts its list empty and asks the relays under its own key
diff --git a/src/feed-cache.js b/src/feed-cache.js
index a4f3ac9..cf3323e 100644
--- a/src/feed-cache.js
+++ b/src/feed-cache.js
@@ -54,6 +54,8 @@ export function createFeedCache(storage, prefix, now = Date.now) {
return value.notes;
} catch { return []; }
},
+ // a feed's posts as stored, without counting as a read
+ peekNotes(id) { const v = peek(id); return v && Array.isArray(v.notes) ? v.notes : null; },
save(id, notes) {
try { storage.setItem(keyOf(id), JSON.stringify({ at: now(), notes: compact(notes) })); sweep(keyOf(id)); } catch {}
},
diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js
index e762d1a..f16fc35 100644
--- a/tools/feed-conversations-test.js
+++ b/tools/feed-conversations-test.js
@@ -67,7 +67,7 @@ try {
const following = await rowIds();
check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(','));
const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' '));
- check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos articles=Articles', await modes());
+ check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos live=Live articles=Articles', await modes());
check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent)))));
console.log('\n[Conversations]');
@@ -79,7 +79,8 @@ try {
const c1 = await contextOf(R1.id);
check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1));
check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText))));
- check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text);
+ // (a "Couldn't find that note" card over it told nobody anything: Adam, 2026-10-20)
+ check('a parent nobody has: the reply leaves the feed once every lookup comes back without it', await waitFor((ids) => !document.querySelector('.note-post[data-zap-post="' + ids[0] + '"]') && !!document.querySelector('.note-post[data-zap-post="' + ids[1] + '"]'), [R2.id, R1.id], 12000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
// the reply's own tap (its text, not the card above it): its thread, at the reply
await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id);
check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000));
@@ -114,7 +115,7 @@ try {
});
await click('save'); await sleep(600);
check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000));
- check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
+ check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got.includes(ids[0]) && !got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id);
await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(','));
@@ -122,7 +123,7 @@ try {
await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click());
check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
- check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got[0] === ids[0]; }, [R2.id], 8000));
+ check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 1 && got[0] === ids[0]; }, [R1.id], 8000));
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());