aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
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 /src/features/messages.js
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
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js47
1 files changed, 40 insertions, 7 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