summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-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.js449
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 };