aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--src/features/messages.js68
-rw-r--r--tools/markdown-note-test.js105
2 files changed, 151 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)));
diff --git a/tools/markdown-note-test.js b/tools/markdown-note-test.js
new file mode 100644
index 0000000..d585fb6
--- /dev/null
+++ b/tools/markdown-note-test.js
@@ -0,0 +1,105 @@
+// A kind 1 is specified as plain text — NIP-10 says markup SHOULD NOT be
+// used — but bridges and cross-posters emit markdown anyway, and what we did
+// with it was the worst of the three options available: the URL inside
+// ![cover](…) rendered as a picture while the "![cover](" sat above it as
+// litter, in a real post from a real account.
+//
+// So: images and links are understood, and nothing else is. # opens a hashtag
+// and * turns up in ordinary prose, so honouring headings and bold would
+// mangle normal posts to pretty up the rare bridged one.
+//
+// Run: bun tools/markdown-note-test.js
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+const SK = generateSecretKey();
+const AUTHOR = getPublicKey(SK);
+const IMG = 'https://coinos.io/punks/7.webp';
+const NOEXT = 'https://example.invalid/media/9f2a1c';
+// the shape the real post had: the paren, then a newline, then the URL
+const CASES = [
+ ['md-image', 'here it is\n![cover](' + IMG + ')\nthat was it'],
+ ['md-image-newline', 'wrapped\n![cover](\n' + IMG + ')\ndone'],
+ ['md-image-noext', 'no extension ![cover](' + NOEXT + ')'],
+ ['md-link', 'read it at [my site](https://example.invalid/post/1) today'],
+ ['bare', 'plain old ' + IMG + ' here'],
+ ['not-md', 'a list [one] and (two) and #hashtag and *stars* stay put'],
+];
+const notes = CASES.map(([, content], i) =>
+ finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000) - i * 60, tags: [], content }, SK));
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5298, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5298/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(500);
+ await click('import existing'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ }, [cacheKeyFor(mn + '\n'), AUTHOR, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(1000);
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
+ await sleep(4000);
+
+ const rows = await page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row')].map((r) => {
+ const b = r.querySelector('.note-text');
+ return {
+ text: b ? b.innerText : '',
+ imgs: b ? [...b.querySelectorAll('img')].map((i) => i.getAttribute('src')) : [],
+ links: b ? [...b.querySelectorAll('a')].map((a) => ({ href: a.getAttribute('href'), txt: a.textContent })) : [],
+ };
+ }));
+ check('all six posts are on screen', rows.length === 6, rows.length + ' rows');
+ const by = (i) => rows[i] || { text: '', imgs: [], links: [] };
+
+ // 0: the reported case
+ check('![alt](url) shows the picture', by(0).imgs.includes(IMG), by(0).imgs.join(' '));
+ check('...and leaves no "![cover](" behind', !/!\[|\]\(/.test(by(0).text), JSON.stringify(by(0).text));
+ // 1: with the newline the real post had
+ check('...even with a newline after the paren', by(1).imgs.includes(IMG) && !/!\[|\]\(/.test(by(1).text),
+ JSON.stringify(by(1).text));
+ // 2: markdown knows it's an image even without an extension
+ check('![…] means picture even with no file extension', by(2).imgs.includes(NOEXT), by(2).imgs.join(' '));
+ // 3: a plain markdown link is a link with its own words
+ const l = by(3).links.find((x) => /example\.invalid\/post\/1/.test(x.href || ''));
+ check('[text](url) is a link labelled with its text', !!l && l.txt === 'my site', l ? l.txt : 'no link');
+ check('...and the brackets are gone', !/\[my site\]/.test(by(3).text), JSON.stringify(by(3).text));
+ // 4: bare URLs unchanged
+ check('a bare image URL still just works', by(4).imgs.includes(IMG));
+ // 5: everything that ISN'T markdown is left exactly alone
+ check('brackets, parens, hashtags and stars in prose are untouched',
+ by(5).text.includes('[one]') && by(5).text.includes('(two)')
+ && by(5).text.includes('#hashtag') && by(5).text.includes('*stars*'),
+ JSON.stringify(by(5).text));
+
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ markdown reads as what it means' : '\n❌ failed');
+process.exit(ok ? 0 : 1);