import { html, render, useState, useEffect, useCallback, useRef, createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; // ── Constants ──────────────────────────────────────────────────────────────── const HUB = ''; const AUTH_KEY = 'mb_auth'; 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(); } } // ── 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 } = {}) { const headers = {}; if (body) headers['Content-Type'] = 'application/json'; if (token) headers['Authorization'] = `Bearer ${token}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); const r = await fetch(HUB + path, opts); 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'], 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'], 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'], }; 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.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')}
`}
`)}
`}
`; } // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) { return html` `; } // ── Sidebar ────────────────────────────────────────────────────────────────── function Sidebar({ groups, route, menuOpen, role }) { const isStaff = role === 'moderator' || role === 'admin'; return html` `; } // ── Login Page ─────────────────────────────────────────────────────────────── 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 /> 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 }) { if (groups.length === 0) { 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')}

${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` ` }
`)}
` }
`; } // ── Create Group Page ──────────────────────────────────────────────────────── function CreateGroupPage({ token, onCreated }) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [visibility, setVisibility] = useState('private'); 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(), visibility, join_policy: joinPolicy }; 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` `} `}
${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`${' '}`} ${statusLabel} ${group && group.is_admin && html` `}
${error && html`
${error}
`} ${needsCode && html`

${t('group.join_code_title')}

${t('group.join_code_hint')}

setCodeInput(e.target.value)} required />
`} ${status === 'connected' && html`
${tab === 'files' && html`
setFilter(e.target.value)} /> ${selecting && html`
${actionsOpen && 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))} /> \u{1F4C1} ${d}/ ${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} onPreview=${(entry) => { if (entry.type === 'video') setVideoEntry(entry); else setPreviewEntry(entry); }} /> `} ${tab === 'chat' && status !== 'connected' && html`

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

`} ${tab === 'members' && html` <${MembersPanel} groupId=${groupId} group=${group} token=${token} transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} operatorPaired=${operatorPaired} onPaired=${() => setOperatorPaired(true)} /> `} `} ${status === 'offline' && html`

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

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

${statusLabel}

`} ${previewEntry && html` <${FilePreview} entry=${previewEntry} transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setPreviewEntry(null)} /> `} ${videoEntry && html` <${VideoPlayer} entry=${videoEntry} transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setVideoEntry(null)} /> `}
`; } // ── 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 }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); const [error, setError] = useState(''); 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)})
${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 ──────────────────────────────────────────────────────── function MembersPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, onPaired }) { 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(''); const [inviteCode, setInviteCode] = useState(null); const [pairCode, setPairCode] = useState(''); const [pairStatus, setPairStatus] = useState(''); const [pairing, setPairing] = useState(false); // 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 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 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')}

`; return html`
${isAdmin && !operatorPaired && html`

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

`} ${isAdmin && operatorPaired && html`

${t('members.invite_title')}

${error && html`

${error}

`} ${inviteCode && html`

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

${inviteCode.code}

${t('members.invite_code_hint')}

`}
setInviteUser(e.target.value)} required />
`} ${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')}` }
${isNodeAdmin && !operatorPaired && html`

${t('members.pair_title')}

${t('members.pair_hint')}

${pairStatus && html`

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

`}
setPairCode(e.target.value)} required />
`}
`; } // ── Chat Panel ────────────────────────────────────────────────────────── function formatTime(ts) { const d = new Date(ts * 1000); const now = new Date(); const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); if (d.toDateString() === now.toDateString()) return time; return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; } function _parsePayload(raw) { if (typeof raw === 'string' && raw.startsWith('{')) { try { return JSON.parse(raw); } catch { /* not JSON */ } } return null; } 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 }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const [attaching, setAttaching] = useState(false); const listRef = useRef(null); const bottomRef = useRef(null); const loadedRef = useRef(false); useEffect(() => { const transport = transportRef.current; if (!transport || !transport.connected) return; if (!loadedRef.current) { loadedRef.current = true; transport.fetchChatHistory(0, 200) .then(msgs => setMessages(msgs)) .catch(() => {}); } transport.onChat = (msg) => { setMessages(prev => [...prev, { sender_id: msg.sender_id, sender_name: msg.sender_name || '', payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, thread_id: msg.thread_id, }]); }; return () => { transport.onChat = null; }; }, [transportRef.current?.connected]); useEffect(() => { if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages.length]); 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, { sender_id: username, sender_name: username, payload: text, timestamp: Date.now() / 1000, thread_id: null, }]); } catch { setInput(text); } finally { setSending(false); } }, [input, username]); 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, { sender_id: username, sender_name: username, payload: structured, timestamp: Date.now() / 1000, thread_id: null, }]); } catch (err) { alert(err.message); } finally { setAttaching(false); } }, [username, onRefreshIndex]); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }, [sendMessage]); return html`
${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 showSender = !isOwn && (i === 0 || (messages[i - 1].sender_name || messages[i - 1].sender_id) !== (m.sender_name || m.sender_id)); const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; return html`
${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` ${m.payload} `} ${formatTime(m.timestamp)}
`; })}