From 05da05590bf8f8e8d54a0eee27d27fce3e85354d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 5 Oct 2026 00:49:03 -0700 Subject: Feed Articles view: long-form posts (NIP-23) from the feed's people/topics/relays as cards, own cursor + index; Popular keeps articles two others engaged with Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi --- src/features/messages.js | 145 ++++++++++++++++++++++++++++++++++++++++------- src/i18n.js | 2 + src/media-cache.js | 6 +- src/style.css | 2 + 4 files changed, 133 insertions(+), 22 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index f89eb43..3bd9ac2 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6167,7 +6167,7 @@ export function messagesFeature(ctx) { }, maxWait); return got; } - async function feedPass(extra = {}, merge = {}, c = feedNow()) { + async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) { const def = feedDef(c.id); if (!def) return false; const authors = feedAuthors(def), topics = feedTopics(def); @@ -6179,14 +6179,14 @@ export function messagesFeature(ctx) { // the firehose asks its relays for everything if (!topics.length && !def.all) return false; const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS); - return queryFeed(relays, { kinds: FEED_KINDS, ...tag, limit: FEED_LIMIT, ...extra }, merge, c); + return queryFeed(relays, { kinds, ...tag, limit, ...extra }, merge, c); } const readPlan = async (plan) => { await Promise.all(plan.flatMap(({ relays, authors: a }) => { const chunks = []; for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS)); return chunks.map(async (chunk) => { - if (await queryFeed(relays, { kinds: FEED_KINDS, authors: chunk, ...tag, limit: FEED_LIMIT, ...extra }, merge, c)) got = true; + if (await queryFeed(relays, { kinds, authors: chunk, ...tag, limit, ...extra }, merge, c)) got = true; }); })); }; @@ -6391,8 +6391,8 @@ export function messagesFeature(ctx) { if (mediaMode !== 'all') { // a grid holds only the posts with media: keep fetching while its // foot is near (within reason — a firehose has no end) - const g = document.querySelector('.media-grid'); - if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) crawlMedia(c).catch(() => {}); + const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid'); + if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {}); return; } if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging @@ -6830,20 +6830,25 @@ export function messagesFeature(ctx) { byline, h('div', { class: 'addr-title' }, '\u{1F465} ' + (title || ref.d)), n ? h('span', { class: 'small muted' }, t('feedPackMembers', { n })) : null); } - if (ref.kind === ARTICLE) { - const img = tagStr(ev, 'image'); - const summary = tagStr(ev, 'summary'); - return h('div', { class: 'quote-card clickable addr-article', onClick: (e) => { e.stopPropagation(); openNoteThread(ev); } }, - /^https:\/\//.test(img) ? h('img', { class: 'addr-cover', src: img, alt: '', loading: 'lazy' }) : null, - byline, - h('div', { class: 'addr-title' }, title || t('addrArticle')), - summary ? h('div', { class: 'small muted addr-summary' }, summary) : null, - h('span', { class: 'small faint' }, t('addrRead', { min: Math.max(1, Math.round(ev.content.split(/\s+/).length / 230)) }))); - } + if (ref.kind === ARTICLE) return articleCard(ev); const alt = tagStr(ev, 'alt'); return h('div', { class: 'quote-card' }, byline, h('div', { class: 'small muted' }, title || alt || t('noteForeignKind', { kind: ev.kind }))); } + // An article as a card: its cover, title and summary; a tap opens it to read. + function articleCard(ev) { + const img = tagStr(ev, 'image'); + const summary = tagStr(ev, 'summary'); + return h('div', { class: 'quote-card clickable addr-article', onClick: (e) => { e.stopPropagation(); openNoteThread(ev); } }, + /^https:\/\//.test(img) ? h('img', { class: 'addr-cover', src: img, alt: '', loading: 'lazy' }) : null, + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + avatar(ev.pubkey, 'chat-avatar mini', false), + h('span', { class: 'quote-name' }, displayName(ev.pubkey)), + h('span', { class: 'small faint', style: 'white-space:nowrap' }, timeLabel((+tagStr(ev, 'published_at') || ev.created_at) * 1000))), + h('div', { class: 'addr-title' }, tagStr(ev, 'title') || t('addrArticle')), + summary ? h('div', { class: 'small muted addr-summary' }, summary) : null, + h('span', { class: 'small faint' }, t('addrRead', { min: Math.max(1, Math.round(ev.content.split(/\s+/).length / 230)) }))); + } // An article's body: its markdown headings as headings, the rest as any post. function articleBody(ev) { const out = []; @@ -9781,7 +9786,7 @@ export function messagesFeature(ctx) { // it), built outside the morph so a repaint never restarts a playing clip. const MEDIA_MODE_KEY = 'coinos-feed-media'; let mediaMode = 'all'; - try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos') mediaMode = m; } catch {} + try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {} const mediaKey = (it) => it.ev.id + '|' + it.url; // the picture an imeta tag names for a clip (NIP-92 `image`), if any const posterOf = (ev, url) => { @@ -9804,12 +9809,17 @@ 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(), checked: new Set(), until: 0, end: false, empty: 0, loaded: false }; + 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 }; mediaIdx.set(key, r); loadMediaIndex(key).then(async (rec) => { if (rec) { for (const kind of ['images', 'videos']) for (const e of rec[kind] || []) if (!r[kind].has(e.id)) r[kind].set(e.id, e); if (rec.until && (!r.until || rec.until < r.until)) r.until = rec.until; + for (const e of rec.articles || []) fileArticle(r, e); + if (rec.art) { + if (rec.art.until && (!r.art.until || rec.art.until < r.art.until)) r.art.until = rec.art.until; + if (rec.art.end && Date.now() - rec.art.end < 86_400_000) r.art.end = r.art.end || rec.art.end; + } // the bottom of a feed is looked for again after a day (a relay // added, a follow back-filled) // (only an end confirmed by repeated empty answers: one empty @@ -9828,7 +9838,8 @@ export function messagesFeature(ctx) { clearTimeout(r.saveT); r.saveT = setTimeout(() => { const list = (m) => [...m.values()].sort((a, b) => b.created_at - a.created_at); - saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), until: r.until, end: r.end, endSure: !!r.end }); + saveMediaIndex(r.key, { images: list(r.images), videos: list(r.videos), articles: list(r.articles), + art: { until: r.art.until, end: r.art.end }, until: r.until, end: r.end, endSure: !!r.end }); }, 1500); } // file the posts that carry media; each post is looked at once @@ -9882,6 +9893,97 @@ export function messagesFeature(ctx) { return mediaCrawling; } + // ---- Articles: the feed's long-form posts (NIP-23) ---------------------- + // The feed itself reads notes and polls; articles are asked for apart, from + // the same people, topics and relays, with a cursor of their own (they're + // sparse: a page of notes back is not a page of articles back). Kept by + // address, so an edited article replaces its old copy. + const ARTICLE_PAGE = 50; + function fileArticle(r, e) { + if (!e || e.kind !== ARTICLE) return false; + const k = e.pubkey + ':' + ((e.tags || []).find((x) => x[0] === 'd') || [])[1]; + const cur = r.articles.get(k); + if (cur && cur.created_at >= e.created_at) return false; + r.articles.set(k, e); + return true; + } + let articlesCrawling = null; + function crawlArticles(c = feedNow()) { + const def = feedDef(c.id); + if (!def || !feedHasQuery(def)) return Promise.resolve(); + const r = mediaIndex(c.id); + const a = r.art; + if (!r.loaded || articlesCrawling) return articlesCrawling || Promise.resolve(); + // newer ones first, now and then; older ones while the list's foot is near + const newest = Math.max(0, ...[...r.articles.values()].map((e) => e.created_at)); + const head = newest && Date.now() - a.head > 5 * 60_000; + if (!head && a.end) return Promise.resolve(); + articlesCrawling = (async () => { + scheduleRepaint(); + let saw = 0, oldest = Infinity; + const sink = (ev) => { + if (ev.kind !== ARTICLE) return; + saw++; + if (ev.created_at < oldest) oldest = ev.created_at; + if (fileArticle(r, ev)) saveMediaIdx(r); + }; + const until = (a.until || Math.floor(Date.now() / 1000) + 1) - 1; + const span = head ? { since: newest + 1 } : { until }; + const filter = { kinds: [ARTICLE], limit: ARTICLE_PAGE, ...span }; + if (def.curated) { + // Popular's rule for articles too: prose in the reader's language + // that at least two other people reacted to, reposted, zapped or + // answered. The relays' raw newest is mostly spam and feed bots. + const relays = feedRelaysOr(def, POPULAR_RELAYS); + const wide = [...new Set([...relays, ...NOTE_RELAYS])]; + const evs = (await queryOn(wide, { ...filter, limit: 100 }, 5000).catch(() => [])).filter((e) => e.kind === ARTICLE); + for (const ev of evs) { saw++; if (ev.created_at < oldest) oldest = ev.created_at; } + const cands = curateFeed(evs).filter((e) => !hidden(e)); + if (cands.length) { + const coord = (e) => ARTICLE + ':' + e.pubkey + ':' + ((e.tags.find((x) => x[0] === 'd') || [])[1] || ''); + const byCoord = new Map(cands.map((e) => [coord(e), e])), byId = new Map(cands.map((e) => [e.id, e])); + const who = new Map(); // article id -> Set(pubkey) + const kinds = [1, 6, 7, 16, 1111, 9735]; + const [ra, re] = await Promise.all([ + queryOn(wide, { kinds, '#a': [...byCoord.keys()], limit: 2000 }, 4500).catch(() => []), + queryOn(wide, { kinds, '#e': [...byId.keys()], limit: 2000 }, 4500).catch(() => []), + ]); + for (const x of [...ra, ...re]) { + // a zap receipt's sender is in its description, not its pubkey + let from = x.pubkey; + if (x.kind === 9735) { try { from = JSON.parse((x.tags.find((t) => t[0] === 'description') || [])[1] || '{}').pubkey || null; } catch { from = null; } } + const hits = new Set(); + for (const tg of x.tags) { + if (tg[0] === 'a' && byCoord.has(tg[1])) hits.add(byCoord.get(tg[1])); + if (tg[0] === 'e' && byId.has(tg[1])) hits.add(byId.get(tg[1])); + } + for (const e of hits) if (from && from !== e.pubkey) { if (!who.has(e.id)) who.set(e.id, new Set()); who.get(e.id).add(from); } + } + for (const e of cands) if ((who.get(e.id)?.size || 0) >= 2 && fileArticle(r, e)) saveMediaIdx(r); + } + } else { + try { await feedPass(span, { sink }, c, [ARTICLE], ARTICLE_PAGE); } catch {} + } + if (head) a.head = Date.now(); + else if (saw) { a.empty = 0; a.until = Math.min(oldest, until); } + else if (++a.empty >= 3) a.end = Date.now(); + saveMediaIdx(r); + })().finally(() => { articlesCrawling = null; scheduleRepaint(); }); + return articlesCrawling; + } + function articleList(c) { + const r = mediaIndex(c.id); + const items = [...r.articles.values()].filter((e) => !hidden(e)) + .sort((x, y) => (+tagStr(y, 'published_at') || y.created_at) - (+tagStr(x, 'published_at') || x.created_at)); + if (!items.length) { + return !r.loaded || articlesCrawling || !r.art.end + ? h('div', { class: 'row gap6', 'data-key': 'media-empty', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) + : h('div', { class: 'small faint', 'data-key': 'media-empty', style: 'text-align:center;padding:12px 0' }, t('mediaNoArticles')); + } + return h('div', { class: 'col article-list', 'data-key': 'article-list' }, + ...items.map((ev) => h('div', { 'data-key': 'art:' + ev.id, style: 'display:contents' }, articleCard(ev)))); + } + // A clip's first frame, kept: drawn from the tile's player once it has // one (when the host allows the pixels to be read), stored small in the // Cache API, and painted as a plain picture from then on — in the grid, @@ -10106,7 +10208,7 @@ export function messagesFeature(ctx) { onClick: () => setMediaMode(m), }, label); return h('div', { class: 'row media-modes', 'data-key': 'media-modes' }, - b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos'))); + b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles'))); } function mediaGrid(c) { const items = mediaItems(c, mediaMode); @@ -10560,16 +10662,17 @@ export function messagesFeature(ctx) { ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:12px 0' }, h('span', { class: 'spinner sm' })) : !visible.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty')) + : mediaMode === 'articles' ? articleList(c) : mediaMode !== 'all' ? mediaGrid(c) : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows), // The foot of the feed says what is happening down there: older // posts on their way, or nothing older left to fetch. (Older pages // prepare offscreen, so the line is mostly seen by a reader who // outruns them.) - c.loadingMore || (mediaMode !== 'all' && mediaCrawling) + c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : mediaMode !== 'all' && mediaCrawling) ? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore'))) - : mediaMode !== 'all' ? (mediaIndex(c.id).end ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) + : mediaMode !== 'all' ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null) : c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null, diff --git a/src/i18n.js b/src/i18n.js index f8bb138..d4699ae 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -306,6 +306,8 @@ const STR = { mediaAll: "All", mediaImages: "Images", mediaVideos: "Videos", + mediaArticles: "Articles", + mediaNoArticles: "No articles in this feed yet.", mediaNoImages: "No pictures in this feed yet.", mediaNoVideos: "No vertical videos in this feed yet.", feedRelays: 'Relays', diff --git a/src/media-cache.js b/src/media-cache.js index ee9e118..74c574a 100644 --- a/src/media-cache.js +++ b/src/media-cache.js @@ -41,6 +41,8 @@ async function tx(mode, fn) { }); } +export const ARTICLES_KEEP = 150; // articles per feed — whole, so a tap reads offline + // what a grid needs of a post: who, when, the words, and its imeta export const compactMediaEvent = (e) => ({ id: e.id, pubkey: e.pubkey, created_at: e.created_at, kind: e.kind, content: String(e.content || '').slice(0, 2000), @@ -55,7 +57,9 @@ export async function saveMediaIndex(key, rec) { try { await tx('readwrite', (s) => { s.put({ ...rec, images: (rec.images || []).slice(0, MEDIA_KEEP).map(compactMediaEvent), - videos: (rec.videos || []).slice(0, MEDIA_KEEP).map(compactMediaEvent), at: Date.now() }, key); + videos: (rec.videos || []).slice(0, MEDIA_KEEP).map(compactMediaEvent), + articles: (rec.articles || []).slice(0, ARTICLES_KEEP) + .map((e) => ({ ...e, content: String(e.content || '').slice(0, 100_000) })), at: Date.now() }, key); }); } catch {} } diff --git a/src/style.css b/src/style.css index bd9d41a..7544c07 100644 --- a/src/style.css +++ b/src/style.css @@ -1383,6 +1383,8 @@ body::after { /* ---- feed media views: All / Images / Videos, the grid, the pager ---- */ .media-modes { gap: 6px; } .media-modes .feed-chip { padding: 4px 12px; font-size: 13px; } +.article-list { gap: 10px; } +.article-list > .quote-card { margin-top: 0; } .media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; border-radius: 12px; overflow: hidden; } .media-tile { position: relative; display: block; aspect-ratio: 1; padding: 0; margin: 0; border: 0; border-radius: 0; -- cgit v1.2.3