import { html, useState, useEffect, useLayoutEffect, useCallback, useRef, } from './vendor/htm-preact.js'; import { t, getLocale } from './i18n.js'; import { tell } from './ask.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 _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. const url = _trimUrl(m[0]); const tail = m[0].slice(url.length); out.push(html`${url}`); if (tail) out.push(tail); last = m.index + m[0].length; } if (last < text.length) out.push(text.slice(last)); 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` ${imgUrl && html``} ${data.site_name && html`${data.site_name}`} ${data.title && html`${data.title}`} ${data.description && html`${data.description}`} `; } 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`
`; if (!blobUrl) return html`
${'\u{1F5BC}'} ${filename}
`; return html`${filename}`; } // `attachRoot` is the shared directory attachments are written to: the name of // the first writable, available root, decided in group-page.js so Files and Chat // read one answer. Empty means the group has no writable root right now — every // root is read-only, or the one drive that was writable is unplugged — and the // paperclip says so rather than producing a refusal from the node. // // `deviceReady` is "this connection has identified a device to the node", the // one thing chat needs beyond being connected. It arrives as a prop, and that // is the correction: it used to be read off the transport during render // (`transportRef.current.devicePk`), and a ref changing re-renders nothing — so // once a reconnect cleared it the composer stayed disabled for the rest of the // session. Defaulting to `true` fails open: a wiring mistake here must never be // able to leave someone with a dead textbox. function ChatPanel({ transportRef, username, userId, entries, gekRef, onRefreshIndex, onPreview, attachRoot = '', attachDir = '', onActivity, status, deviceReady = true }) { 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); // Landing on the newest message is an *arrival* behaviour: it belongs to // opening the group and to coming back to the Chat tab, and it has to survive // the thumbnails and link-preview cards that keep growing the list for a // second or two afterwards. It ends the moment the reader asks to move, and // after that nothing may move the view for them. const arrivingRef = useRef(true); useEffect(() => { if (status !== 'connected') return; const transport = transportRef.current; if (!transport || !transport.connected) return; if (!loadedRef.current) { loadedRef.current = true; transport.fetchChatHistory({ limit: CHAT_PAGE }) .then(({ messages: msgs, hasMore: more }) => { setHasMore(more); setMessages(msgs); requestAnimationFrame(() => { const l = listRef.current; // Not if the reader already moved: a slow node means this page // lands seconds after the panel opened, and by then they may be // reading somewhere else entirely. if (l && arrivingRef.current) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; } }); }) .catch(() => { loadedRef.current = false; }); } transport.onChat = (msg) => { const id = msg.id || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; // Spread rather than rebuilt field by field: the transport is the one // place that decides how a message is read, and copying a subset of its // result here is how the live path and the history path come to disagree // — which would show up only for messages that cannot be opened. setMessages(prev => [...prev, { ...msg, id, timestamp: msg.timestamp || Date.now() / 1000, }]); if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); }; return () => { transport.onChat = null; }; }, [status]); 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; } if (atBottomRef.current) list.scrollTop = list.scrollHeight; }, [messages, hasMore]); // Hold the arrival at the newest message 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. // // Bounded by `arrivingRef`, not by `atBottomRef` alone. The at-bottom test // has a 40px tolerance and is fed by an event delivered a frame late, so on // its own it let a resize storm hold the reader against the end of the // conversation with no way back up it. Once the reader has asked to move, // this stops observing entirely. useEffect(() => { const list = listRef.current; if (!list || typeof ResizeObserver === 'undefined' || !arrivingRef.current) return; // Declared before `stick` closes over it: a `const` further down would be // in its temporal dead zone here, which is the hook-ordering trap this // codebase has already paid for once. let ro = null; const stick = () => { if (!arrivingRef.current) { if (ro) ro.disconnect(); return; } if (atBottomRef.current) list.scrollTop = list.scrollHeight; }; ro = new ResizeObserver(stick); ro.observe(list); for (const child of list.children) ro.observe(child); stick(); return () => ro.disconnect(); }, [messages]); // The reader taking hold of the scroll ends the arrival, and it has to be // recorded here rather than in `onScroll`: a `scroll` event is delivered at // the next rendering step, so anything that pins in between undoes the // movement before the handler that would have stopped it ever runs. These // fire with the gesture itself. useEffect(() => { const list = listRef.current; if (!list) return; const release = () => { arrivingRef.current = false; }; const events = ['wheel', 'touchmove', 'pointerdown', 'keydown']; for (const name of events) list.addEventListener(name, release, { passive: true }); return () => { for (const name of events) list.removeEventListener(name, release, { passive: true }); }; }, []); // Entering the Chat tab should leave the cursor in the composer, ready to // type — the panel mounts fresh on every tab switch, so a mount effect is // the tab-entry hook. `preventScroll` because the layout effects above are // still settling the panel height and a focus-driven scroll would fight // them; on a phone this also means the keyboard opens without the view // jumping. useEffect(() => { inputRef.current?.focus({ preventScroll: true }); }, []); // 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; // 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. // // Remembered on the element rather than re-derived on every call, because // this runs on `resize` and so can *cause* the event it listens for. // Setting the naive height, reading the overflow back and subtracting it // means the document alternately does and does not overflow the window: a // page scrollbar appears and vanishes with it, `visualViewport` fires // `resize` at every pass, and fit() re-enters itself for the life of the // panel. Measured 2026-09-01 on a page nobody was touching: 240 firings // in two seconds, against 2 for a bare document. Each one re-pinned the // list to the bottom, so every attempt to scroll up was undone inside the // same frame — before the `scroll` event that would have recorded it was // even delivered, which is why the reader could not move and the "jump to // latest" button never appeared. Converged, this writes nothing. const below = el._chatFitBelow || CHAT_BOTTOM_GAP; const target = Math.max(CHAT_MIN_HEIGHT, vh - top - below); if (Math.abs(target - el.getBoundingClientRect().height) > 0.5) { el.style.height = `${target}px`; } const over = document.documentElement.scrollHeight - vh; if (over > 0) { el._chatFitBelow = below + over; el.style.height = `${Math.max(CHAT_MIN_HEIGHT, target - over)}px`; } }; const fitAndPin = () => { fit(); const l = listRef.current; if (l && atBottomRef.current) l.scrollTop = l.scrollHeight; }; // A real viewport change can also mean the page below the panel reflowed, // so the learnt leftover is dropped and measured again. Not on // `visualViewport`: an Android keyboard changes the viewport, not the // layout under the panel, and re-learning there would put the oscillation // back on the one platform that fires that event constantly. const refit = () => { el._chatFitBelow = 0; fitAndPin(); }; fitAndPin(); window.addEventListener('resize', refit); window.addEventListener('orientationchange', refit); window.visualViewport?.addEventListener('resize', fitAndPin); return () => { window.removeEventListener('resize', refit); window.removeEventListener('orientationchange', refit); window.visualViewport?.removeEventListener('resize', fitAndPin); }; }, []); const onScroll = useCallback((e) => { const el = e.target; const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; atBottomRef.current = bottom; // Belt and braces for a scroll no gesture listener saw — a scrollbar // dragged from outside the list, a "find in page" jump, assistive tech. if (!bottom) arrivingRef.current = false; 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)}`, own: true, sender_id: userId || 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, userId, 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. // `attachDir` is the folder the operator chose in Settings; `attachRoot` // is the fallback for a group where they have not chosen one yet. const ack = await transport.uploadFile( file, { root: attachRoot, dir: attachDir || undefined }); 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)}`, own: true, sender_id: userId || username, sender_name: username, payload: structured, timestamp: Date.now() / 1000, thread_id: null, }]); jumpToBottom(); } catch (err) { tell(err.message); } finally { setAttaching(false); } }, [username, userId, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); // Chat is always encrypted, and sealing needs this device to have identified // itself to the node (`device_hello`) — which is also what lets the node // refuse a member claiming somebody else's key. Without it there is nothing // to send with, so the composer says so before anything is typed rather than // producing a refusal the reader cannot act on. // // Only while connected: before that the composer is enabled and `sendMessage` // simply declines, which is what it always did — saying "this device cannot // post" at someone who is merely still connecting names the wrong problem. const cannotSend = status === 'connected' && !deviceReady; // The composer's disabled state has exactly two inputs, and neither of them // was observable from outside the component. A "chat hangs, the textbox is // not clickable" report arrived with a complete console dump that could not // say which of the two it had been, nor when it started. This is what makes // the next one answer that in one line. useEffect(() => { const why = sending ? 'send in flight' : cannotSend ? 'device not identified to the node' : null; console.log('[MeshBay] chat composer:', why ? 'disabled (' + why + ')' : 'enabled'); if (window.MeshBayTrace && window.MeshBayTrace.record) { window.MeshBayTrace.record('chat_composer', { disabled: !!why, why }); } }, [sending, cannotSend]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }, [sendMessage]); return html`
${hasMore && html`
`} ${!hasMore && messages.length > 0 && html`
${t('chat.start_of_history')}
`} ${messages.length === 0 && html`
${(status === 'discovering' || status === 'connecting' || status === 'fetching') ? html`${' '}${t('status.connecting_short')}` : t('chat.empty')}
`} ${messages.map((m, i) => { // By account, and by an explicit flag on our own optimistic echo. // Comparing a display name against a sender id happened to work // while the echo invented `sender_id: username`, and would have // started rendering other people's messages as the reader's own the // moment two members shared a display name. const isOwn = m.own === true || (!!userId && m.sender_id === userId) || (!userId && m.sender_name === 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; // A message the transport could not open is shown as a gap, with // what went wrong. Dropping it would leave a conversation quietly // missing messages, which is worse than a visible hole: nobody can // notice what they were never shown. if (m.unreadable) { return html` ${daySep && html`
${daySep}
`}
${showSender && html`
${displayName}
`}
${t('chat.unreadable_' + m.unreadable) || t('chat.unreadable')} ${formatTime(m.timestamp)}
`; } 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`
${daySep}
`} ${unreadFrom && unreadFrom === m.id && html`
${t('chat.unread')}
`}
${showSender && html`
${displayName}
`} ${m.trust === 'changed' && html` ${/* The one notice §4.8 budgets for. Not shown for a first sight, which every account has exactly once — an alarm that fires on normal events stops being read. */ ''}
${t('chat.key_changed')}
`}
${att ? html`
{ 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`
${'\u{1F3AC}'} ${att.filename}
` : html`
${'\u{1F4CE}'} ${att.filename}
` }
${formatSize(att.size)}
` : html` ${linkify(msgText)} ${msgUrl && html`<${LinkPreview} url=${msgUrl} transportRef=${transportRef} gekRef=${gekRef} />`} `} ${formatTime(m.timestamp)}
`; })}
${!atBottom && messages.length > 0 && html` `}
${attachRoot ? html` ` : html` <${Icon} name="clip" /> `}