diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 133 |
1 files changed, 102 insertions, 31 deletions
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' }, |
