aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js114
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css52
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js27
3 files changed, 186 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>
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);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 9f85ee1..1a146ce 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -847,6 +847,20 @@ class MeshBayTransport {
}
/**
+ * Unfurl a URL pasted in chat. The node fetches it (the browser cannot —
+ * CSP and CORS — and would leak every reader's IP), parses an OpenGraph
+ * card, and caches any image in its thumb store; `image_thumb_hash` then
+ * rides the normal file_req path like a poster. `ok: false` means "no
+ * preview" (blocked, unreachable, not HTML) — the caller just shows the
+ * bare link. Keyed by url: a message with several links fires one each.
+ */
+ async fetchLinkPreview(url) {
+ const msg = await this._sendAndWait({ type: 'link_preview_req', v: '0.6', url });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
* One season's own overview/air_date/poster (docs/mediacenter.md §5.4's
* per-season view) — a show's own tmdb_meta is one static field that does
* not necessarily describe every season alike, found live: a 3-season
@@ -1854,6 +1868,9 @@ class MeshBayTransport {
? `chunk:${obj.file_id}:${obj.chunk_index}`
: obj.type === 'ping' ? `ping:${obj.token}`
: obj.type === 'media_meta_req' ? `media_meta:${obj.file_id}`
+ // One chat message can carry several links, each unfurled on its
+ // own; matching by arrival order would swap two cards.
+ : obj.type === 'link_preview_req' ? `link_preview:${obj.url}`
// Same reordering hazard as media_meta_req: an album grid fires
// one music_meta_req per visible tile, several at a time.
: obj.type === 'music_meta_req' ? `music_meta:${obj.file_id}`
@@ -2171,6 +2188,16 @@ class MeshBayTransport {
return;
}
+ // Same reasoning as media_meta_resp: keyed by url, and "nobody's waiting"
+ // must not fall through.
+ if (msg.type === 'link_preview_resp') {
+ const key = `link_preview:${msg.url}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ return;
+ }
+
// Same reasoning as media_meta_resp: keyed, not arrival-order, and
// "nobody's waiting any more" must not fall through either.
if (msg.type === 'music_meta_resp') {