diff options
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> |