import { html, render, useState, useEffect, useLayoutEffect, useCallback, useRef, createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; import * as downloads from './downloads.js'; import * as platform from './platform.js'; // ── Constants ──────────────────────────────────────────────────────────────── // Where the hub is. Empty in a browser — it served this page, so a relative // path cannot be pointed at the wrong place. In the installed app the page // comes from disk and has no origin of its own, so the base is configured. // See platform.js. const HUB = platform.hubBase(); const AUTH_KEY = 'mb_auth'; // Renew an access token with this much life left rather than waiting for it to // fail. Generous against a one-hour token: a film is watched without the hub // hearing a word, and coming back to a tab that has been asleep for an hour // should not cost a round trip before the first click works. const TOKEN_RENEW_MARGIN_S = 600; // How often to look. Cheap — it reads a timestamp out of the token and almost // always does nothing. const TOKEN_CHECK_MS = 60000; const THEME_KEY = 'mb_theme'; const IDB_NAME = 'meshbay'; const IDB_VERSION = 1; const IDB_STORE = 'group_indexes'; // ── IndexedDB cache ───────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function cacheGroupIndex(groupId, groupName, entries) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put({ groupId, groupName, entries, cachedAt: Date.now(), }); await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); db.close(); } catch { /* best-effort */ } } async function getCachedGroupIndex(groupId) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(groupId); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || null; } catch { return null; } } async function getAllCachedIndexes() { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).getAll(); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || []; } catch { return []; } } // ── Auth persistence ───────────────────────────────────────────────────────── // The key that opens a node's keypair bundle, derived once at sign-in. There is // no global identity to keep: identity keys belong to a node and are fetched from // it (transport.js), so nothing of that kind lives here. let _bundleKey = null; // A one-time pairing code the user just typed, consumed by the next connection // attempt. Deliberately not persisted: it is single-use and short-lived. let _pendingJoinCode = null; function _openKeyDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('meshbay_keys', 1); req.onupgradeneeded = () => req.result.createObjectStore('k'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function _storeBundleKey(key) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').put(key, 'bk'); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } async function _loadBundleKey() { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readonly'); const g = tx.objectStore('k').get('bk'); const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); db.close(); return val || null; } catch { return null; } } async function _clearKeyDB() { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').clear(); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } /** * Rough passphrase strength, in bits, and what it is up against. * * This number carries more weight here than in most applications. The encrypted * keypair bundle is protected by PBKDF2-SHA512 (600k) and sits on every node * whose group you join, so the people who host your groups can attack it offline * (finding C4). PBKDF2 is memory-light, which is exactly what GPUs are good at. * * The estimate is deliberately conservative — character classes and length, with * a penalty for repetition and for the handful of patterns everyone tries. It is * a guide, not a guarantee, and it says so in the UI. */ function passwordBits(pw) { if (!pw) return 0; let pool = 0; if (/[a-z]/.test(pw)) pool += 26; if (/[A-Z]/.test(pw)) pool += 26; if (/[0-9]/.test(pw)) pool += 10; if (/[^A-Za-z0-9]/.test(pw)) pool += 32; let bits = pw.length * Math.log2(pool || 1); const unique = new Set(pw).size; if (unique < pw.length / 2) bits *= 0.6; // "aaaaaaaa", "abcabcabc" if (/^[0-9]+$/.test(pw)) bits *= 0.5; // dates, PINs if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3; return Math.round(bits); } const PASSWORD_MIN_BITS = 60; // refuse below this const PASSWORD_MIN_LEN = 12; /** Public X25519 key from our own secret — never read back from the hub. */ async function _pkXFromSk(skPkcs8B64) { const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); const jwk = await crypto.subtle.exportKey('jwk', sk); const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4; return pad ? b64 + '='.repeat(4 - pad) : b64; } function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } } function saveAuth(auth) { if (auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); _bundleKey = null; _clearKeyDB(); } } // ── Session ────────────────────────────────────────────────────────────────── // // The access token lasts an hour and the refresh token thirty days. Nothing was // using the second: `hubFetch` reported a 401 as an error like any other, so an // hour of watching a film — during which the hub hears nothing, because the // video comes over WebRTC — ended with "token expired or invalid" and no way // out but signing out and back in. Reopening the tab the next day did the same, // with a perfectly good refresh token sitting in localStorage beside the stale // access one. // // This lives outside the component because `hubFetch` is a plain function and // has to be able to renew a token mid-request without every caller passing the // machinery down to it. let _auth = loadAuth(); let _onAuthChange = null; // set by App, so the UI follows a background renewal let _refreshing = null; // in flight, shared: see refreshAccessToken function setAuth(auth) { _auth = auth; saveAuth(auth); if (_onAuthChange) _onAuthChange(auth); } /** Seconds until this JWT expires, or null if it says nothing useful. */ function tokenLifeLeft(token) { try { const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); if (!payload.exp) return null; return payload.exp - Math.floor(Date.now() / 1000); } catch { return null; // not a JWT we can read; treat as unknown, never as expired } } /** * Trade the refresh token for a new pair. * * The hub rotates: it revokes the token presented and returns a new one, and a * revoked token presented again revokes the whole family. So the new one must * be stored — the previous code kept only the access token and dropped its * replacement, which burned the refresh token on first use and locked the * account out of renewal on the second. That is why signing out and in was the * only way back. * * Concurrent callers share one request. Two 401s racing would otherwise send * the same refresh token twice, and the second would look exactly like theft. */ async function refreshAccessToken() { if (!_auth || !_auth.refreshToken) return null; if (_refreshing) return _refreshing; _refreshing = (async () => { try { const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: _auth.refreshToken }), }); if (!r.ok) { // Expired, revoked, or the family was torn down. Nothing to salvage: // sign out cleanly rather than leave a session that fails every call. setAuth(null); return null; } const data = await r.json(); setAuth({ ..._auth, token: data.access_token, refreshToken: data.refresh_token || _auth.refreshToken, }); return data.access_token; } catch { return null; // offline: keep the session, the next call can try again } finally { _refreshing = null; } })(); return _refreshing; } /** Renew before it bites, rather than after. */ async function ensureFreshToken() { if (!_auth || !_auth.token) return null; const left = tokenLifeLeft(_auth.token); if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token; return refreshAccessToken(); } // ── Theme ──────────────────────────────────────────────────────────────────── function getInitialTheme() { const stored = localStorage.getItem(THEME_KEY); if (stored === 'dark' || stored === 'light' || stored === 'system') return stored; return 'system'; } function resolveTheme(pref) { if (pref === 'system') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } return pref; } // ── Hub API ────────────────────────────────────────────────────────────────── async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { const headers = {}; if (body) headers['Content-Type'] = 'application/json'; // Prefer the token the session currently holds. Callers read theirs from // React state, which is a render behind a renewal that happened in the // background — and sending the stale one would 401 for no reason. const bearer = token && _auth && _auth.token ? _auth.token : token; if (bearer) headers['Authorization'] = `Bearer ${bearer}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); const r = await platform.apiFetch(HUB + path, opts); if (r.status === 401 && bearer && !_retried) { // The one case worth a second attempt: the access token aged out while // nothing was talking to the hub. Renew once and replay. If the renewal // fails it signs out, and the replay below is skipped. const fresh = await refreshAccessToken(); if (fresh) { return hubFetch(path, { method, body, token: fresh, _retried: true }); } } if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); const detail = Array.isArray(err.detail) ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ') : (err.detail || r.statusText); throw new Error(String(detail)); } return r.json(); } // ── Router ─────────────────────────────────────────────────────────────────── function useRoute() { const [hash, setHash] = useState(window.location.hash.slice(1) || '/'); useEffect(() => { const onHash = () => setHash(window.location.hash.slice(1) || '/'); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); return hash; } function navigate(path) { window.location.hash = path; } // ── Context ────────────────────────────────────────────────────────────────── const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } // ── Icons ──────────────────────────────────────────────────────────────────── // // One stroked set, drawn in currentColor and sized in em, so an icon takes the // weight and colour of the text beside it. The Administration entry was already // an outline shield while the rest of the site was colour emoji — a different // drawing on every operating system, and never the same line weight twice. // // The explorer keeps its emoji on purpose. There the icon says what kind of file // this is, and the colour is doing real work; a wall of identical grey outlines // would be a worse file list. const ICON_PATHS = { menu: ['M4 7h16M4 12h16M4 17h16'], bell: ['M18 9a6 6 0 1 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', 'M10.3 20a2 2 0 0 0 3.4 0'], shield: ['M12 3l7.5 3v5.2c0 4.6-3.1 8.6-7.5 10.3-4.4-1.7-7.5-5.7-7.5-10.3V6z'], globe: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18', 'M3.4 9.2h17.2M3.4 14.8h17.2', 'M12 3c-2.6 2.4-4 5.6-4 9s1.4 6.6 4 9c2.6-2.4 4-5.6 4-9s-1.4-6.6-4-9'], archive: ['M3 7.5h18v3H3z', 'M4.5 10.5V19a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-8.5', 'M10 14h4'], play: ['M8 5.5v13l11-6.5z'], eye: ['M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12', 'M12 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5'], trash: ['M4 7h16', 'M10 11v6M14 11v6', 'M6 7l1 12.5A1.5 1.5 0 0 0 8.5 21h7a1.5 1.5 0 0 0 1.5-1.5L18 7', 'M9.5 7V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v2'], user: ['M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8', 'M4.5 20a7.5 7.5 0 0 1 15 0'], gear: ['M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6', 'M19.2 14.4a1.7 1.7 0 0 0 .3 1.9 2 2 0 1 1-2.8 2.8 1.7 1.7 0 0 0-2.9 1.2 2 2 0 0 1-4 0 1.7 1.7 0 0 0-2.9-1.2 2 2 0 1 1-2.8-2.8 1.7 1.7 0 0 0-1.2-2.9 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.2-2.9 2 2 0 1 1 2.8-2.8 1.7 1.7 0 0 0 2.9-1.2 2 2 0 0 1 4 0 1.7 1.7 0 0 0 2.9 1.2 2 2 0 1 1 2.8 2.8 1.7 1.7 0 0 0 1.2 2.9 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1z'], sun: ['M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6', 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4'], moon: ['M20.8 13.4A8.6 8.6 0 1 1 10.6 3.2a6.9 6.9 0 0 0 10.2 10.2z'], power: ['M12 3.2v8.4', 'M6.9 6.6a7.6 7.6 0 1 0 10.2 0'], lock: ['M5.5 11h13a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1V12a1 1 0 0 1 1-1z', 'M8 11V7.4a4 4 0 0 1 8 0V11'], envelope: ['M4 5.5h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z', 'M3.4 6.6L12 13.4l8.6-6.8'], door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5', 'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'], download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'], upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'], transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5', 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'], search: ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5'], dots: ['M12 5.6h.01', 'M12 12h.01', 'M12 18.4h.01'], checkbox: ['M5.5 4h13a1.5 1.5 0 0 1 1.5 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-13A1.5 1.5 0 0 1 5.5 4z'], home: ['M4 11.2L12 4.5l8 6.7', 'M6.2 9.8V19a1 1 0 0 0 1 1h9.6a1 1 0 0 0 1-1V9.8'], 'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z', 'M12 11.4v5', 'M9.5 13.9h5'], plus: ['M12 5v14', 'M5 12h14'], clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'], pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3', 'M14.5 6.5l3 3'], check: ['M4.5 12.5l5 5 10-11'], chevron: ['M6 9.5l6 6 6-6'], close: ['M6 6l12 12M18 6L6 18'], chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'], folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'], 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', 'M10.3 20a2 2 0 0 0 3.4 0', 'M4 4l16 16'], server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', 'M8 7.5h.01', 'M8 16.5h.01'], cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21', 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6', 'M2 21h.01'], }; // The M of the wordmark is a picture; the rest is text. Resolved from this // module's own URL so the hub's fingerprinted path and the application's // app:// scheme both come out right without either being named here. const BRAND_M = new URL('./meshbay-m.png', import.meta.url).href; function Icon({ name, cls = '' }) { const paths = ICON_PATHS[name]; if (!paths) return null; return html` `; } // ── User Menu ──────────────────────────────────────────────────────────────── function UserMenu({ user, theme, onThemeChange, onLogout }) { const [open, setOpen] = useState(false); const [langOpen, setLangOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('click', close); return () => document.removeEventListener('click', close); }, [open]); const resolved = resolveTheme(theme); return html`
${open && html`
${user.username[0].toUpperCase()}
${user.username}
${user.role || 'user'}
${langOpen && LOCALES.map(l => html` `)}
`}
`; } // ── Transfers widget ───────────────────────────────────────────────────────── function TransferWidget() { const [items, setItems] = useState(() => transfers.list()); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => transfers.subscribe(setItems), []); useEffect(() => { if (!open) return; const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('click', close); return () => document.removeEventListener('click', close); }, [open]); const running = items.filter(i => i.status === 'running'); if (!items.length) return null; return html`
${open && html`
${t('transfers.title')}
${items.map(it => html`
<${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> ${it.canOpen ? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}` : html`${it.name}`} ${it.status === 'running' && html` `}
${it.status === 'running' ? html`
${formatSize(it.done)}${it.total ? ' / ' + formatSize(it.total) : ''} ${formatSpeed(it.speed)}
` : html`
${it.status === 'done' ? t('transfers.done') : it.status === 'cancelled' ? t('transfers.cancelled') : it.error || t('transfers.failed')} ${it.canOpen && html` `}
`}
`)}
`}
`; } // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, hubUnset }) { return html` `; } // ── Sidebar ────────────────────────────────────────────────────────────────── function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) { const isStaff = role === 'moderator' || role === 'admin'; return html` `; } // ── Login Page ─────────────────────────────────────────────────────────────── /** * Which hub, asked once on a desktop build. * * There is no default. A client that picks its own hub is a client that can be * pointed at one, and the address is the whole of what the application trusts * the hub for — its API, and nothing else: the interface comes from the package. * * Changing it restarts the window, because the address reaches the interface as * a process argument. Reloading in place would leave it talking to the old hub * with nothing on screen to say so. */ function FirstRunPage({ onSet }) { const [url, setUrl] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const submit = async (e) => { e.preventDefault(); setError(''); setBusy(true); try { await window.meshbay.setHubBase(url.trim()); onSet(); } catch (err) { setError(platform.bridgeMessage(err)); setBusy(false); } }; return html`

${t('firstrun.title')}

${t('firstrun.hint')}

setUrl(e.target.value)} />
${error && html`
${error}
`}

${t('firstrun.note')}

`; } function LoginPage() { const auth = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!username || !password) return; setError(''); setLoading(true); try { await auth.login(username, password); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('login.title')}

setUsername(e.target.value)} autocomplete="username" required autofocus /> setPassword(e.target.value)} autocomplete="current-password" required /> ${error && html`
${error}
`}
`; } // ── Register Page ──────────────────────────────────────────────────────────── function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (password !== confirm) { setError(t('register.err_mismatch')); return; } if (password.length < PASSWORD_MIN_LEN) { setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; } // The floor can only live here: with the password split (T1) the hub never // sees the password, so it cannot enforce anything about it. if (passwordBits(password) < PASSWORD_MIN_BITS) { setError(t('register.err_too_weak')); return; } setError(''); setLoading(true); try { if (window.MeshBayKeys) { await window.MeshBayKeys.registerUser(username, email, password); } else { await hubFetch('/v1/users/register', { method: 'POST', body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, }); } setSuccess(true); } catch (err) { setError(err.message); } finally { setLoading(false); } }; if (success) { return html`

${t('register.success_title')}

${t('register.success_msg')}

${t('register.go_login')}
`; } return html`

${t('register.title')}

setUsername(e.target.value)} autocomplete="username" required /> setEmail(e.target.value)} autocomplete="email" required /> setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> ${password && html`

${t('register.strength', { bits: passwordBits(password) })}

`} setConfirm(e.target.value)} autocomplete="new-password" required /> ${error && html`
${error}
`}
`; } // ── Home Page ──────────────────────────────────────────────────────────────── function NotificationFeed({ notifications, onMarkRead, onPurge }) { if (!notifications.length) return null; return html`

${t('notif.title')}

${notifications.map(n => html`
{ // Reading it is the point of clicking it: it goes, here and in the // count, rather than sitting there greyed out. onMarkRead(n.id); if (n.link) navigate(n.link); }}> ${n.kind} ${n.title} ${new Date(n.created_at).toLocaleDateString()}
`)}
`; } function HomePage({ groups, notifications, onMarkRead, onPurge }) { const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { if (platform.isNative && !setupDismissed) { return html`<${SetupWelcome} onDismiss=${() => setSetupDismissed(true)} />`; } return html`

${t('home.welcome')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} />

${t('home.no_groups')} ${' '}${t('home.browse_prefix')}${t('home.browse_link')}${t('home.browse_suffix')}

`; } return html`

${t('home.my_groups')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} />
${groups.map(g => html`

${g.name}

${g.description && html`

${g.description}

`} ${g.visibility} ${' '} ${g.join_policy} ${g.is_admin && html`${' '}admin`}
`)}
`; } // ── Explore Page ───────────────────────────────────────────────────────────── function ExplorePage({ token, myGroupIds }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [joining, setJoining] = useState(null); const doSearch = useCallback((q) => { setLoading(true); const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; hubFetch(url, { token }) .then(data => setGroups(data.groups || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [token]); useEffect(() => { doSearch(''); }, [token]); const onSearch = useCallback((e) => { const q = e.target.value; setSearch(q); doSearch(q); }, [doSearch]); const joinGroup = useCallback(async (gid) => { setJoining(gid); try { await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); navigate(`/group/${gid}`); setTimeout(() => window.location.reload(), 100); } catch (err) { if (err.message.includes('Already a member')) { navigate(`/group/${gid}`); } else { alert(err.message); } } finally { setJoining(null); } }, [token]); const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); return html`

${t('explore.title')}

${platform.node.available && html` ${t('explore.create_group')} `}
${loading ? html`

${t('explore.loading')}

` : groups.length === 0 ? html`

${t('explore.empty')}

` : html`
${groups.map(g => html`

${g.name}

${g.description && html`

${g.description}

`}
${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `} ${' '} ${isMember(g.id) ? html`${t('explore.member')}` : g.join_policy === 'open' && html` ` }
`)}
` }
`; } // ── First-run welcome (Electron-only, shown once on empty home) ───────────── function SetupWelcome({ onDismiss }) { return html`

${t('setup.welcome_title')}

${t('setup.welcome_message')}

${t('setup.create_group')}
`; } // ── Create Group Page ──────────────────────────────────────────────────────── function CreateGroupPage(props) { if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`; return html`<${CreateGroupFormSimple} ...${props} />`; } function CreateGroupFormSimple({ token, onCreated }) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [joinPolicy, setJoinPolicy] = useState('invite'); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!name.trim()) return; setLoading(true); setError(''); try { const body = { name: name.trim(), join_policy: joinPolicy, visibility: joinPolicy === 'open' ? 'public' : 'private' }; if (description.trim()) body.description = description.trim().slice(0, 512); const data = await hubFetch('/v1/groups', { method: 'POST', token, body, }); if (onCreated) onCreated(); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('create_group.title')}

${t('create_group.hint')}

${error && html`
${error}
`}
setName(e.target.value)} required autofocus />
` : html` ${group && group.description && html`

${group.description}

`} ${group && group.is_admin && html` `} `}
${group && html` `}
${error && html`
${error}${' '}
`} ${needsDevice && html`

${t('device.add_title')}

${t('device.add_hint')}

${!deviceCode && html` `} ${deviceCode && html`

${t('device.add_show')}

${deviceCode}

`}
`} ${needsCode && html`

${t('group.join_code_title')}

${t('group.join_code_hint')}

setCodeInput(e.target.value)} required />
`} ${/* Not gated on the connection any more. Leaving a group, deleting it and seeing who is in it are hub-side, and moving them into this tab would otherwise have made them unreachable exactly when a node is down — which is when someone is most likely to want them. Files and chat still need the node and say so. */ group && html`
${tab === 'files' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${' '}${t('status.connecting_short')}

`} ${tab === 'files' && status === 'offline' && html`

${t('group.offline_title')} ${t('group.offline_hint')}

`} ${tab === 'files' && status === 'connected' && html`
${mayUpload && html` `} ${canCreateDir && html` `}
${selecting && html`
${actionItems}
`}
${selecting && html``} ${subdirs.map(d => { const full = currentPath ? currentPath + '/' + d : d; const inside = entriesUnder(entries, full); const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); return html` selecting ? toggle(dirKey(d)) : setCurrentPath(full)}> ${selecting && html` `} `; })} ${sorted.map(e => html` selecting && toggle(e.id)}> ${selecting && html` `} `)} ${sorted.length === 0 && subdirs.length === 0 && html` `}
toggleSort('name')}> ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('size')}> ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('type')}> ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('date')}> ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
ev.stopPropagation()} onChange=${() => toggle(dirKey(d))} /> ${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'} ${d}${unavailableHere.includes(d) ? html` ${t('group.root_unavailable')} ` : ''} ${inside.length ? formatSize(bytes) : ''}
ev.stopPropagation()} onChange=${() => toggle(e.id)} /> ${FILE_ICONS[e.type] || FILE_ICONS.other} ${!selecting && canPreview(e) ? html` { if (e.type === 'video') setVideoEntry(e); else setPreviewEntry(e); }}>${e.name}` : e.name } ${formatSize(e.size)} ${e.type} ${formatDate(e.added_at)}
${filter ? t('group.empty_filter') : t('group.empty_dir')}
`} ${tab === 'chat' && status === 'connected' && html` <${ChatPanel} transportRef=${transportRef} username=${username} entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} mayUpload=${mayUpload} onActivity=${touchActivity} onPreview=${(entry) => { if (entry.type === 'video') setVideoEntry(entry); else setPreviewEntry(entry); }} /> `} ${tab === 'chat' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${' '}${t('status.connecting_short')}

`} ${tab === 'chat' && status === 'offline' && html`

${t('group.offline_title')} ${t('group.offline_hint')}

`} ${tab === 'settings' && html` <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token} transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} operatorPaired=${operatorPaired} connected=${status === 'connected'} memberUpload=${memberUpload} onMemberUpload=${(allowed) => setMemberUpload(allowed)} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> `} `} ${status === 'offline' && !group && html`

${t('group.offline_title')} ${' '}${t('group.offline_hint')}

`} ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`

${' '}${t('status.connecting_short')}

`} ${previewEntry && html` <${FilePreview} entry=${previewEntry} transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setPreviewEntry(null)} onDownload=${() => downloadFile(previewEntry)} /> `} ${videoEntry && html` <${VideoPlayer} entry=${videoEntry} transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setVideoEntry(null)} onDownload=${() => downloadFile(videoEntry)} /> `}
`; } // ── File Preview (text, images) ───────────────────────────────────────── const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); const [error, setError] = useState(''); const [downloading, setDownloading] = useState(false); const blobUrlRef = useRef(null); useEffect(() => { let cancelled = false; const load = async () => { const transport = transportRef.current; if (!transport || !transport.connected) { setError(t('video.err_transport')); setPhase('error'); return; } try { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let downloaded = 0; const chunks = await pipelinedDownload( transport, gekRef.current, entry.id, totalChunks, (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, ); if (cancelled) return; if (/\.pdf$/i.test(entry.name)) { // Decrypted here and shown from a blob: URL — the bytes never leave // the page, and the browser's own viewer renders them. const blob = new Blob(chunks, { type: 'application/pdf' }); blobUrlRef.current = URL.createObjectURL(blob); setContent({ type: 'pdf' }); } else if (entry.name.match(IMAGE_EXTS)) { const ext = entry.name.split('.').pop().toLowerCase(); const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; const blob = new Blob(chunks, { type: mime }); blobUrlRef.current = URL.createObjectURL(blob); setContent({ type: 'image' }); } else { const decoder = new TextDecoder('utf-8', { fatal: false }); const text = chunks.map(c => decoder.decode(c, { stream: true })).join(''); setContent({ type: 'text', text: text.slice(0, 500000) }); } setPhase('ready'); } catch (err) { if (!cancelled) { setError(err.message); setPhase('error'); } } }; load(); return () => { cancelled = true; }; }, [entry]); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); useEffect(() => { return () => { if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; } }; }, []); return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); }}>
${entry.name} (${formatSize(entry.size)}) ${onDownload && html` `}
${phase === 'loading' && html`
${t('video.loading', { name: entry.name })}
`} ${phase === 'ready' && content?.type === 'pdf' && html`

${t('preview.pdf_fallback')}

`} ${phase === 'ready' && content?.type === 'image' && html`
${entry.name}
`} ${phase === 'ready' && content?.type === 'text' && html`
${content.text}
`} ${phase === 'error' && html`
${error}
`}
`; } function _b64ToU8(b64) { const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr; } // ── Members Panel ──────────────────────────────────────────────────────── /** * Everything about the group that is not its files or its chat. * * Was "Members", which was a list with three unrelated forms stacked on top of * it and the group's own controls somewhere else entirely — leaving or deleting * a group lived in the header, beside its title. One tab now, in sections, with * the roster last: it is the part that grows without limit, and burying the * controls under two hundred names is how a tab stops being usable. */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, memberUpload, onMemberUpload, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(''); // Node loopback state (Electron-only) const [nodeDetected, setNodeDetected] = useState(false); const [nodeRoots, setNodeRoots] = useState([]); const [nodeGroupName, setNodeGroupName] = useState(''); const [nodeBusy, setNodeBusy] = useState(false); const [nodeMsg, setNodeMsg] = useState(''); const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; try { const detect = await platform.node.detect(); if (!detect.detected) { setNodeDetected(false); return; } setNodeDetected(true); const data = await platform.node.call('GET', '/api/groups'); const groups = data.groups || []; const ng = groups.find(g => g.id === groupId); if (ng) { setNodeRoots(ng.roots || []); setNodeGroupName(ng.name || ''); } } catch { setNodeDetected(false); } }, [groupId]); useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); const [inviteCode, setInviteCode] = useState(null); const [pairCode, setPairCode] = useState(''); const [pairStatus, setPairStatus] = useState(''); const [pairing, setPairing] = useState(false); // Your own devices on this node. Not a members feature — it is beside them // because this is where a live connection to the node exists. const [devices, setDevices] = useState([]); const [approveCode, setApproveCode] = useState(''); const [deviceMsg, setDeviceMsg] = useState(''); // Pairing lives here rather than in Settings because this is where a live // connection to the node exists — and it is offered only when the node itself // says this account is its operator (is_node_admin comes from the authenticated // handshake_ack, not from the hub). const loadDevices = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; try { const out = await transport.listDevices(); setDevices(out.devices); } catch { /* a node that has none says so by listing none */ } }, [transportRef]); useEffect(() => { loadDevices(); }, [loadDevices]); const approveDevice = useCallback(async (e) => { e.preventDefault(); const code = approveCode.trim(); if (!code) return; setDeviceMsg(''); try { await transportRef.current.approveDevice(userId, code); setApproveCode(''); setDeviceMsg(t('device.approved')); await loadDevices(); } catch (err) { setDeviceMsg(err.message); } }, [approveCode, userId, transportRef, loadDevices]); const revokeDevice = useCallback(async (device) => { if (!confirm(t('device.revoke_confirm'))) return; setDeviceMsg(''); try { await transportRef.current.revokeDevice( userId, device.pk_ed25519, device.pk_x25519 || ''); await loadDevices(); } catch (err) { setDeviceMsg(err.message); } }, [userId, transportRef, loadDevices]); const doPair = useCallback(async (e) => { e.preventDefault(); const code = pairCode.trim(); if (!code) return; setPairing(true); setPairStatus(''); try { const transport = transportRef && transportRef.current; if (!transport || !transport.connected) throw new Error('Not connected to the node'); await transport.pairOperator(userId, code); setPairCode(''); setPairStatus('paired'); // The node has pinned this key as an operator key; the form has nothing // left to do. It used to stay put through a refresh, because what governed // it was the account, which pairing does not change. if (onPaired) onPaired(); } catch (err) { setPairStatus(err.message); } finally { setPairing(false); } }, [pairCode, transportRef, userId]); const [uploadBusy, setUploadBusy] = useState(false); const [uploadMsg, setUploadMsg] = useState(''); /** * Close or open uploading for everyone who is not the operator. * * Signed, like removing a member: the node refuses an unsigned instruction, * so this is a request to the node rather than a decision taken here. The * button does not move until the node has said it did it. */ const setUploads = useCallback(async (allowed) => { const transport = transportRef && transportRef.current; setUploadMsg(''); setUploadBusy(true); try { if (!transport || !transport.connected) { throw new Error('Not connected to the node'); } const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.setMemberUpload(allowed, signFn); if (onMemberUpload) onMemberUpload(allowed); } catch (err) { setUploadMsg(err.message); } finally { setUploadBusy(false); } }, [transportRef, onMemberUpload]); const [removing, setRemoving] = useState(''); /** * Take someone out of this group: both halves, in the order that fails safe. * * The node first, because that is the half that stops the group key being * wrapped for them; if the hub removal then fails, they are a member on paper * with no key. The other order would leave them able to reach a node that * still serves them. */ const removeMember = useCallback(async (member) => { const transport = transportRef && transportRef.current; setError(''); setRemoving(member.user_id); try { if (platform.node.available) { try { await platform.node.call('POST', `/api/members/${member.user_id}/revoke?group_id=${groupId}`); } catch { /* best effort — node may not host this group */ } try { await platform.node.call('POST', `/api/members/${member.user_id}/unpin`); } catch { /* best effort */ } } else if (transport && transport.connected && operatorPaired) { const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.revokeMember(member.user_id, signFn); } await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { method: 'DELETE', token, }); loadMembers(); } catch (err) { setError(err.message); } finally { setRemoving(''); } }, [groupId, token, transportRef, operatorPaired]); const loadMembers = useCallback(() => { setLoading(true); hubFetch(`/v1/groups/${groupId}/members`, { token }) .then(data => { setMembers(data.members || []); setAdminId(data.admin_id || ''); }) .catch(() => {}) .finally(() => setLoading(false)); }, [groupId, token]); useEffect(() => { loadMembers(); }, [loadMembers]); const isAdmin = group && group.is_admin; const doInvite = useCallback(async (e) => { e.preventDefault(); if (!inviteUser.trim()) return; setInviting(true); setError(''); setInviteCode(null); try { const transport = transportRef && transportRef.current; const username = inviteUser.trim(); if (!transport || !transport.connected) { throw new Error('Not connected to the node — it must be online to invite'); } // The hub is asked for the account id, and nothing else. It is no longer // asked for the invitee's public key: the node wraps the group key itself, // for a key the invitee proves possession of when they connect (H3). A hub // that answered with the wrong account here would produce an invite whose // code it never learns — the code goes to a human, out of band. const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); // Signed with the identity this node pinned for us — the only one it // will accept, and the only one we hold here. const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; const result = await transport.createInvite( account.user_id, groupId, username, signFn); // Membership on the hub is what lets them reach the node at all; the code // is what gets them the key. await hubFetch(`/v1/groups/${groupId}/members/${username}`, { method: 'POST', token, body: {}, }); setInviteCode({ username, code: result.code, expires: result.expires_at }); setInviteUser(''); loadMembers(); } catch (err) { setError(err.message); } finally { setInviting(false); } }, [groupId, token, inviteUser, loadMembers, transportRef]); if (loading) return html`

${t('explore.loading')}

`; const isOwner = Boolean(isAdmin); return html`
${error && html`
${error}
`} ${/* Inviting needs the node: it is the node that wraps the group key and issues the code, not the hub. Public groups admit anyone — no invite. */ isAdmin && group?.join_policy !== 'open' && html`

${t('members.invite_title')}

${!connected && html`

${t('group.offline_title')}

`} ${connected && !operatorPaired && html`

${isNodeAdmin ? t('members.invite_needs_pairing') : t('members.invite_ask_operator')}

`} ${connected && operatorPaired && html`
${inviteCode && html`

${t('members.invite_code_ready', { user: inviteCode.username })}

${inviteCode.code}

${t('members.invite_code_hint')}

`}
setInviteUser(e.target.value)} required />
`}
`} ${isNodeAdmin && !operatorPaired && connected && html`

${t('members.pair_title')}

${t('members.pair_hint')}

${pairStatus && html`

${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}

`}
setPairCode(e.target.value)} required />
`} ${/* Operator only, and only with a live connection: the node is what holds and enforces this, so there is nothing to show or change without one. */ isNodeAdmin && connected && html`

${t('members.uploads_title')}

${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}

${t('members.uploads_hint')}

${uploadMsg && html`

${uploadMsg}

`}
`} ${connected && html`

${t('device.mine_title')}

${t('device.mine_hint')}

${deviceMsg && html`

${deviceMsg}

`} ${devices.length === 0 ? html`

${t('device.mine_empty')}

` : html` `}

${t('device.approve_hint')}

setApproveCode(e.target.value)} />
`} ${/* Roots management (Electron-only, when node is local) */ nodeDetected && nodeRoots.length > 0 && html`

${t('settings_node.roots')}

${nodeMsg && html`

${nodeMsg}

`}
${nodeRoots.map(r => html`
<${Icon} name="folder" /> ${r.name} ${r.upload && html` ${t('node.upload_root')}`} ${!r.available && html` ${t('node.unavailable')}`}
${nodeRoots.length > 1 && !r.upload && html` `}
`)}
`} ${/* Upload toggle via loopback when MNP not connected */ nodeDetected && !connected && html`

${t('members.uploads_title')}

${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}

${t('members.uploads_hint')}

`} ${/* Delete/leave — node detach first (reversible), then hub delete (irreversible). */ html`

${isOwner ? t('group.delete_group') : t('group.leave')}

${isOwner ? t('members.danger_delete_hint') : t('members.danger_leave_hint')} ${isOwner ? html` ` : html` `}
`}

${t('group.tab_members')} (${members.length})

${members.map(m => html` `)}
${t('admin.col_username')} ${t('members.group_role')}
${m.username} ${m.user_id === adminId ? html`${t('members.owner')}` : html`${t('members.member')}` } ${isAdmin && m.user_id !== adminId && html` `}
${isAdmin && members.length > 1 && html`

${t('members.remove_hint')}

`}
`; } // ── Chat Panel ────────────────────────────────────────────────────────── /** * 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`${url}`); 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`
`; if (!blobUrl) return html`
${'\u{1F5BC}'} ${filename}
`; return html`${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`
${hasMore && html`
`} ${!hasMore && messages.length > 0 && html`
${t('chat.start_of_history')}
`} ${messages.length === 0 && html`
${t('chat.empty')}
`} ${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`
${daySep}
`} ${unreadFrom && unreadFrom === m.id && html`
${t('chat.unread')}
`}
${showSender && html`
${displayName}
`}
${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(parsed && typeof parsed.text === 'string' ? parsed.text : m.payload)} `} ${formatTime(m.timestamp)}
`; })}
${!atBottom && messages.length > 0 && html` `}
${mayUpload && html` `}