diff options
| author | Adam Soltys <[email protected]> | 2026-10-05 00:44:53 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-05 00:44:53 -0700 |
| commit | c2a4e13e5ce6b5df522d23bf94bde01f1a9bcbf5 (patch) | |
| tree | 0f6343f2309493d64c04a23f2ffbf7589b345cc3 /src | |
| parent | 5d82c98970e3fa7ac4153294e0e27a5cb1a7f480 (diff) | |
| download | coinosv3-c2a4e13e5ce6b5df522d23bf94bde01f1a9bcbf5.tar.gz coinosv3-c2a4e13e5ce6b5df522d23bf94bde01f1a9bcbf5.zip | |
nostr:naddr renders: article cards (cover, title, summary) that open to read with headings, list/pack cards that open as feeds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 102 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/style.css | 10 |
3 files changed, 115 insertions, 2 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index c32c1ce..f89eb43 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6675,6 +6675,7 @@ export function messagesFeature(ctx) { // until a reload. A missing note is asked for again the next time it is // wanted, half a minute on, with any relay hints the reference carried. const QUOTE_RETRY_MS = 30_000; + const ARTICLE = 30023; // NIP-23 long-form const quoted = new Map(); // id -> { status, ev, at } // Where a referenced note may be: the reference's hints, its author's own // relays, ours — and when none of those has it, a wider net of big public @@ -6770,6 +6771,96 @@ export function messagesFeature(ctx) { ev.kind === POLL ? pollBox(ev, { interactive: false }) : null); } + // A nostr:naddr: a replaceable thing by its address (kind:author:d) — an + // article, a list. Its newest copy, from the hint relays, the author's, + // ours, and the wide net at once; the newest answer within the wait wins. + const addrKey = (ref) => 'a:' + ref.kind + ':' + ref.pk + ':' + ref.d; + async function findAddr(ref) { + const filter = { kinds: [ref.kind], authors: [ref.pk], '#d': [ref.d] }; + const near = [...new Set([...(ref.relays || []), ...zapRelays()])]; + const theirs = await Promise.resolve(relaysOf(ref.pk)).catch(() => []); + const relays = [...new Set([...near, ...(theirs || []), ...WIDE_RELAYS])]; + const evs = await queryOn(relays, filter, 4500).catch(() => []); + return (evs || []).filter((e) => e.kind === ref.kind && e.pubkey === ref.pk) + .sort((a, b) => b.created_at - a.created_at)[0] || null; + } + function addrNote(ref) { + const key = addrKey(ref); + let c = quoted.get(key); + 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(key, c); } + c.at = Date.now(); + profileOf(ref.pk); + findAddr(ref).catch(() => null).then((ev) => { + c.ev = ev || c.ev || null; + c.status = c.ev ? 'ready' : 'missing'; + scheduleRepaint(); + }); + return c; + } + const tagStr = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1] || ''; + // The card for it. An article shows its cover, title and summary and opens + // to read; a list or pack opens as a feed; anything else says what it is. + function addrCard(ref) { + const key = addrKey(ref); + let c = feedPaint ? feedPaint.quotes.get(key) : null; + if (!c?.ev) { + c = addrNote(ref); + if (feedPaint && c.ev) feedPaint.quotes.set(key, { ...c }); + } + const isList = ref.kind === LIST_KIND || ref.kind === PACK_KIND; + if (!c.ev && isList) { + // a list needs nothing fetched to open: the feed resolves its members + return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title: '' }); } }, + h('span', { class: 'quote-name' }, '\u{1F465} ' + t('addrList', { name: displayName(ref.pk) }))); + } + if (!c.ev) return h('div', { class: 'quote-card' + (c.status === 'loading' ? ' quote-loading' : '') }, + c.status === 'loading' ? h('span', { class: 'spinner sm' }) : null, + h('span', { class: 'small faint' }, t(c.status === 'loading' ? 'addrLoading' : 'addrNotFound'))); + const ev = c.ev; + if (hidden(ev)) return h('div', { class: 'quote-card' }, h('span', { class: 'small faint' }, t('quoteHidden'))); + const title = tagStr(ev, 'title') || tagStr(ev, 'name'); + const byline = 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))); + if (isList) { + const n = ev.tags.filter((x) => x[0] === 'p').length; + return h('div', { class: 'quote-card clickable', onClick: (e) => { e.stopPropagation(); openListFeed({ pk: ref.pk, d: ref.d, kind: ref.kind, title }); } }, + 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)) }))); + } + 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's body: its markdown headings as headings, the rest as any post. + function articleBody(ev) { + const out = []; + const title = tagStr(ev, 'title'); + let text = ev.content; + // most clients repeat the title as the first heading + if (title) text = text.replace(new RegExp('^\\s*#\\s+' + title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\n'), ''); + if (title) out.push(h('div', { class: 'article-title' }, title)); + const em = emojiTagMap(ev.tags); + for (const chunk of text.split(/^(#{1,6} .*)$/m)) { + const hd = /^(#{1,6}) (.*)$/.exec(chunk); + if (hd) out.push(h('div', { class: 'article-h' + Math.min(hd[1].length, 3) }, hd[2].trim())); + else if (chunk.trim()) out.push(...noteBody(chunk.replace(/^\n+|\n+$/g, ''), 0, em)); + } + return out; + } + // One URL, rendered as whatever it points at. `isImage` is markdown saying // so outright — plenty of perfectly good picture URLs carry no extension // (a CDN path, a /media/ route), and ![…] is the author telling us what it @@ -7129,6 +7220,13 @@ export function messagesFeature(ctx) { href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); openNoteRef(ref); }, }, t('noteRefLink')) : quoteCard(ref, depth)); } else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); + } else if (/^nostr:naddr1/i.test(part)) { + const ref = parseNostrRef(part.slice(6)); + if (ref && ref.type === 'addr' && !depth) out.push(addrCard(ref)); + else if (ref && ref.type === 'addr') out.push(h('a', { + href: '#', onClick: (e) => { e.preventDefault(); e.stopPropagation(); const c = addrNote(ref); if (c.ev) openNoteThread(c.ev); }, + }, t('noteRefLink'))); + else out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^nostr:/i.test(part)) { out.push(h('span', { class: 'faint' }, part.slice(6, 18) + '…')); } else if (/^@[A-Za-z0-9_]{2,32}$/.test(part)) { @@ -8338,7 +8436,7 @@ export function messagesFeature(ctx) { // An event of a kind this app has no renderer for (a chess game, kind // 64, reached through a reaction to it) is shown as what its author's // client said it is (NIP-31 alt), never as raw content dressed as a post. - const foreign = ev.kind !== 1 && ev.kind !== 30023 && ev.kind !== POLL && ev.kind !== COMMENT; + const foreign = ev.kind !== 1 && ev.kind !== ARTICLE && ev.kind !== POLL && ev.kind !== COMMENT; const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null; const isReply = ev.kind === COMMENT || (!foreign && ev.tags.some((x) => x[0] === 'e')); const canZap = canZapPk(pk); @@ -8387,7 +8485,7 @@ export function messagesFeature(ctx) { }, '\u22ef')), foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) - : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(ev.content, 0, emojiTagMap(ev.tags))), + : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? articleBody(ev) : noteBody(ev.content, 0, emojiTagMap(ev.tags)))), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), !pending && whoOpen(ev.id) ? whoPanel(ev) : null)); diff --git a/src/i18n.js b/src/i18n.js index 20818c7..f8bb138 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -476,6 +476,11 @@ const STR = { noteRefLink: 'view note', noteRefLoading: 'Fetching note…', noteRefNotFound: "Couldn't find that note on the relays.", + addrLoading: 'Fetching…', + addrNotFound: "Couldn't find that on the relays.", + addrArticle: 'Article', + addrRead: '{min} min read', + addrList: 'A list by {name}', msgSignerOff: "Your nostr signer isn't connected, so you can't post as yourself yet.", msgReconnect: 'Reconnect', msgDmsTitle: 'Messages', diff --git a/src/style.css b/src/style.css index bfebff7..bd9d41a 100644 --- a/src/style.css +++ b/src/style.css @@ -811,6 +811,16 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .quote-card.clickable { cursor: pointer; } .quote-card.clickable:hover { border-color: var(--line-strong); } .quote-loading { flex-direction: row; align-items: center; gap: 8px; } +/* A referenced article or list (nostr:naddr). The cover's box is fixed, so + the row doesn't grow under the reader when the picture lands. */ +.addr-article { padding: 0 0 10px; overflow: hidden; gap: 6px; } +.addr-article > :not(.addr-cover) { margin: 0 10px; } +.addr-cover { width: 100%; aspect-ratio: 2 / 1; object-fit: cover; display: block; background: var(--line); } +.addr-title { font-weight: 650; font-size: 1.05em; line-height: 1.3; } +.addr-summary { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; } +.article-title { font-size: 1.35em; font-weight: 700; line-height: 1.25; margin-bottom: 6px; white-space: normal; } +.article-h1, .article-h2, .article-h3 { font-weight: 650; line-height: 1.3; margin: 10px 0 2px; white-space: normal; } +.article-h1 { font-size: 1.2em; } .article-h2 { font-size: 1.1em; } .article-h3 { font-size: 1em; } /* A poll (NIP-88): each option a row with its share of the vote as a bar behind the label. Only the bar's width changes as votes arrive, so the post never changes height under the reader. */ |
