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 _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``;
}
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
onPreview, mayUpload = true, onActivity, status }) {
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)}`;
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,
}]);
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)}`,
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`
${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')}