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/style.css | |
| 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/style.css')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 52 |
1 files changed, 52 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 975f47c..61a5e3b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -581,6 +581,58 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .chat-text { white-space: pre-wrap; } +/* Link unfurl card. Sits on its own line inside the bubble and carries its + own surface colours so it stays readable inside an own-message bubble too. */ +.chat-link-preview { + flex: 0 0 100%; + display: flex; + gap: 10px; + margin-top: 4px; + padding: 8px; + max-width: 380px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 8px; + background: var(--bg-base); + color: var(--text); + text-decoration: none; + overflow: hidden; +} +.chat-link-preview:hover { background: var(--bg-surface); } +.clp-img { + width: 72px; + height: 72px; + flex-shrink: 0; + object-fit: cover; + border-radius: 6px; + background: var(--bg-surface); +} +.clp-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.clp-site { + font-size: 0.7em; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.clp-title { + font-weight: 600; + font-size: 0.9em; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.clp-desc { + font-size: 0.82em; + color: var(--text-secondary); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + .chat-time { font-size: 0.65em; color: var(--text-dim); |