From 55cb118f25ddd69ffe9cc4bd44a36b1e689ee19e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 27 Sep 2026 19:10:05 -0700 Subject: Links in posts show as the cards their pages describe. A plain link becomes an Open Graph card: picture, title, a line, the site. The registrar fetches the page on the reader's behalf (names/preview.js, GET /preview?url=): only public hosts, at most half a megabyte of HTML, cached a day. The client warms the card with the rest of the post so the row arrives whole, keeps a few hundred cards across sessions, and leaves a page with nothing to say as a bare link. Pictures and YouTube keep their boxes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D --- src/features/messages.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ src/style.css | 15 +++++++++ 2 files changed, 95 insertions(+) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index cd0631a..e004e91 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4240,6 +4240,76 @@ export function messagesFeature(ctx) { mediaWarming.set(url, task); return task; }; + // ---- link previews -------------------------------------------------- + // A plain link in a post becomes the card its page describes (Open Graph: + // title, a line, a picture), fetched through the registrar because a + // browser can't read another site's HTML. Warmed with the rest of a post + // so the row arrives whole; remembered across sessions, a few hundred at + // a time, so a feed you've seen paints its cards at once. + const PREVIEW_API = 'https://names.coinos.io/preview?url='; + const PREVIEW_KEEP = 400; + const previews = new Map(); // url -> { title, description, image, site } | null (nothing there) + const previewWarming = new Map(); + try { + for (const [u, m] of JSON.parse(localStorage.getItem('coinos-link-previews') || '[]')) previews.set(u, m); + } catch {} + let previewFlush = null; + const savePreviews = () => { + if (previewFlush) return; + previewFlush = setTimeout(() => { + previewFlush = null; + const rows = [...previews].filter(([, m]) => m).slice(-PREVIEW_KEEP); + try { localStorage.setItem('coinos-link-previews', JSON.stringify(rows)); } catch {} + }, 1000); + }; + const previewable = (url) => /^https?:\/\//i.test(url) && !youtubeId(url) + && !/\.(png|jpe?g|gif|webp|avif|mp4|webm|mov|m4v|mp3|m4a|ogg|wav|pdf|zip)(\?[^\s]*)?$/i.test(url); + const warmPreview = (url) => { + if (!url || previews.has(url) || typeof fetch === 'undefined') return Promise.resolve(); + if (previewWarming.has(url)) return previewWarming.get(url); + const task = (async () => { + let meta = null; + try { + const r = await fetch(PREVIEW_API + encodeURIComponent(url), { signal: AbortSignal.timeout(READY_MS) }); + const j = r.ok ? await r.json() : null; + if (j && (j.title || j.image)) meta = { title: j.title || '', description: j.description || '', image: j.image || '', site: j.site || '' }; + } catch { return; } // asked again another time: the registrar was away, not the page + previews.set(url, meta); + if (meta) { savePreviews(); if (meta.image) await warmMedia(meta.image); } + })().finally(() => previewWarming.delete(url)); + previewWarming.set(url, task); + return task; + }; + // the first two plain links of a post (a wall of links is a list, not cards) + function notePreviewUrls(content) { + const urls = []; + for (const part of String(content || '').split(NOTE_SPLIT)) { + if (!part || urls.length >= 2) continue; + const md = MD_PARTS.exec(part); + const url = md ? md[3] : (/^https?:\/\//i.test(part) ? part : null); + if (url && !(md && md[1]) && previewable(url) && !PACK_LINK_RE.test(url)) urls.push(url); + } + return urls; + } + function linkCard(url, pv) { + let host = ''; + try { host = new URL(url).hostname.replace(/^www\./, ''); } catch {} + const size = pv.image ? (feedPaint ? feedMedia(pv.image) : mediaReady.get(pv.image)) : null; + return h('a', { + class: 'link-card', href: url, target: '_blank', rel: 'noopener noreferrer', + onClick: (e) => e.stopPropagation(), + }, + pv.image ? h('img', { + class: 'link-thumb', src: pv.image, alt: '', loading: 'lazy', + width: size?.width, height: size?.height, + style: size && size.width && size.height ? 'aspect-ratio:' + size.width + '/' + size.height : undefined, + onError: (e) => { e.target.style.display = 'none'; }, + }) : null, + h('div', { class: 'link-meta' }, + h('div', { class: 'link-title' }, pv.title || host), + pv.description ? h('div', { class: 'link-desc small muted' }, pv.description) : null, + h('div', { class: 'link-site small faint' }, pv.site || host))); + } function noteMediaUrls(content) { const urls = []; for (const part of String(content || '').split(NOTE_SPLIT)) { @@ -4266,6 +4336,7 @@ export function messagesFeature(ctx) { } async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) { const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia), + ...notePreviewUrls(ev.content).map(warmPreview), ...[...emojiTagMap(ev.tags).values()].map(warmMedia)]; for (const part of String(ev.content || '').split(NOTE_SPLIT)) { if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { @@ -4873,6 +4944,15 @@ export function messagesFeature(ctx) { }); } if (youtubeId(url)) return youtubeEmbed(url, youtubeId(url)); + if (!label && previewable(url)) { + // a frozen feed row keeps the card (or the bare link) it first painted + let pv = previews.get(url) || null; + if (feedPaint) { + if (!feedPaint.media.has('pv:' + url)) feedPaint.media.set('pv:' + url, pv); + pv = feedPaint.media.get('pv:' + url); + } + if (pv) return linkCard(url, pv); + } const shown = label || (url.length > 64 ? url.slice(0, 61) + '…' : url); return h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, shown); } diff --git a/src/style.css b/src/style.css index 1e5a1c7..11b9cf0 100644 --- a/src/style.css +++ b/src/style.css @@ -833,6 +833,21 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } padding: 4px 7px; border-radius: 8px; background: rgba(18, 18, 18, .6); text-decoration: none; } .yt-open:hover { background: rgba(18, 18, 18, .85); text-decoration: none; } +/* A link, as the card its page describes: picture on top, title, a line, + the site. The whole card is the link. */ +.link-card { + display: block; margin-top: 6px; border: 1px solid var(--line); border-radius: 10px; + overflow: hidden; color: inherit; text-decoration: none; background: var(--surface2); +} +.link-card:hover { text-decoration: none; border-color: var(--line-strong); } +.link-thumb { display: block; width: 100%; max-height: 220px; object-fit: cover; background: var(--surface2); } +.link-meta { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.link-title, .link-desc { + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; +} +.link-title { font-weight: 600; line-height: 1.3; } +.link-site { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.quote-card .link-thumb { max-height: 140px; } /* the profile notes feed: page-scrolled (no inner scrollbox); on phones it runs edge to edge — the card walls step outside #app's padding */ .notes-feed { padding: 2px 14px; } -- cgit v1.2.3