diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/chat-app.js | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
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 | 449 |
1 files changed, 449 insertions, 0 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 new file mode 100644 index 0000000..0babf51 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -0,0 +1,449 @@ +import { + html, useState, useEffect, useLayoutEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t, getLocale } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; + +/** + * Message text with its links made clickable. + * + * Only http and https, and built as elements rather than markup: a message is + * something another member wrote, so it must never become HTML. `javascript:` + * and `data:` are not matched at all, and the anchors carry noopener so the new + * tab cannot reach back into this one. + */ +const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; + +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); } + out.push(html`<a href=${url} target="_blank" rel="noopener noreferrer" + class="chat-link">${url}</a>`); + if (tail) out.push(tail); + last = m.index + m[0].length; + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + +function formatTime(ts) { + const d = new Date(ts * 1000); + const now = new Date(); + // getLocale() rather than the browser default: the user may have picked a + // language here that differs from the one their OS reports. + const time = d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); + if (d.toDateString() === now.toDateString()) return time; + return d.toLocaleDateString(getLocale(), { month: 'short', day: 'numeric' }) + ' ' + time; +} + +function _parsePayload(raw) { + if (typeof raw === 'string' && raw.startsWith('{')) { + try { return JSON.parse(raw); } catch { /* not JSON */ } + } + return null; +} + +// How much history a group opens with, and how much each "older" click adds. +const CHAT_PAGE = 100; +const CHAT_OLDER_PAGE = 50; + +// Breathing room under the panel, and the floor below which shrinking it stops +// helping — past that the page may scroll after all, which beats a chat two +// lines tall. +const CHAT_BOTTOM_GAP = 16; +const CHAT_MIN_HEIGHT = 240; + +function _sameDay(a, b) { + const da = new Date(a * 1000), db = new Date(b * 1000); + return da.getFullYear() === db.getFullYear() + && da.getMonth() === db.getMonth() + && da.getDate() === db.getDate(); +} + +/** "Today" / "Yesterday" / a written date, in the reader's language. */ +function _dayLabel(ts) { + const d = new Date(ts * 1000); + const now = new Date(); + if (_sameDay(ts, now.getTime() / 1000)) return t('chat.today'); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (_sameDay(ts, yesterday.getTime() / 1000)) return t('chat.yesterday'); + return d.toLocaleDateString(getLocale(), { + weekday: 'long', day: 'numeric', month: 'long', + year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', + }); +} + +function ChatImage({ filename, entries, transportRef, gekRef }) { + const [blobUrl, setBlobUrl] = useState(null); + const [loading, setLoading] = useState(true); + const loadedRef = useRef(false); + + useEffect(() => { + if (loadedRef.current) return; + let cancelled = false; + const load = async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { setLoading(true); return; } + const entry = entries.find(e => e.name === filename); + if (!entry) { setLoading(true); return; } + try { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks); + if (cancelled) return; + const ext = filename.split('.').pop().toLowerCase(); + const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg'; + const blob = new Blob(chunks, { type: mime }); + loadedRef.current = true; + setBlobUrl(URL.createObjectURL(blob)); + } catch { /* ignore */ } + if (!cancelled) setLoading(false); + }; + load(); + return () => { cancelled = true; }; + }, [filename, entries.length]); + + useEffect(() => { + return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); }; + }, [blobUrl]); + + if (loading) return html`<div class="chat-att-thumb"><span class="spinner"></span></div>`; + if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`; + return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; +} + +function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, + onPreview, mayUpload = true, onActivity }) { + const [messages, setMessages] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [atBottom, setAtBottom] = useState(true); + const [unreadFrom, setUnreadFrom] = useState(null); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [attaching, setAttaching] = useState(false); + const listRef = useRef(null); + const panelRef = useRef(null); + const inputRef = useRef(null); + const loadedRef = useRef(false); + // Set just before older messages are prepended; read once, after the DOM has + // them but before the browser paints. + const anchorRef = useRef(null); + const atBottomRef = useRef(true); + + useEffect(() => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + if (!loadedRef.current) { + loadedRef.current = true; + // The newest page. This used to be fetchChatHistory(0, 200), which paged + // forwards from the very first message ever sent, so a busy group opened + // on its oldest screen and the recent conversation was unreachable. + transport.fetchChatHistory({ limit: CHAT_PAGE }) + .then(({ messages: msgs, hasMore: more }) => { + setMessages(msgs); + setHasMore(more); + }) + .catch(() => {}); + } + + transport.onChat = (msg) => { + // A live message has no row id until it is re-read from the node, so it + // gets a local one. Keys have to be stable and unique or prepending a + // page makes Preact reuse the wrong bubbles. Computed once and reused + // below: the unread marker points at a message by id, so generating a + // second one there would point it at nothing. + const id = msg.id + || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; + setMessages(prev => [...prev, { + id, + sender_id: msg.sender_id, + sender_name: msg.sender_name || '', + payload: msg.payload, + timestamp: msg.timestamp || Date.now() / 1000, + thread_id: msg.thread_id, + }]); + // Somebody wrote while you were reading further up: mark where you were + // rather than yanking the view down. + if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); + }; + + return () => { transport.onChat = null; }; + }, [transportRef.current?.connected]); + + const loadOlder = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected || loadingOlder || !messages.length) return; + setLoadingOlder(true); + const list = listRef.current; + // Keeping the reading position means restoring the distance from the + // *bottom*, not scrollTop: everything above the viewport just grew. + anchorRef.current = list ? list.scrollHeight - list.scrollTop : null; + try { + const { messages: older, hasMore: more } = + await transport.fetchChatHistory({ before: messages[0].id, limit: CHAT_OLDER_PAGE }); + setMessages(prev => [...older, ...prev]); + setHasMore(more); + } catch { + anchorRef.current = null; + } finally { + setLoadingOlder(false); + } + }, [messages, loadingOlder]); + + useLayoutEffect(() => { + const list = listRef.current; + if (!list) return; + if (anchorRef.current !== null) { + list.scrollTop = list.scrollHeight - anchorRef.current; + anchorRef.current = null; + return; + } + // Only follow the conversation if the reader was already at the bottom. + // Scrolling unconditionally fought every attempt to read back through it. + // + // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no + // height, so aligning it to the bottom of the viewport leaves the list's + // own padding below it and the bar stops just short of the end. + if (atBottomRef.current) list.scrollTop = list.scrollHeight; + }, [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 + // up off screen with the whole page scrolling to reach it. + // + // Measured instead, from the panel's own position in the document, so the + // header can be any height. `visualViewport` rather than innerHeight where it + // exists: on Android the on-screen keyboard shrinks the visual viewport + // without changing innerHeight, and the composer would go back under it. + useLayoutEffect(() => { + const el = panelRef.current; + if (!el) return; + const fit = () => { + const vh = window.visualViewport?.height || window.innerHeight; + // Document-relative, so a page that happens to be scrolled does not skew + // the result — the answer must be the same either way. + const top = el.getBoundingClientRect().top + window.scrollY; + el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`; + // What sits *below* the panel is not knowable from up here — today it is + // `.main`'s 24px bottom padding against this 16px gap, which left the + // document 8px taller than the window and a scrollbar on the chat tab at + // every window size. Rather than encode 24 somewhere and have the next + // change to the page break it again, the leftover is measured and taken + // off. Self-correcting: anything added under the panel is absorbed the + // same way. + const over = document.documentElement.scrollHeight - vh; + if (over > 0) { + el.style.height = + `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`; + } + }; + fit(); + window.addEventListener('resize', fit); + window.addEventListener('orientationchange', fit); + window.visualViewport?.addEventListener('resize', fit); + return () => { + window.removeEventListener('resize', fit); + window.removeEventListener('orientationchange', fit); + window.visualViewport?.removeEventListener('resize', fit); + }; + }, []); + + const onScroll = useCallback((e) => { + const el = e.target; + const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + atBottomRef.current = bottom; + setAtBottom(bottom); + if (bottom) setUnreadFrom(null); + }, []); + + const jumpToBottom = useCallback(() => { + atBottomRef.current = true; + setAtBottom(true); + setUnreadFrom(null); + const list = listRef.current; + if (list) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' }); + }, []); + + const sendMessage = useCallback(async () => { + const text = input.trim(); + if (!text) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + setSending(true); + setInput(''); + try { + await transport.sendChat(text, 0, null, username); + setMessages(prev => [...prev, { + id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, + sender_id: username, + sender_name: username, + payload: text, + timestamp: Date.now() / 1000, + thread_id: null, + }]); + jumpToBottom(); + if (onActivity) onActivity(); + } catch { + setInput(text); + } finally { + setSending(false); + setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }); + } + }, [input, username, jumpToBottom]); + + const attachFile = useCallback(async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setAttaching(true); + try { + // Two people sending IMG_1234.jpg both succeed; the node picks a free name + // and the message has to point at the one it chose. + const ack = await transport.uploadFile(file); + const storedAs = (ack && ack.stored_as) || file.name; + await new Promise(r => setTimeout(r, 2500)); + if (onRefreshIndex) await onRefreshIndex(); + const ext = file.name.split('.').pop().toLowerCase(); + const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image' + : ['mp4','webm','mkv','mov','avi'].includes(ext) ? 'video' : 'file'; + const structured = JSON.stringify({ + text: '', attachment: { filename: storedAs, size: file.size, type: ftype }, + }); + await transport.sendChat(structured, 0, null, username); + setMessages(prev => [...prev, { + id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, + sender_id: username, sender_name: username, + payload: structured, timestamp: Date.now() / 1000, thread_id: null, + }]); + jumpToBottom(); + } catch (err) { + alert(err.message); + } finally { + setAttaching(false); + } + }, [username, onRefreshIndex, jumpToBottom]); + + const onKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }, [sendMessage]); + + return html` + <div class="chat-panel" ref=${panelRef}> + <div class="chat-messages" ref=${listRef} onScroll=${onScroll}> + ${hasMore && html` + <div class="chat-older-row"> + <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}> + ${loadingOlder + ? html`<span class="spinner"></span>` + : html`<${Icon} name="chevron" cls="chat-older-icon" />`} + ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })} + </button> + </div> + `} + ${!hasMore && messages.length > 0 && html` + <div class="chat-start">${t('chat.start_of_history')}</div> + `} + ${messages.length === 0 && html` + <div class="chat-empty">${t('chat.empty')}</div> + `} + ${messages.map((m, i) => { + const isOwn = m.sender_name === username || m.sender_id === username; + const displayName = m.sender_name || '?'; + const prev = messages[i - 1]; + const showSender = !isOwn && (i === 0 || + (prev.sender_name || prev.sender_id) !== (m.sender_name || m.sender_id)); + // A conversation read over several days is unreadable without them. + const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) + ? _dayLabel(m.timestamp) : null; + const parsed = _parsePayload(m.payload); + const att = parsed && parsed.attachment; + return html` + ${daySep && html` + <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div> + `} + ${unreadFrom && unreadFrom === m.id && html` + <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div> + `} + <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''} + ${showSender || daySep ? '' : 'chat-msg-tight'}"> + ${showSender && html` + <div class="chat-sender">${displayName}</div> + `} + <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}"> + ${att ? html` + <div class="chat-attachment" style="cursor:pointer" onClick=${() => { + if (!onPreview) return; + const entry = entries.find(e => e.name === att.filename); + if (entry) onPreview(entry); + }}> + ${att.type === 'image' + ? html`<${ChatImage} filename=${att.filename} entries=${entries} + transportRef=${transportRef} gekRef=${gekRef} />` + : att.type === 'video' + ? html`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>` + : html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>` + } + <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-time">${formatTime(m.timestamp)}</span> + </div> + </div> + `; + })} + </div> + ${!atBottom && messages.length > 0 && html` + <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}> + <${Icon} name="chevron" cls="chat-jump-icon" /> + ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')} + </button> + `} + <div class="chat-input-row"> + ${mayUpload && html` + <label class="chat-attach" title="${t('chat.attach')}"> + ${attaching ? html`<span class="spinner"></span>` + : html`<${Icon} name="clip" />`} + <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} /> + </label> + `} + <textarea class="chat-input" rows="1" ref=${inputRef} + placeholder="${t('chat.placeholder')}" + value=${input} + onInput=${e => setInput(e.target.value)} + onKeyDown=${onKeyDown} + disabled=${sending} /> + <button class="chat-send" onClick=${sendMessage} + disabled=${sending || !input.trim()}> + ${t('chat.send')} + </button> + </div> + </div> + `; +} + +// ── Video Player (MSE streaming) ──────────────────────────────────────── + + +export { ChatPanel }; |