aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js102
-rw-r--r--src/i18n.js5
-rw-r--r--src/style.css10
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. */