aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-13 18:24:28 -0700
committerAdam Soltys <[email protected]>2026-09-13 18:24:28 -0700
commitb6e5faf417a8d3fb72a9e38458800db555742457 (patch)
tree10c7693562cdde9ea4fee31656731c778722e56f /src
parentb11ae2e1811bd5dc6965d127414d8f4f03f248cd (diff)
downloadcoinosv3-b6e5faf417a8d3fb72a9e38458800db555742457.tar.gz
coinosv3-b6e5faf417a8d3fb72a9e38458800db555742457.zip
Markdown in a post reads as what it means.
A kind 1 is specified as plain text — NIP-10 says markup SHOULD NOT be used — but bridges and cross-posters emit it anyway, and of the three things we could do with it we were doing the worst: the URL inside ![cover](…) rendered as a picture while the "![cover](" sat above it as litter. That is a real post from a real account, not a hypothetical. Images and links, and nothing else. ![alt](url) is a picture, [text](url) is a link that reads as its text. Headings, bold and italics are deliberately NOT honoured: # opens a hashtag and * turns up in ordinary prose, so supporting those would mangle normal posts to tidy up the rare bridged one. One thing markdown gives us that guessing never could: ![…] is the author stating outright that the URL is an image, so a picture on a CDN path with no file extension now renders as a picture instead of a bare link. The URL rendering is one function now rather than a branch inside the splitter, since a markdown link and a bare link deserve the same answer about what they point at. tools/markdown-note-test.js covers both shapes the real post came in (including the newline after the paren), the no-extension case, and — the one that matters most — that brackets, parens, hashtags and stars in ordinary prose are left exactly as written. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js68
1 files changed, 46 insertions, 22 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 69b348b..e913db4 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2880,7 +2880,18 @@ export function messagesFeature(ctx) {
// Note content, safely: text stays text nodes — relay content must never
// reach innerHTML. URLs become links (image URLs inline), npub mentions a
// clickable @name, other nostr: refs a dim stub.
- const NOTE_SPLIT = /(https?:\/\/[^\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)/gi;
+ // Markdown, narrowly. A kind 1 is specified as plain text — NIP-10 says
+ // markup SHOULD NOT be used — but bridges and cross-posters emit it anyway,
+ // and what we did with it was the worst of the three options: the URL
+ // inside ![cover](…) rendered as a picture while the ![cover]( was left
+ // sitting above it as litter. So the whole construct is one token now.
+ //
+ // Images and links ONLY. Not headings, not bold, not italics: # opens a
+ // hashtag and * turns up in ordinary prose, so honouring those would
+ // mangle normal posts to pretty up the rare bridged one.
+ const MD_LINK = '!?\\[[^\\]\\n]{0,300}\\]\\(\\s*<?https?:\\/\\/[^\\s>)]+>?[^)\\n]{0,300}\\)';
+ const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+)', 'gi');
+ const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
// in: the long one, the short one, and a Shorts link.
@@ -2978,31 +2989,44 @@ export function messagesFeature(ctx) {
...noteBody(ev.content, depth + 1)));
}
+ // 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
+ // is, which beats guessing from the filename.
+ function urlNode(url, { label = null, isImage = false } = {}) {
+ if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
+ // metadata-only preload: the poster frame paints, nothing streams
+ // until the viewer presses play
+ return h('video', { src: url, class: 'note-video', controls: true,
+ preload: 'metadata', playsinline: true,
+ onError: (e) => { e.target.style.display = 'none'; } });
+ }
+ if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) {
+ // tap it to see it properly — a 320px-tall crop of someone's
+ // photograph is a thumbnail, not the picture they posted
+ return h('img', {
+ src: url, class: 'note-img clickable', loading: 'lazy', alt: label || '',
+ onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(url); },
+ // a picture we were TOLD was a picture and which won't load leaves
+ // nothing behind — the alt text is already in the sentence above it
+ onError: (e) => { e.target.style.display = 'none'; },
+ });
+ }
+ if (youtubeId(url)) return youtubeEmbed(url, youtubeId(url));
+ const shown = label || (url.length > 64 ? url.slice(0, 61) + '…' : url);
+ return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, shown);
+ }
+
function noteBody(text, depth = 0) {
const out = [];
for (const part of String(text || '').split(NOTE_SPLIT)) {
if (!part) continue;
- if (/^https?:\/\//i.test(part)) {
- if (/\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(part)) {
- // tap it to see it properly — a 320px-tall crop of someone's
- // photograph is a thumbnail, not the picture they posted
- out.push(h('img', {
- src: part, class: 'note-img clickable', loading: 'lazy',
- onClick: (e) => { e.stopPropagation(); ctx.openImage && ctx.openImage(part); },
- onError: (e) => { e.target.style.display = 'none'; },
- }));
- } else if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(part)) {
- // metadata-only preload: the poster frame paints, nothing streams
- // until the viewer presses play
- out.push(h('video', { src: part, class: 'note-video', controls: true,
- preload: 'metadata', playsinline: true,
- onError: (e) => { e.target.style.display = 'none'; } }));
- } else if (youtubeId(part)) {
- out.push(youtubeEmbed(part, youtubeId(part)));
- } else {
- out.push(h('a', { href: part, target: '_blank', rel: 'noopener noreferrer' },
- part.length > 64 ? part.slice(0, 61) + '…' : part));
- }
+ const md = MD_PARTS.exec(part);
+ if (md) {
+ const [, bang, label, url] = md;
+ out.push(urlNode(url, { label: label || null, isImage: !!bang }));
+ } else if (/^https?:\/\//i.test(part)) {
+ out.push(urlNode(part));
} else if (/^nostr:(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.slice(6));
if (ref && ref.type === 'pubkey') out.push(h('a', { href: '#', onClick: (e) => { e.preventDefault(); openProfile(ref.pk); } }, '@' + displayName(ref.pk)));