From 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 23 Aug 2026 15:15:35 +0200 Subject: feat(hub): split the group UI into a pluggable "applications" architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 3539 +------------------- 1 file changed, 16 insertions(+), 3523 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 36df0cd..d18d9a4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -7,124 +7,23 @@ import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; import * as downloads from './downloads.js'; import * as platform from './platform.js'; +import { Icon } from './icon.js'; +import { FILE_ICONS, formatSize } from './file-utils.js'; +import { + HUB, navigate, session, getCachedGroupIndex, getAllCachedIndexes, + _storeBundleKey, _loadBundleKey, _clearKeyDB, + loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, + refreshAccessToken, +} from './hub-client.js'; +import { GroupPage } from './group-page.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. * @@ -166,112 +65,6 @@ async function _pkXFromSk(skPkcs8B64) { 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() { @@ -287,38 +80,6 @@ function resolveTheme(pref) { 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() { @@ -331,103 +92,16 @@ function useRoute() { 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 }) { @@ -1298,7 +972,7 @@ function CreateGroupWizard({ token, username, onCreated }) { const pairResult = await platform.node.call('POST', '/api/operator/pair'); if (pairResult && pairResult.code) { await platform.node.setPairingCode(pairResult.code); - _pendingJoinCode = pairResult.code; + session.pendingJoinCode = pairResult.code; } update('done'); @@ -1471,3187 +1145,6 @@ function CreateGroupWizard({ token, username, onCreated }) { `; } -// ── Helpers ────────────────────────────────────────────────────────────────── - -const FILE_ICONS = { - video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', - document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', -}; - -function formatSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; -} - -function formatDate(ts) { - return new Date(ts * 1000).toLocaleDateString(undefined, { - year: 'numeric', month: 'short', day: 'numeric', - }); -} - -// ── Group Page ────────────────────────────────────────────────────────────── - -const PREVIEWABLE_TEXT = - /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; - -function canPreview(e) { - return ['image', 'video', 'document'].includes(e.type) - || PREVIEWABLE_TEXT.test(e.name); -} - -const CHUNK_SIZE = 1024 * 1024; -// Seconds of already-watched video kept in the SourceBuffer, and the queue depth -// past which we start making room before being forced to. -const BUFFER_BEHIND_S = 60; -// How far past the playhead we are willing to pull. The browser caps a video -// SourceBuffer at a few hundred megabytes and refuses the append that goes -// past, so "as fast as the network allows" is not a strategy for a film: the -// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a -// ten-megabit second fills the ceiling in the first minute. Buffering by time -// rather than by bytes keeps a two-hour film and a two-minute clip alike. -const BUFFER_AHEAD_S = 90; -// While we deliberately hold credit back, the node must still hear from us: its -// own stall timeout is two minutes, and a paused film is not a gone viewer. -const CREDIT_KEEPALIVE_MS = 20000; -// Segments allowed in flight while there is room to put them. This is a window, -// topped up as segments land, and not a debt released in one go: accumulating a -// credit per append and handing the lot over when the buffer finally had room -// sent 6 MB in a burst, overshot the target by a minute of film, and then said -// nothing for the next forty-six seconds. Measured in Chrome against real -// fragmented MP4. A stream that arrives in gulps has no margin for a network -// that hesitates, and looks like a hang while it is quiet. -const STREAM_WINDOW = 8; -// Dragging the scrubber fires `seeking` continuously, and every seek we act on -// kills an ffmpeg and spawns another. Only where the finger stops is worth a -// restart. -const SEEK_DEBOUNCE_MS = 350; -// A position is remembered per file, in this browser. Below the first threshold -// there is nothing to resume; above the second the film is finished and -// offering to resume thirty seconds before the credits is a nuisance. -const RESUME_MIN_S = 30; -const RESUME_MAX_FRACTION = 0.97; -const QUEUE_HIGH_WATER = 12; -const PIPELINE_WINDOW = 8; - -/** - * Open somewhere to write, honouring the user's download setting. - * - * Returns a target ({writable, name}), null for "no stream available — collect - * it and hand the browser a blob", or false for "the person dismissed the - * dialog", which is not an error and must not start a transfer. - */ -async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, - swSize = size) { - // On a desktop build this is the whole answer, and it comes first. - // - // The two browser paths below are both unavailable there — `showDirectoryPicker` - // does not exist, and Chromium refuses a service worker on a custom scheme — - // so without this the chain fell all the way through to its floor, which - // collects the file in the page and hands the browser a blob. A gigabyte of - // film meant a gigabyte of RAM, and a Save As dialog at the *end*. - if (platform.capabilities.nativeSave) { - try { - const native = await platform.nativeSave( - filename, { auto: downloads.getMode() === 'auto' }); - // Null means the person dismissed the dialog, which is not an error and - // must not start a transfer. - return native || false; - } catch (err) { - console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err)); - return false; - } - } - - try { - const target = await downloads.openTarget(filename); - if (target) return target; - } catch (err) { - console.warn('[MeshBay] download folder unusable:', err.message); - } - - // No granted folder. A service worker can still hand the browser a stream to - // write, which is how this works at all in Firefox: the alternative there is - // to collect gigabytes in a tab. It goes to the browser's own download - // folder, without a dialog, which is what "save automatically" meant. - if (downloads.getMode() === 'auto') { - const streamed = await downloads.openStreamedDownload(filename, swSize); - if (streamed) return streamed; - // Nothing to stream to: small enough for memory, and no dialog. - if (size < downloads.BLOB_LIMIT) return null; - } - - if (!window.showSaveFilePicker) return null; - try { - const handle = await window.showSaveFilePicker({ - suggestedName: filename, ...pickerOpts, - }); - return { writable: await handle.createWritable(), name: handle.name || filename }; - } catch (err) { - if (err.name === 'AbortError') return false; - throw err; - } -} - -/** The download of last resort, for browsers with no way to stream to disk. */ -function _saveBlob(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, - writable, signal) { - const results = writable ? null : new Array(totalChunks); - let nextSend = 0, nextRecv = 0; - const inflight = new Array(totalChunks); - - const fire = () => { - while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { - inflight[nextSend] = transport.fetchChunk(fileId, nextSend); - nextSend++; - } - }; - - fire(); - while (nextRecv < totalChunks) { - if (signal && signal.aborted) { - const err = new Error('Cancelled'); - err.name = 'AbortError'; - throw err; - } - const chunkMsg = await inflight[nextRecv]; - let plaintext; - if (gekKey && chunkMsg.ct) { - plaintext = await window.MeshBayCrypto.decryptChunkBin( - gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); - } else if (gekKey && chunkMsg.ct_b64) { - plaintext = await window.MeshBayCrypto.decryptChunk( - gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64); - } else { - plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64); - } - if (writable) { - await writable.write(plaintext); - } else { - results[nextRecv] = plaintext; - } - nextRecv++; - fire(); - if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks); - } - return results; -} - -function GroupPage({ groupId, group, token, username, userId, userPrefs, - onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) { - const [status, setStatus] = useState('idle'); - const [entries, setEntries] = useState([]); - - const [error, setError] = useState(''); - const [selecting, setSelecting] = useState(false); - const [selected, setSelected] = useState(() => new Set()); - const [editingDesc, setEditingDesc] = useState(false); - const [descDraft, setDescDraft] = useState(''); - const [savingDesc, setSavingDesc] = useState(false); - const [sortKey, setSortKey] = useState('name'); - const [sortAsc, setSortAsc] = useState(true); - const [filter, setFilter] = useState(''); - const [currentPath, setCurrentPath] = useState(''); - const [videoEntry, setVideoEntry] = useState(null); - const [previewEntry, setPreviewEntry] = useState(null); - const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat'; - const [tab, setTab] = useState(defaultTab); - useEffect(() => { setTab(defaultTab); }, [groupId]); - // A directory from the group just left rarely exists in the one just - // entered (e.g. "outputs" in one group, absent in another) — Files would - // otherwise show that stale path and list nothing. - useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); - const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted)); - - const _lastTouch = useRef(0); - const touchActivity = useCallback(() => { - const now = Date.now(); - if (now - _lastTouch.current < 60_000) return; - _lastTouch.current = now; - const ts = new Date().toISOString(); - if (onGroupUpdated) onGroupUpdated(groupId, { last_activity_at: ts }); - hubFetch(`/v1/groups/${groupId}/activity`, { method: 'POST', token }).catch(() => {}); - }, [groupId, token, onGroupUpdated]); - - const toggleGroupMute = useCallback(async () => { - const next = !groupMuted; - setGroupMuted(next); - try { - await hubFetch(`/v1/groups/${groupId}/mute`, { - method: 'POST', token, body: { muted: next }, - }); - if (onGroupUpdated) onGroupUpdated(groupId, { muted: next }); - } catch (err) { - setGroupMuted(!next); - } - }, [groupMuted, groupId, token, onGroupUpdated]); - - // Directories are not index entries, so a new empty one needs a nudge - // to appear in the breadcrumb listing. - const [nodeDirs, setNodeDirs] = useState([]); - // The group's roots and whether each is readable. A root whose drive is - // unplugged keeps its files listed — they are frozen, not deleted — so this is - // the only thing that lets the UI say which of the two it is. - const [nodeRoots, setNodeRoots] = useState([]); - - const [isNodeAdmin, setIsNodeAdmin] = useState(false); - // Whether ordinary members may upload here. The node decides and enforces it; - // this only says whether to offer the controls. Defaults to true so a node - // that predates the setting behaves as it always did. - const [memberUpload, setMemberUpload] = useState(true); - // Paired ≠ operator account. `is_node_admin` says the hub account owning this - // node is the one connecting; this says the node pinned *this browser's* key - // as an operator key. Only the second one lets you sign an invite, and only - // the second one should make the pairing form go away. - const [operatorPaired, setOperatorPaired] = useState(false); - const [needsCode, setNeedsCode] = useState(false); - // This browser holds a key the node does not know, for an account it does. - // Not the operator's problem: a device already paired here can admit it. - const [needsDevice, setNeedsDevice] = useState(false); - const [deviceCode, setDeviceCode] = useState(''); - const [codeInput, setCodeInput] = useState(''); - const [retryKey, setRetryKey] = useState(0); - const transportRef = useRef(null); - const gekRef = useRef(null); - // One refresh per mount: if a fresh token still says we are not a member, we - // really are not, and retrying forever would hide that. - const refreshedRef = useRef(false); - - const submitJoinCode = useCallback((e) => { - e.preventDefault(); - const code = codeInput.trim(); - if (!code) return; - _pendingJoinCode = code; - setCodeInput(''); - setNeedsCode(false); - setError(''); - setRetryKey(k => k + 1); - }, [codeInput]); - - // One place that takes an index from the node and puts it everywhere it has to - // go. Deleting a file used to refresh the table and leave the cache alone, so - // the search page went on offering a file that no longer existed until the - // group was reconnected. - const applyIndex = useCallback((indexMsg) => { - const fresh = indexMsg.entries || []; - setEntries(fresh); - if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); - if (indexMsg.roots) setNodeRoots(indexMsg.roots); - cacheGroupIndex(groupId, group ? group.name : groupId, fresh); - }, [groupId, group]); - - useEffect(() => { - let cancelled = false; - - // The cache is written here and read only by the search page. It used to - // seed this list too, which put a stale index on screen and then raced the - // live one: IndexedDB is async, so a fast node could be overwritten by the - // cache landing afterwards. Files shows what the node says, or says it - // cannot reach the node. - - const connect = async () => { - setStatus('discovering'); - setError(''); - gekRef.current = null; - if (!_bundleKey) _bundleKey = await _loadBundleKey(); - try { - const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); - if (cancelled) return; - if (!nodesData.nodes || nodesData.nodes.length === 0) { - setStatus('offline'); - if (onPresence) onPresence(groupId, 'offline'); - return; - } - - // No keys are carried in: the transport fetches this node's identity - // from the node, or creates one there on a first join. - const sessionKeys = null; - - setStatus('connecting'); - const nodeId = nodesData.nodes[0].node_id; - // Renewed here rather than taken from the prop. This effect no longer - // re-runs when the token rotates (see the dependency list below), so - // the captured one can be older than the session's — and it is used to - // sign the offer to the hub, where an expired one is a 401 and no - // connection at all. Renewals are shared, so if one is already in - // flight this waits for it instead of starting a second. - const live = (await ensureFreshToken()) || token; - // The same base the API calls use: signaling is a hub endpoint like - // any other, and two sources for one address is how they drift. - const transport = new window.MeshBayTransport(HUB, live); - transportRef.current = transport; - - const ack = await transport.connect( - nodeId, live, groupId, null, sessionKeys, _bundleKey, username, - userId, _pendingJoinCode); - _pendingJoinCode = null; - if (cancelled) return; - setIsNodeAdmin(!!ack.is_node_admin); - setMemberUpload(ack.member_upload !== false); - // Changed while we are connected, by an operator who may be someone - // else entirely. Without this the button stays until a reconnection, - // and a button that is still there is a button people press. - transport.onUploadPolicy = (allowed) => setMemberUpload(allowed); - setOperatorPaired(transport.memberRole === 'operator'); - - // A first join to this node generated an identity for it; leave it with - // the node so any other browser can become the same person here with the - // passphrase. It is this node's key and no other's. - if (transport.connected && transport.newNodeBundle) { - try { - await transport.storeKeypairBundle(transport.newNodeBundle); - transport.newNodeBundle = null; - } catch (e) { - console.warn('[MeshBay] could not leave our key with the node:', e.message); - } - } - - // Import GEK from transport (fetched from node during handshake) - if (transport.gekRaw && window.MeshBayCrypto) { - gekRef.current = await window.MeshBayCrypto.importGEK( - window.MeshBayCrypto.b64encode(transport.gekRaw)); - } - - setStatus('fetching'); - - transport.onIndexSync = (msg) => { - if (cancelled) return; - applyIndex(msg); - }; - - // We are in: an invitation to this group has served its purpose. - if (onJoined) onJoined(groupId); - - const indexMsg = await transport.fetchIndex(); - if (cancelled) return; - applyIndex(indexMsg); - setStatus('connected'); - touchActivity(); - // First-hand evidence, and the strongest available: this browser spoke - // to the node. It outranks whatever the hub said in the group list. - if (onPresence) onPresence(groupId, 'online'); - } catch (err) { - if (cancelled) return; - - // Our token predates being added to this group. Refresh once and retry - // rather than telling someone who was just invited that they are not a - // member — which is what the node honestly sees, and is useless to them. - if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) { - refreshedRef.current = true; - try { - if (await onRefreshAuth()) { - setRetryKey(k => k + 1); - return; - } - } catch { /* fall through to the message below */ } - } - - // The node has never seen this browser for this account: it needs a - // one-time code from the operator before it will hand over the group - // key. Not an error to shout about — a step in joining. - if (err.reason === 'code_required') setNeedsCode(true); - // A key this node has never pinned, for an account it knows. The way in - // is a device already trusted here, not an operator — which is the - // whole point of device linking: a second browser or a native client - // must not cost anyone a support request. - if (err.reason === 'unknown_device') setNeedsDevice(true); - setError(err.message); - setStatus('error'); - if (transportRef.current) { - try { transportRef.current.close(); } catch { /* already gone */ } - transportRef.current = null; - } - // A refusal means the node answered, so it is up; only a failure to - // reach it at all is evidence of absence. - if (onPresence) { - onPresence(groupId, err.reason ? 'online' : 'offline'); - } - } - }; - - if (token && window.MeshBayTransport) { - connect(); - } else if (!window.MeshBayTransport) { - setStatus('error'); - setError(t('group.err_transport')); - } - - return () => { - cancelled = true; - if (transportRef.current) { - // Handed over rather than closed: a download running when you leave the - // group keeps its connection, and the last transfer using it closes it. - transfers.releaseWhenIdle(transportRef.current); - transportRef.current = null; - } - }; - // applyIndex is deliberately not a dependency: its identity changes with the - // `group` object, which the hub poll re-creates, and re-running this effect - // means tearing down the WebRTC connection. groupId is here, so a real group - // change still re-captures it. - // - // Neither is the token itself, only whether there is one. It used to be a - // dependency and that was harmless while a token never changed during a - // session — it only expired. Now that the session renews itself, the string - // rotates, and this effect tore the WebRTC connection down and rebuilt it - // every time. Worst on arrival: a stored token past its life is renewed the - // instant the page mounts, which is exactly when the group page is - // negotiating ICE, so the connection was abandoned mid-handshake and the - // node sat in `connecting` for ever. The live token is read inside - // `connect()` instead. Signing out unmounts this page; signing in mounts - // it; nothing in between should disturb a working connection. - }, [groupId, Boolean(token), retryKey]); - - const downloadFile = useCallback(async (entry) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - const gek = gekRef.current; - - // Both of these have to happen inside the click: a browser grants a file - // picker, and re-grants a folder, only from a user gesture. - const target = await _openDownloadTarget(entry.name, entry.size); - if (target === false) return; // the picker was dismissed - - const openRef = { url: null }; - transfers.start({ - kind: 'download', name: (target && target.name) || entry.name, - total: entry.size, transport, - open: target - ? (target.open || null) - : () => { if (openRef.url) window.open(openRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - let done = 0; - const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; - - if (target) { - try { - await pipelinedDownload(transport, gek, entry.id, totalChunks, - onChunk, target.writable, signal); - await target.writable.close(); - } catch (err) { - await target.writable.abort().catch(() => {}); - throw err; - } - } else { - const chunks = await pipelinedDownload( - transport, gek, entry.id, totalChunks, onChunk, null, signal); - const blob = new Blob(chunks); - _saveBlob(blob, entry.name); - openRef.url = URL.createObjectURL(blob); - } - }, - }); - }, []); - - const uploadFile = useCallback((e) => { - const files = [...(e.target.files || [])]; - e.target.value = ''; - const transport = transportRef.current; - if (!files.length || !transport || !transport.connected) return; - setError(''); - - for (const file of files) { - transfers.start({ - kind: 'upload', name: file.name, total: file.size, transport, - run: async ({ signal, onProgress }) => { - await transport.uploadFile(file, { - // Bytes the node acknowledged, not bytes read locally. - onProgress: (sent) => onProgress(sent, file.size), - signal, - }); - // The node re-indexes on a filesystem event, so there is nothing to - // wait on but the clock. Refreshing here means the file appears in - // the list without anyone reloading. - await new Promise(r => setTimeout(r, 2500)); - if (transport.connected) applyIndex(await transport.fetchIndex()); - }, - }); - } - }, [applyIndex]); - - const makeDirectory = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - const name = prompt(t('group.mkdir_prompt')); - if (!name || !name.trim()) return; - try { - await transport.createDirectory(currentPath, name.trim()); - const indexMsg = await transport.fetchIndex(); - if (indexMsg.entries) setEntries(indexMsg.entries); - if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); - if (indexMsg.roots) setNodeRoots(indexMsg.roots); - } catch (err) { - setError(err.message); - } - }, [currentPath]); - - const saveDescription = useCallback(async (e) => { - e.preventDefault(); - setSavingDesc(true); - try { - const r = await hubFetch(`/v1/groups/${groupId}`, { - method: 'PATCH', token, body: { description: descDraft }, - }); - if (onGroupUpdated) onGroupUpdated(groupId, { description: r.description }); - setEditingDesc(false); - } catch (err) { - setError(err.message); - } finally { - setSavingDesc(false); - } - }, [groupId, token, descDraft, onGroupUpdated]); - - /** - * Download a directory as a zip, written straight to disk. - * - * An archive of a group directory is routinely tens of gigabytes, so it is - * never held anywhere: each file is fetched chunk by chunk, decrypted, and - * handed to the zip writer, which hands it to the file the browser opened. - * Peak memory is one chunk plus one small record per file. - * - * Without the File System Access API there is nowhere to stream to, and the - * only alternative is to build the whole thing in memory — so that path is - * offered but says what it costs first. - */ - const downloadDirectory = useCallback(async (dir) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - - const files = entriesUnder(entries, dir); - if (!files.length) { - setError(t('group.zip_empty')); - return; - } - const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); - const suggested = (dir.split('/').pop() || 'files') + '.zip'; - - // totalBytes decides how this is delivered, but it is not the archive's - // size — headers and the central directory come on top — so it is not - // announced as a Content-Length that the download would then miss. - const target = await _openDownloadTarget(suggested, totalBytes, { - types: [{ description: 'ZIP archive', - accept: { 'application/zip': ['.zip'] } }], - }, 0); - if (target === false) return; - if (!target && !confirm(t('group.zip_no_stream', { - size: formatSize(totalBytes), name: suggested, - }))) { - return; - } - const gek = gekRef.current; - const zipOpenRef = { url: null }; - - transfers.start({ - kind: 'download', name: (target && target.name) || suggested, - total: totalBytes, transport, - open: target - ? (target.open || null) - : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const writable = target ? target.writable : null; - const parts = writable ? null : []; - let written = 0; - try { - const zip = new ZipStream(async (bytes) => { - if (writable) await writable.write(bytes); - else parts.push(bytes.slice()); - }); - - for (const { entry, name } of files) { - await zip.begin(name, entry.size, - new Date((entry.added_at || 0) * 1000)); - // A zero-byte file has no chunk to ask for; the header and an empty - // descriptor are the whole entry. - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - if (totalChunks > 0) await pipelinedDownload( - transport, gek, entry.id, totalChunks, - (bytes) => { written += bytes; onProgress(written, totalBytes); }, - // pipelinedDownload writes in order, which the archive needs. - { write: (plaintext) => zip.write(plaintext) }, signal); - await zip.end(); - } - await zip.finish(); - if (writable) await writable.close(); - else { - const blob = new Blob(parts, { type: 'application/zip' }); - _saveBlob(blob, suggested); - zipOpenRef.url = URL.createObjectURL(blob); - } - } catch (err) { - if (writable) await writable.abort().catch(() => {}); - throw err; - } - }, - }); - }, [entries]); - - const deleteDirectory = useCallback(async (dir) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - await transport.deleteDirectory(dir, signFn); - applyIndex(await transport.fetchIndex()); - } catch (err) { - setError(err.message); - } - }, [applyIndex]); - - const deleteFile = useCallback(async (entry) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - // Signs an explicit transcript built by transport.js, not opaque bytes from - // the node — see MeshBayCrypto.adminTranscript and finding H5. - // 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; - await transport.deleteFile(entry.id, signFn); - applyIndex(await transport.fetchIndex()); - } catch (err) { - setError(err.message); - } - }, [applyIndex]); - - const refreshIndex = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - applyIndex(await transport.fetchIndex()); - } catch {} - }, [applyIndex]); - - const toggleSort = useCallback((key) => { - setSortAsc(prev => sortKey === key ? !prev : true); - setSortKey(key); - }, [sortKey]); - - const dirs = new Set(); - const filteredEntries = entries.filter(e => { - const ePath = e.path || ''; - if (ePath === currentPath) { - return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); - } - if (!currentPath && ePath) { - dirs.add(ePath.split('/')[0]); - } else if (currentPath && ePath.startsWith(currentPath + '/')) { - const rest = ePath.slice(currentPath.length + 1); - dirs.add(rest.split('/')[0]); - } - return false; - }); - - const sorted = [...filteredEntries].sort((a, b) => { - let cmp = 0; - if (sortKey === 'name') cmp = a.name.localeCompare(b.name); - else if (sortKey === 'size') cmp = a.size - b.size; - else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); - else if (sortKey === 'date') cmp = a.added_at - b.added_at; - return sortAsc ? cmp : -cmp; - }); - - // The node's own listing, so an empty folder is visible, plus anything implied - // by a file path in case the two ever disagree. - for (const d of nodeDirs) { - if (!currentPath && !d.includes('/')) dirs.add(d); - else if (currentPath && d.startsWith(currentPath + '/')) { - const rest = d.slice(currentPath.length + 1); - if (!rest.includes('/')) dirs.add(rest); - } - } - const subdirs = [...dirs].sort(); - - // At the top of a group the folders on screen ARE the roots, so their state - // belongs there. Deeper in, everything shown lives inside one readable root - // and there is nothing to flag. - const rootState = new Map(nodeRoots.map(r => [r.name, r])); - const unavailableHere = currentPath - ? [] - : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); - // A member cannot create a folder at the top of a group: that level is the - // set of roots, which is the operator's configuration and not a directory on - // anyone's disk. The node refuses it, so offering it would only produce an - // error nobody can act on. - const canCreateDir = Boolean(currentPath) && isNodeAdmin; - - const baseLabel = { - idle: t('status.idle'), - discovering: t('status.discovering'), - connecting: t('status.connecting'), - fetching: t('status.fetching'), - connected: t('status.files', { n: entries.length }), - offline: t('status.offline'), - error: t('status.error'), - }[status] || status; - const statusLabel = baseLabel; - - const breadcrumbs = currentPath ? currentPath.split('/') : []; - - // Selection is keyed globally — file ids, and 'dir:' plus a full path — so - // walking into another folder keeps what was already ticked. - const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name); - const selectedFiles = entries.filter(e => selected.has(e.id)); - const selectedDirs = [...selected] - .filter(k => typeof k === 'string' && k.startsWith('dir:')) - .map(k => k.slice(4)); - const toggle = (key) => setSelected(prev => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); else next.add(key); - return next; - }); - - const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0 - ? selectedFiles[0] : null; - const deletableFiles = selectedFiles.filter( - e => isNodeAdmin || (userId && e.uploader_id === userId)); - - // Asked in two places — the Files toolbar and the chat composer — so it is - // answered once. The operator is never locked out of their own node. - const mayUpload = memberUpload || isNodeAdmin; - const run = (fn) => { - setSelecting(false); - setSelected(new Set()); - Promise.resolve().then(fn).catch(err => { - if (err && err.name !== 'AbortError') setError(err.message); - }); - }; - - // Icon only, with the name in the tooltip: these sit in a toolbar that is - // already narrow, and every one of them is a verb the icon carries on its - // own. `title` gives the hover text and `aria-label` the accessible name — - // an icon button with neither is unusable with a screen reader. - // - // Every action is rendered as soon as Select is on, and the ones that do not - // apply are disabled rather than absent. Buttons appearing and vanishing as - // the selection changed made the bar jump about and gave no clue that an - // action existed at all before something was ticked. - const action = (icon, label, onClick, opts = {}) => html` - - `; - - const canPlay = !!(onlyFile && onlyFile.type === 'video'); - const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); - const deletableCount = deletableFiles.length - + (operatorPaired ? selectedDirs.length : 0); - // The operator can always delete; anyone else only ever sees the button if - // something here is theirs to remove. Hiding it from an uploader would take - // away a right the protocol grants them (draft-v5 §5.1), not just a control. - const mayEverDelete = isNodeAdmin - || (userId && entries.some(e => e.uploader_id === userId)); - - const actionItems = html` - ${action('play', t('group.play'), - () => run(() => setVideoEntry(onlyFile)), { disabled: !canPlay })} - ${action('eye', t('group.view'), - () => run(() => setPreviewEntry(onlyFile)), { disabled: !canView })} - ${action('download', - selectedFiles.length - ? t('group.download_n', { n: selectedFiles.length }) - : t('group.download'), - () => run(async () => { - // Awaited one at a time, and each returns as soon as its transfer is - // registered — so the transfers still run together. Firing them without - // awaiting meant every file asked the browser for a save dialog at - // once, and a browser allows one: the rest were rejected and only the - // first file ever downloaded. - for (const e of selectedFiles) await downloadFile(e); - }), { disabled: selectedFiles.length === 0 })} - ${action('archive', - selectedDirs.length - ? t('group.download_zip_n', { n: selectedDirs.length }) - : t('group.download_zip_n', { n: 0 }), - () => run(async () => { - for (const d of selectedDirs) await downloadDirectory(d); - }), { disabled: selectedDirs.length === 0 })} - ${mayEverDelete && action('trash', - deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), - () => { - const names = [...deletableFiles.map(e => e.name), - ...(operatorPaired ? selectedDirs : [])]; - if (!confirm(t('group.delete_n_confirm', { n: names.length, - names: names.join(', ') }))) return; - run(() => { - for (const e of deletableFiles) deleteFile(e); - if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); - }); - }, - { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} - `; - - return html` -
-
-
-

- ${group ? group.name : t('group.default_name')} -

- ${editingDesc - ? html` -
- -
- - -
-
- ` - : 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. - The form stays in the DOM so a brief reconnect does not destroy the - input the user is typing into — controls are disabled instead. */ - isAdmin && group?.join_policy !== 'open' && html` -
-

${t('members.invite_title')}

- ${!connected ? html` -

${t('group.offline_title')}

- ` : !operatorPaired ? html` -

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

- ` : ''} -
- ${inviteCode && html` -
-

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

-

${inviteCode.code}

-

${t('members.invite_code_hint')}

-
- `} -
- setInviteUser(e.target.value)} - disabled=${!connected || !operatorPaired} 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` -
    - ${devices.map(d => html` -
  • - ${d.pk_ed25519.slice(0, 16)}… - - ${d.is_this_one && html` - ${t('device.this_one')}${' '} - `} - ${d.pinned_via}${d.label ? ' · ' + d.label : ''} - - ${!d.is_this_one && devices.length > 1 && 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` - - `} -