diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-28 03:43:19 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-28 03:43:19 +0200 |
| commit | ce4e10c4b8bd9c66c375c3a5d5c18d8552655775 (patch) | |
| tree | d72429e99d5a4eb8e6d59d9d41a004aae4cb70e7 /packages/meshbay-hub/src/meshbay_hub/static/chat-app.js | |
| parent | e1f1b65cfac031096e4bae24ccf102ca0dbb86d9 (diff) | |
| download | meshbay-ce4e10c4b8bd9c66c375c3a5d5c18d8552655775.tar.gz | |
feat(chat): link previews for pasted URLs
Paste an http(s) link in a group's chat and it unfurls into an OpenGraph
card — title, description, site name, and image — the way WhatsApp/Signal/
Slack do it.
The fetch is the node's, never the browser's or the hub's. The browser
cannot: a strict img-src/connect-src and CORS block it, and a direct fetch
would leak every reader's IP to the linked host on each render. The hub must
not touch group content (draft-v6 §2.5). The node already fetches third-party
metadata for the Videos and Music apps, over the same authorised path.
Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the
node replies `link_preview_resp` with the card fields (or `ok: false`), and
any OG image is stored under its blake3 in the existing media_cache thumb
store — the client then fetches it via the normal file_req path, exactly like
a poster. Nothing durable is added: the card text lives in a bounded in-memory
TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device
caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type"
and the client shows the bare link).
Because the URL is chosen by a *member* and triggers an outbound request from
the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only,
no credentials, and every resolved address must be globally routable — no
loopback, private, link-local, multicast or reserved range, cloud-metadata
included. Redirects are followed by hand so each hop is re-checked. Residual,
documented in the module: DNS rebinding between the check and connect, closed
properly by pinning the checked IP — a follow-up.
Also fixes a long-standing chat annoyance the preview cards made worse:
opening the Chat tab landed a screen or two above the newest message because
the scroll-to-bottom ran before attachment thumbnails and (now) preview cards
had loaded and grown the content. A ResizeObserver keeps the view pinned to
the bottom through late content growth, and does nothing once the reader
scrolls up.
Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl.
redirect re-validation and image downscaling) and test_link_preview_request.py
(reply shape, the media_cache image round-trip, the result cache).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/chat-app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/chat-app.js | 114 |
1 files changed, 107 insertions, 7 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 0babf51..3d518b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -15,15 +15,20 @@ import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; */ const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; +function _trimUrl(url) { + let tail = ''; + while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } + return url; +} + function linkify(text) { const out = []; let last = 0; for (const m of String(text).matchAll(URL_RE)) { if (m.index > last) out.push(text.slice(last, m.index)); // Trailing punctuation is almost never part of the address. - let url = m[0]; - let tail = ''; - while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } + const url = _trimUrl(m[0]); + const tail = m[0].slice(url.length); out.push(html`<a href=${url} target="_blank" rel="noopener noreferrer" class="chat-link">${url}</a>`); if (tail) out.push(tail); @@ -33,6 +38,79 @@ function linkify(text) { return out; } +/** The first http(s) URL in a message, address only, or null. */ +function firstUrl(text) { + const m = String(text).match(URL_RE); + return m ? _trimUrl(m[0]) : null; +} + +// Unfurled cards and their images, per session. The node keeps nothing +// durable (draft-v6 §2.7) and re-answers on request; this stops the same +// message re-asking on every scroll/rerender. `_previewCache` values are the +// node's response object, or the string 'pending' / 'failed'. +const _previewCache = new Map(); +const _previewImgCache = new Map(); + +function LinkPreview({ url, transportRef, gekRef }) { + const [data, setData] = useState(() => { + const c = _previewCache.get(url); + return c && typeof c === 'object' ? c : null; + }); + const [imgUrl, setImgUrl] = useState(() => null); + + useEffect(() => { + let cancelled = false; + const cached = _previewCache.get(url); + if (cached === 'failed') return; + if (cached && typeof cached === 'object') { setData(cached); return; } + if (cached === 'pending') return; + + const transport = transportRef.current; + if (!transport || !transport.connected) return; + _previewCache.set(url, 'pending'); + transport.fetchLinkPreview(url) + .then(resp => { + const value = resp && resp.ok ? resp : 'failed'; + _previewCache.set(url, value); + if (!cancelled && typeof value === 'object') setData(value); + }) + .catch(() => { _previewCache.set(url, 'failed'); }); + return () => { cancelled = true; }; + }, [url]); + + const thumbHash = data && data.image_thumb_hash; + useEffect(() => { + if (!thumbHash) return; + const cached = _previewImgCache.get(thumbHash); + if (cached) { setImgUrl(cached); return; } + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1); + if (cancelled) return; + const blobUrl = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' })); + _previewImgCache.set(thumbHash, blobUrl); + setImgUrl(blobUrl); + } catch { /* card renders without the image */ } + })(); + return () => { cancelled = true; }; + }, [thumbHash]); + + if (!data || !data.ok) return null; + return html` + <a class="chat-link-preview" href=${url} target="_blank" rel="noopener noreferrer"> + ${imgUrl && html`<img class="clp-img" src=${imgUrl} alt="" />`} + <span class="clp-body"> + ${data.site_name && html`<span class="clp-site">${data.site_name}</span>`} + ${data.title && html`<span class="clp-title">${data.title}</span>`} + ${data.description && html`<span class="clp-desc">${data.description}</span>`} + </span> + </a> + `; +} + function formatTime(ts) { const d = new Date(ts * 1000); const now = new Date(); @@ -217,6 +295,27 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, if (atBottomRef.current) list.scrollTop = list.scrollHeight; }, [messages]); + // Keep the view pinned to the newest message while the reader is at the + // bottom, through everything that grows the content *after* the initial + // paint: attachment thumbnails and link-preview cards fetched over the + // network, late layout, and the panel resizing itself with `fit()` below. + // Without this, opening the Chat tab reliably lands a screen or two above + // the last message — the layout effect above ran when the list was still + // short. Does nothing once the reader scrolls up (`atBottomRef` is false), + // so it never fights reading back or the "load older" anchor. + useEffect(() => { + const list = listRef.current; + if (!list || typeof ResizeObserver === 'undefined') return; + const stick = () => { + if (atBottomRef.current) list.scrollTop = list.scrollHeight; + }; + const ro = new ResizeObserver(stick); + ro.observe(list); + for (const child of list.children) ro.observe(child); + stick(); + return () => ro.disconnect(); + }, [messages]); + // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a // phone the group header — title, description, edit link, delete button, tabs // — is closer to 430px, so the panel ran past the fold and the composer ended @@ -374,6 +473,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, ? _dayLabel(m.timestamp) : null; const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; + const msgText = parsed && typeof parsed.text === 'string' ? parsed.text : m.payload; + const msgUrl = att ? null : firstUrl(msgText); return html` ${daySep && html` <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div> @@ -403,10 +504,9 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, <div class="chat-att-size">${formatSize(att.size)}</div> </div> ` : html` - <span class="chat-text"> - ${linkify(parsed && typeof parsed.text === 'string' - ? parsed.text : m.payload)} - </span> + <span class="chat-text">${linkify(msgText)}</span> + ${msgUrl && html`<${LinkPreview} url=${msgUrl} + transportRef=${transportRef} gekRef=${gekRef} />`} `} <span class="chat-time">${formatTime(m.timestamp)}</span> </div> |