From 885bc2647a3558554a53a794f1d56e9dbd7657e6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 17:02:00 -0700 Subject: Conversations: a feed of the replies the people you follow write, each under the post it answers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amethyst's Conversations beside its New Threads (Adam, 2026-10-16: "a replies mode or Conversations mode like Amethyst has"). The Following feed showed their posts and dropped every reply, so what people were saying TO each other only turned up by opening threads. Now a built-in Conversations chip sits beside Following (id 'replies'; its own notes, clock, disk cache and relay choice like the other built-ins): the same people, asked for notes and NIP-22 comments, keeping the replies — a reply as NIP-10 marks it, a comment, never a quote or a repost (feedKinds/feedKeeps; the merge, seeding, borrowing, the live watch and the media index all go by them). Each row shows what it answers above it — "↩ Replying to" and the quote card of the parent (the e tag marked reply, else root, else the last; a comment's e or E), tapping the card opens that post, tapping the reply its thread at the reply — and the header drops its "reply" tag there. The parent is usually in hand already (the Following feed, a thread, someone's page: noteInHand, which quotedNote checks before any relay — nostr:nevent quotes gain the same), and a thread opened from such a row paints its root at once instead of fetching it. In a conversations feed the parent counts as part of the row, so it arrives whole with the first screen (noteReady's parent option, per feed); a profile's Replies tab shows the same context and fills its cards in. Not a shareable URL (it is nobody's timeline), so no Share in its menu. Any custom feed can be one: "Replies instead of posts" in the editor (def.replies, synced with the definition), named in its summary. The disk cache now keeps comments. tools/feed-conversations-test.js: Following keeps posts only; the chip; the replies newest first with the parent card from hand (no relay asked) and a missing parent said so; the menu without Share; the reply's own tap opens its thread at the reply, with the root painted from hand; the card's tap opens the parent; Back lands on Conversations; the profile's Replies tab shows the context, Posts none; a custom conversations feed via the editor borrows the replies from the feeds in hand, not the posts. feed-custom-test lists the new chip. Pre-existing failures not touched: feed-custom-test's "a tagged post lands in it"/"lands in Everything" and feed-streaming-test's pill position fail on clean HEAD the same way; feed-follow, feed-cache, own-post-feeds, feed-list, feed-media-views, feed-switch-seed, profile-pager, thread-pager, feed-repost, feed-live, feed-new-posts, feed-window, feed-choice-per-wallet, feed-nav-destination, feed-wallet-back, moderation, feed-mention-tap and feed-reboost pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 133 ++++++++++++++++++++++++++++++--------- src/feed-cache.js | 2 +- src/i18n.js | 5 ++ src/style.css | 5 ++ tools/feed-conversations-test.js | 127 +++++++++++++++++++++++++++++++++++++ tools/feed-custom-test.js | 10 +-- 6 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 tools/feed-conversations-test.js diff --git a/src/features/messages.js b/src/features/messages.js index 20cfaa6..9a1a56d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4708,6 +4708,41 @@ export function messagesFeature(ctx) { const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts'); // what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e)); + // A conversations feed (Amethyst's "Conversations" beside its "New + // Threads"): the replies these people write, each under the post it + // answers, instead of their posts. Asked for as notes and NIP-22 comments; + // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.) + const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS); + const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); + // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' + // (a reply straight to the root), else — the deprecated positional form — + // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. + function parentRefOf(ev) { + if (!ev || !replyish(ev)) return null; + const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]); + let tag; + if (ev.kind === 1111) tag = es[0] || (ev.tags || []).find((x) => x[0] === 'E' && x[1]); + 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(); + 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. + function noteInHand(id) { + for (const c of feedStates.values()) for (const e of c.notes) { + if (e.id === id) return e; + if (e.kind === REPOST) { const inner = repostInner(e); if (inner && inner.id === id) return inner; } + } + for (const c of threadCache.values()) { + if (c.root && c.root.id === id) return c.root; + 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 null; + } function notesFor(pk) { let c = notesCache.get(pk); if (c) { @@ -5177,6 +5212,7 @@ export function messagesFeature(ctx) { // Two feeds everyone has: the people you follow, and everything the coinos // relay carries. Each remembers its own relay choice in the state. const EVERYTHING = 'all'; + const REPLIES = 'replies'; // the people you follow, their replies (see feedKeeps) // Amethyst's feed model, for the people who asked: everyone you follow plus // everyone on your lists, and on top of that posts from anyone on a // hashtag you follow — your topic feeds' tags and your NIP-51 interests @@ -5229,11 +5265,12 @@ export function messagesFeature(ctx) { const br = st().builtinRelays || {}; return [ { id: FOLLOWING, name: t('feedFollowingName'), follows: true, builtin: true, relays: br[FOLLOWING] || [] }, + { id: REPLIES, name: t('feedConversations'), follows: true, replies: true, builtin: true, relays: br[REPLIES] || [] }, { id: EVERYTHING, name: t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: true, builtin: true, relays: oldPopularDefault(br[EVERYTHING]) ? [] : br[EVERYTHING] || [] }, ...st().feeds.map(expandFeed)]; }; function feedDef(id = curFeedId) { - if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null; + if (id === FOLLOWING || id === EVERYTHING || id === REPLIES) return feedList().find((f) => f.id === id) || null; const own = st().feeds.find((f) => f.id === id); return own ? expandFeed(own) : adhocFeeds.get(id) || null; } @@ -5862,14 +5899,14 @@ export function messagesFeature(ctx) { feed = c; // Only the opening page gates first paint. The next page warms on the // side, so an offscreen image cannot delay the whole feed. - c.boot = notesReady(stored.slice(0, FEED_PAGE)).finally(() => { + c.boot = notesReady(stored.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => { c.booting = false; c.prepared = null; if (!c.stopped && ui.chatOpen && ui.msgView === 'feed' && c === feed) scheduleRepaint(); }); c.boot.then(() => { if (c.stopped) return; - notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2)); + notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2), { parent: feedReplies(c) }); }); prefetchFeed(c); } @@ -5913,8 +5950,8 @@ export function messagesFeature(ctx) { const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def)); if (!authors.size && !topics.size) return []; const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1]))); - const fits = (e) => ((!authors.size || authors.has(e.pubkey)) - && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT)); + const fits = (e) => feedKeeps(def, e) && (((!authors.size || authors.has(e.pubkey)) + && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT))); const seen = new Set(), out = []; for (const o of feedStates.values()) { for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); } @@ -5936,10 +5973,10 @@ export function messagesFeature(ctx) { if (!authors.length && !topics.length) continue; try { const events = (await Promise.all((authors.length ? authorChunks(authors) : [null]).map((chunk) => - queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2, + queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: feedKinds(def), limit: FEED_SEED_POSTS * 2, ...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000)))).flat(); if (feedStates.has(def.id)) continue; // opened while we were asking - disk.seed(def.id, events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e))); + disk.seed(def.id, events.filter((e) => feedKeeps(def, e) && !hidden(e))); } catch {} } } @@ -6048,7 +6085,7 @@ export function messagesFeature(ctx) { if (!def) return null; const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : ''; if (def.all) return (def.curated ? '/feed' : '/feed/all') + r; - if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null; + if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null; if (def.of) return '/feed/' + npubOf(def.of) + r; const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || []; if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r; @@ -6685,12 +6722,21 @@ export function messagesFeature(ctx) { await warmMedia(avatarUrl(p, pk)); if (p?.picture) makeThumb(pk, p); } - async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) { + // parent: the post a reply answers is part of the row (a conversations + // feed), so it arrives with the row; elsewhere a reply's parent is fetched + // only where it is shown (a profile's Replies tab fills its cards in). + async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0, { parent: withParent = false } = {}) { if (ev && ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return; ev = inner; } const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })), ...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))), ...notePreviewUrls(ev.content).map(warmPreview), ...[...emojiTagMap(ev.tags).values()].map(warmMedia)]; + // a reply's parent, shown above it where replies are shown with context + const parent = withParent && !depth ? parentRefOf(ev) : null; + if (parent) { + const quote = quotedNote(parent); + tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1))); + } for (const part of String(ev.content || '').split(NOTE_SPLIT)) { if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, '')); @@ -6708,7 +6754,7 @@ export function messagesFeature(ctx) { await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]); } catch {} finally { clearTimeout(timer); } } - const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); }; + const notesReady = (evs, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); }; const keyed = (node, key) => { node.setAttribute('data-key', key); return node; }; // Keep only the resources this row actually uses. Copying the entire // session media cache into every row grows quadratically as you scroll. @@ -6717,6 +6763,9 @@ export function messagesFeature(ctx) { if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null); return feedPaint.media.get(url); } + // a conversations feed: each reply under the post it answers (remembered + // on the feed's state; a changed definition rebuilds the state) + const feedReplies = (c) => (c.replies ??= !!(feedDef(c.id) || {}).replies); function feedRow(c, ev) { let presentation = c.presentations.get(ev.id); if (!presentation) { @@ -6726,7 +6775,7 @@ export function messagesFeature(ctx) { const prev = feedPaint; feedPaint = presentation; try { - const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id); + const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id); if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); } return row; } @@ -6853,10 +6902,10 @@ export function messagesFeature(ctx) { const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); }; const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); }; const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } }; - let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e) + const def = feedDef(c.id); + let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); if (!add.length) return false; - const def = feedDef(c.id); if (def && def.curated) { const kept = curateFeed(add); for (const e of add) if (!kept.includes(e)) unstage(e); @@ -6871,7 +6920,7 @@ export function messagesFeature(ctx) { c.booting = true; for (const e of add) unstage(e); admitFeed(add, c); - c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => { + c.boot = notesReady(c.notes.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => { c.booting = false; c.prepared = null; if (!c.stopped && c === feed) scheduleRepaint(); @@ -6882,7 +6931,7 @@ export function messagesFeature(ctx) { // Each row waits only for its own presentation. A slow photo or quote // cannot hold ready text posts (or their new-post notice) behind it. const ready = await Promise.all(add.map(async (e) => { - await noteReady(e); + await noteReady(e, undefined, 0, { parent: feedReplies(c) }); unstage(e); if (c.stopped) return false; // Cached rows can still be warming. This new row is already prepared @@ -7135,6 +7184,7 @@ export function messagesFeature(ctx) { async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) { const def = feedDef(c.id); if (!def) return false; + if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies const authors = feedAuthors(def), topics = feedTopics(def); const tag = topics.length ? { '#t': topics } : {}; let got = false; @@ -7368,16 +7418,17 @@ export function messagesFeature(ctx) { 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); 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: FEED_KINDS, ...tag, since }, on)); 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: FEED_KINDS, '#t': orT, since }, on)); + 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 ? FEED_KINDS : [...FEED_KINDS, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); + feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on)); } function stopFeedWatch() { for (const u of feedUnsubs) { try { u(); } catch {} } @@ -7413,7 +7464,7 @@ export function messagesFeature(ctx) { if (c.shown < c.notes.length) { const end = Math.min(c.shown + FEED_PAGE, c.notes.length); const boundary = c.notes[end - 1].id; - await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length))); + await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)), { parent: feedReplies(c) }); c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1); return; } @@ -7948,7 +7999,11 @@ export function messagesFeature(ctx) { function quotedNote(ref) { let c = quoted.get(ref.id); if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c; - if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); } + if (!c) { + const have = noteInHand(ref.id); + if (have) { c = { status: 'ready', ev: have, at: Date.now(), promise: Promise.resolve() }; quoted.set(ref.id, c); return c; } + c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); + } c.at = Date.now(); c.promise = (async () => { c.ev = (await findNote(ref).catch(() => null)) || c.ev || null; @@ -9945,13 +10000,16 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } - function noteRow(pk, ev, name, { open = true, focus = false, joined = false } = {}) { + // context: a reply shows the post it answers above itself (the + // Conversations feed, a profile's Replies tab) — a quote card, which opens + // that post; the reply's own tap opens the thread at the reply. + function noteRow(pk, ev, name, { open = true, focus = false, joined = false, context = false } = {}) { if (ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return h('div', { class: 'row note-post', style: 'display:none' }); const prev = repostBy; repostBy = ev.pubkey; - try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined }); } + try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined, context }); } finally { repostBy = prev; } } // who reposted this, for its header line (only this row: a quote inside draws none) @@ -9976,6 +10034,7 @@ export function messagesFeature(ctx) { const foreign = ev.kind !== 1 && ev.kind !== ARTICLE && ev.kind !== POLL && ev.kind !== COMMENT && !isVideoEvent(ev) && !zapRec; const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e')); + const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null; const canZap = canZapPk(pk); // an optimistic post mid-publish: visible but not yet a real event — // dimmed, and no thread/reply/zap until its signed self takes over @@ -10015,6 +10074,9 @@ export function messagesFeature(ctx) { h('div', { class: 'note-content' }, reposter ? h('div', { class: 'small faint note-reposted', onClick: (e) => { e.stopPropagation(); openProfile(reposter); } }, h('span', { 'aria-hidden': 'true' }, '\u{1F501} '), t('postRepostedBy', { name: displayName(reposter) })) : null, + parent ? h('div', { class: 'note-context', 'data-context-of': parent.id }, + h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')), + quoteCard(parent, 0)) : null, h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' }, h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, h('span', { @@ -10024,7 +10086,7 @@ export function messagesFeature(ctx) { onClick: (e) => { e.stopPropagation(); openProfile(pk); }, }, name), h('span', { class: 'small faint', style: 'white-space:nowrap' }, - (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000) + (edited ? ' · ' + t('postEditedTag') : ''))), pending ? null : h('button', { // the overflow stays up here; reply, boost, quote, react and zap @@ -10171,7 +10233,9 @@ export function messagesFeature(ctx) { for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() }); const rootId = stored?.rootId || requestedRootId; c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, prefetched: prefetch, - root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} }; + // a root already in hand (the post a conversation row answers sits in + // the Following feed) paints at once; the relays only fill the replies + root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} }; if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed); c.replies.sort((a, b) => a.created_at - b.created_at); threadCache.set(requestedRootId, c); @@ -11089,7 +11153,7 @@ export function messagesFeature(ctx) { return h('div', { class: 'col', style: 'gap:8px' }, tabs, h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab }, - ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name), ev.id)))), + ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))), c.loadingMore ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' })) @@ -11288,7 +11352,7 @@ export function messagesFeature(ctx) { : n ? t('feedSumPeople', { n }) : ''; const packs = ((f.packs || []).map((p) => (packOf(p) || {}).title || p.title).filter(Boolean)).join(', '); const topics = feedTopics(f).map((x) => '#' + x).join(' '); - return [people, packs, topics].filter(Boolean).join(' \u00b7 ') || t('feedNoQuery'); + return [people, packs, topics, f.replies ? t('feedSumReplies') : ''].filter(Boolean).join(' \u00b7 ') || t('feedNoQuery'); } // The row of feeds under the title: Following, then yours, then + for // a new one. A topic opened for the session sits at the end until saved. @@ -11490,7 +11554,7 @@ export function messagesFeature(ctx) { const key = mediaIdxKey(feedId); let r = mediaIdx.get(key); if (r) return r; - r = { key, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; + r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; mediaIdx.set(key, r); loadMediaIndex(key).then(async (rec) => { if (rec) { @@ -11529,7 +11593,7 @@ export function messagesFeature(ctx) { for (const e of evs) { if (r.checked.has(e.id)) continue; r.checked.add(e.id); - if (!FEED_KINDS.includes(e.kind) || isReply(e)) continue; + if (r.replies ? !replyish(e) : !FEED_KINDS.includes(e.kind) || isReply(e)) continue; for (const kind of ['images', 'videos']) if (!r[kind].has(e.id) && hasMediaOf(e, kind)) { r[kind].set(e.id, e); n++; } } if (n) saveMediaIdx(r); @@ -13144,7 +13208,7 @@ export function messagesFeature(ctx) { const visNotes = c.notes, visShown = c.shown; c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE)); const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id) - ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev))); + ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id) : feedRow(c, ev))); // Posts that went in above you while you were reading. A floating pill // that says how many are up there; the tap takes you up to them. // Keyed, so the morph keeps this very node while the count changes — @@ -13255,10 +13319,10 @@ export function messagesFeature(ctx) { const isNew = !def || adhocFeeds.has(def.id); def = (def && st().feeds.find((f) => f.id === def.id)) || def; // as saved, not expanded ui.feedEdit = def - ? { id: def.id, name: def.name || '', follows: !!def.follows, lists: !!def.lists, interests: !!def.interests, authors: [...(def.authors || [])], priv: [...(def.priv || [])], packs: [...(def.packs || [])], + ? { id: def.id, name: def.name || '', follows: !!def.follows, lists: !!def.lists, interests: !!def.interests, replies: !!def.replies, authors: [...(def.authors || [])], priv: [...(def.priv || [])], packs: [...(def.packs || [])], topics: feedTopics(def).map((x) => '#' + x).join(' '), q: '', rows: null, pq: '', packRows: null, isNew, d: def.d || null, publish: isNew || !!def.d, relays: (def.relays || []).map((r) => r.replace(/^wss:\/\//, '')).join(' '), all: !!def.all && !def.builtin, curated: !!def.curated } - : { id: null, name: '', follows: false, lists: false, interests: false, authors: [], priv: [], packs: [], topics: '', q: '', rows: null, pq: '', packRows: null, isNew: true, d: null, publish: true, + : { id: null, name: '', follows: false, lists: false, interests: false, replies: false, authors: [], priv: [], packs: [], topics: '', q: '', rows: null, pq: '', packRows: null, isNew: true, d: null, publish: true, relays: '', all: false, curated: false }; render(); } @@ -13284,6 +13348,7 @@ export function messagesFeature(ctx) { const def = e.all ? { id, name, follows: false, authors: [], packs: [], topics: [], all: true, curated: !!e.curated, relays, at: Date.now() } : { id, name, follows: e.follows, lists: !!e.lists, interests: !!e.interests, authors: e.authors.slice(0, FEED_AUTHORS_MAX), packs: e.packs.slice(0, 20), topics, relays, all: false, curated: false, at: Date.now() }; + if (e.replies) def.replies = true; if (!relays.length) delete def.relays; if (!def.lists) delete def.lists; if (!def.interests) delete def.interests; @@ -13449,6 +13514,12 @@ export function messagesFeature(ctx) { h('input', { type: 'checkbox', class: 'feed-interests-toggle', checked: !!e.interests, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.interests = ev.target.checked; } }), h('span', {}, t('feedInterestsToggle'))), h('div', { class: 'small faint' }, t('feedInterestsHelp'))), + // their replies instead of their posts: a conversations feed + h('div', { class: 'col', style: 'gap:4px' }, + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', class: 'feed-replies-toggle', checked: !!e.replies, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => { e.replies = ev.target.checked; } }), + h('span', {}, t('feedRepliesToggle'))), + h('div', { class: 'small faint' }, t('feedRepliesHelp'))), // a feed of people is a nostr list (NIP-51 follow set) unless told not to be !e.all && (e.authors.length || (e.priv || []).length || e.d) ? h('div', { class: 'col', style: 'gap:4px' }, diff --git a/src/feed-cache.js b/src/feed-cache.js index 2c0b153..a4f3ac9 100644 --- a/src/feed-cache.js +++ b/src/feed-cache.js @@ -6,7 +6,7 @@ export const FEED_SEED_POSTS = 5; const FULL_FEEDS = 4, MAX_FEEDS = 12, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000; export function createFeedCache(storage, prefix, now = Date.now) { const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id; - const valid = (e) => (e?.kind === 1 || e?.kind === 1068) && typeof e.id === 'string' && typeof e.pubkey === 'string' + const valid = (e) => (e?.kind === 1 || e?.kind === 1068 || e?.kind === 1111) && typeof e.id === 'string' && typeof e.pubkey === 'string' && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags); function compact(notes, max = FEED_CACHE_POSTS) { const kept = []; let size = 0; diff --git a/src/i18n.js b/src/i18n.js index cdbcfeb..e561e5d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -303,6 +303,11 @@ const STR = { currencyLabel: 'Show amounts in', feedTitle: 'Feed', feedFollowingName: 'Following', + feedConversations: 'Conversations', + feedRepliesToggle: 'Replies instead of posts', + feedRepliesHelp: 'A conversations feed: what these people reply, each under the post it answers. Their own posts stay out of it.', + feedSumReplies: 'replies', + noteReplyingTo: 'Replying to', feedNew: 'New feed', feedNewName: 'New feed', feedEdit: 'Edit feed', diff --git a/src/style.css b/src/style.css index 4a9b191..2090e24 100644 --- a/src/style.css +++ b/src/style.css @@ -1797,6 +1797,11 @@ body::after { .gif-tile video, .gif-tile img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } .gif-note { display: flex; justify-content: center; padding: 32px 0; } .note-reposted { margin-bottom: 2px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* a reply shown with the post it answers above it (Conversations, a profile's Replies) */ +.note-context { margin-bottom: 6px; } +.note-context-label { margin-bottom: 2px; } +.note-context .quote-card { margin-top: 0; } +.note-context .quote-card .note-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; overflow: hidden; } /* attach + GIF inside the composer's field, bottom right; the field keeps room under its text so a long post never runs beneath them */ .compose-box { position: relative; } diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js new file mode 100644 index 0000000..0e408fd --- /dev/null +++ b/tools/feed-conversations-test.js @@ -0,0 +1,127 @@ +// A Conversations feed beside Following: the replies the people you follow +// write, each under the post it answers (Amethyst's Conversations). The +// Following feed keeps only their posts; a profile's Replies tab shows the +// same context; a custom feed can be a conversations feed too. +// Run: bun tools/feed-conversations-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +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 SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SKA, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const B1 = post('bob root here, a question for everyone', 400, SKB); +const A1 = post('alice root post about nothing', 300); +const MISSING = 'f'.repeat(64); +const R1 = post('alice answers bob right here', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]); +const R2 = post('alice replies to a post nobody has', 100, SKA, [['e', MISSING, '', 'root']]); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return 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(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const rowIds = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post'))); +const contextOf = (id) => page.evaluate((id) => { + const r = document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'); + const c = r && r.querySelector('.note-context'); + return c ? { of: c.getAttribute('data-context-of'), text: c.innerText, card: !!c.querySelector('.quote-card.clickable') } : null; +}, id); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, alice, bob, roots, replies]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(roots)); + localStorage.setItem(k + ':feedNotes:replies', JSON.stringify(replies)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Replier', about: 'alice bio here', t: Date.now() }, [bob]: { name: 'Bob Asker', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, ALICE, BOB, [A1, B1], [R1, R2]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('Following paints their posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000)); + 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 chips = await page.$$eval('.feed-chip[data-key]', (l) => l.map((e) => e.getAttribute('data-key') + '=' + e.textContent.trim())); + check('a Conversations chip beside Following', chips[0] === 'chip:following=Following' && chips[1] === 'chip:replies=Conversations', chips.join(' ')); + + console.log('\n[Conversations]'); + await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:replies"]').click()); + check('the replies paint', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000)); + const convo = await rowIds(); + check('…only replies, newest first', JSON.stringify(convo) === JSON.stringify([R2.id, R1.id]), convo.map((x) => x.slice(0, 6)).join(',')); + check('chip marked on', await page.$eval('.feed-chip[data-key="chip:replies"]', (e) => e.classList.contains('on'))); + 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); + await page.evaluate(() => document.querySelector('button[aria-label="Feed options"]').click()); await sleep(300); + const menu = await page.evaluate(() => document.querySelector('.msg-sheet')?.innerText || ''); + check('the feed menu has relays but no share link for it (it is nobody\'s timeline)', /relays/i.test(menu) && !/share/i.test(menu), menu.replace(/\n/g, ' | ')); + await click('back'); await sleep(300); + // 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)); + check('…with Bob\'s post as its root', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('.feed-chips'), B1.id, 4000)); + await page.goBack(); await sleep(800); + check('Back lands on Conversations again', await waitFor(() => document.querySelector('.feed-chip[data-key="chip:replies"]')?.classList.contains('on'), null, 6000)); + // the context card's tap: the parent's own thread + await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context .quote-card').click(), R1.id); + check('tapping the post it answers opens that post, at the top', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('[data-focus-note]') && !document.querySelector('.feed-chips'), B1.id, 8000)); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + console.log('\n[a profile\'s Replies tab]'); + await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-avatar').click(), R1.id); + check('Alice\'s profile opens', await waitText('alice bio here', 8000)); + await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="replies"]').click()); + check('her Replies tab lists the reply', await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), R1.id, 8000)); + const pc = await contextOf(R1.id); + check('…under the post it answers', !!pc && pc.of === B1.id && /bob root here/.test(pc.text), JSON.stringify(pc)); + await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="posts"]').click()); + await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), A1.id, 8000); + check('her Posts tab shows no context block', (await page.$$('.notes-feed .note-context')).length === 0); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + console.log('\n[a custom conversations feed]'); + await page.evaluate(() => document.querySelector('.feed-chip.add').click()); + check('the editor offers replies instead of posts', await waitFor(() => !!document.querySelector('.feed-replies-toggle'), null, 4000)); + await page.evaluate(() => { + const name = document.querySelector('.chat-page input[type=text]'); name.value = 'Alice talks'; name.dispatchEvent(new Event('input', { bubbles: true })); + const follows = [...document.querySelectorAll('label')].find((l) => /Everyone I follow/.test(l.textContent)).querySelector('input'); follows.click(); + document.querySelector('.feed-replies-toggle').click(); + }); + 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('…holding the replies (borrowed from the feeds in hand), not the 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]); }, [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('.feed-chip[data-key="chip:following"]').click()); + check('Following is still 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)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ conversations: the replies they write, under what they answer' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js index 122cb0a..6f345f1 100644 --- a/tools/feed-custom-test.js +++ b/tools/feed-custom-test.js @@ -108,7 +108,7 @@ try { await waitText('receive', 20000); await openFeed(); await sleep(1200); - check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); + check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Conversations', 'Popular', '#gardenstr', '+']), JSON.stringify(await chips())); console.log('\n[a new feed: everyone I follow, on one topic]'); await page.click('.feed-chip.add'); await sleep(300); @@ -117,7 +117,7 @@ try { await page.click('input[type=checkbox]:not(.feed-all-toggle)'); // everyone I follow (the first box is the relays' every-post one) await type('input[placeholder="#bitcoin #nostr"]', '#Bitcoin, lightning'); await click('save'); await sleep(1500); - check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); + check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Conversations', 'Popular', '#gardenstr', '*Coin talk', '+']), JSON.stringify(await chips())); const req = (await page.evaluate(() => window.__reqs)).find((f) => f['#t'] && f.authors); check('it asks relays for the follows AND the topics', !!req && req.authors?.includes(AUTHOR) && JSON.stringify(req['#t']) === JSON.stringify(['bitcoin', 'lightning']), JSON.stringify(req || null)); const tagged = signed('fresh #bitcoin post', 0, [['t', 'bitcoin']]); @@ -132,7 +132,7 @@ try { await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Following').click()); await sleep(1200); check('Following again, with its own posts', (await chips()).includes('*Following') && await waitText('plain post', 4000)); await page.evaluate(() => { const a = [...document.querySelectorAll('.notes-feed a')].find((x) => x.textContent === '#bitcoin'); if (a) a.click(); }); await sleep(1200); - check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => { const m = document.querySelector('[aria-label="Feed options"]'); if (!m) return false; m.click(); return true; }) && await waitText('save feed', 2000) && await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].find((x) => /back/i.test(x.textContent)); if (b) b.click(); return true; }), JSON.stringify(await chips())); + check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Conversations', 'Popular', '#gardenstr', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => { const m = document.querySelector('[aria-label="Feed options"]'); if (!m) return false; m.click(); return true; }) && await waitText('save feed', 2000) && await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].find((x) => /back/i.test(x.textContent)); if (b) b.click(); return true; }), JSON.stringify(await chips())); const treq = (await page.evaluate(() => window.__reqs)).filter((f) => f['#t'] && !f.authors).pop(); check('...asked for by topic alone', !!treq && JSON.stringify(treq['#t']) === JSON.stringify(['bitcoin']), JSON.stringify(treq || null)); @@ -183,7 +183,7 @@ try { await waitText('receive', 20000); await openFeed(); await sleep(1200); // (the feed you were on is remembered: Follows + lists, saved last) - check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Popular', '#gardenstr', 'Coin talk', 'Pack feed', 'Everything', '*Follows + lists', '+']), JSON.stringify(await chips())); + check('the saved feeds are still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Conversations', 'Popular', '#gardenstr', 'Coin talk', 'Pack feed', 'Everything', '*Follows + lists', '+']), JSON.stringify(await chips())); // (the chat home no longer lists the feed with its summary: its editor shows the pack) await chip('Pack feed'); await sleep(800); await page.click('[aria-label="Feed options"]'); await sleep(300); @@ -199,7 +199,7 @@ try { await page.click('[aria-label="Feed options"]'); await sleep(300); check('the menu offers Delete feed beside Edit', await page.evaluate(() => { const b = [...document.querySelectorAll('.msg-sheet button')].map((x) => x.textContent); return b.some((x) => /edit feed/i.test(x)) && b.some((x) => /delete feed/i.test(x)); })); await page.evaluate(() => [...document.querySelectorAll('.msg-sheet button')].find((b) => /delete feed/i.test(b.textContent)).click()); await sleep(1000); - check('deleting it from the menu lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Popular', '#gardenstr', 'Pack feed', 'Everything', 'Follows + lists', '+']), JSON.stringify(await chips())); + check('deleting it from the menu lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Conversations', 'Popular', '#gardenstr', 'Pack feed', 'Everything', 'Follows + lists', '+']), JSON.stringify(await chips())); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ feeds: Following is one of many' : '\n❌ failed'); -- cgit v1.2.3