aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz
feat(hub): split the group UI into a pluggable "applications" architecture
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js3539
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js449
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js207
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js620
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js484
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js635
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js269
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js992
21 files changed, 3848 insertions, 3523 deletions
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`
- <svg class="icon ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"
- fill="none" stroke="currentColor" stroke-width="1.6"
- stroke-linecap="round" stroke-linejoin="round">
- ${paths.map((d, i) => html`<path key=${i} d=${d} />`)}
- </svg>
- `;
-}
-
// ── 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 }) {
</div>`;
}
-// ── 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`
- <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}"
- title=${label} aria-label=${label}
- disabled=${!!opts.disabled} onClick=${onClick}>
- <${Icon} name=${icon} />
- </button>
- `;
-
- 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`
- <div>
- <div class="group-header">
- <div>
- <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}">
- ${group ? group.name : t('group.default_name')}
- </h2>
- ${editingDesc
- ? html`
- <form class="group-desc-edit" onSubmit=${saveDescription}>
- <textarea rows="2" maxlength="512" autofocus
- placeholder="${t('group.desc_placeholder')}"
- value=${descDraft}
- onInput=${e => setDescDraft(e.target.value)}></textarea>
- <div>
- <button class="admin-btn" type="submit" disabled=${savingDesc}>
- ${savingDesc ? '...' : t('group.desc_save')}
- </button>
- <button class="btn-secondary" type="button"
- onClick=${() => setEditingDesc(false)}>${t('group.desc_cancel')}</button>
- </div>
- </form>
- `
- : html`
- ${group && group.description && html`
- <p class="group-desc">${group.description}</p>
- `}
- ${group && group.is_admin && html`
- <button class="link-btn" title=${t('group.desc_edit')}
- onClick=${() => { setDescDraft(group.description || '');
- setEditingDesc(true); }}>
- <${Icon} name="pencil" />${' '}
- ${group.description ? t('group.desc_edit') : t('group.desc_add')}
- </button>
- `}
- `}
- </div>
- ${group && html`
- <button class="group-mute-btn" onClick=${toggleGroupMute}
- title=${groupMuted ? t('group.unmute') : t('group.mute')}>
- <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} />
- </button>
- `}
- </div>
- ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '}
- <button class="admin-btn" style="margin-left:8px;font-size:0.9em"
- onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
- </div>`}
- ${needsDevice && html`
- <div class="invite-form" style="margin-bottom:12px">
- <h4>${t('device.add_title')}</h4>
- <p class="settings-hint">${t('device.add_hint')}</p>
- ${!deviceCode && html`
- <button class="admin-btn" onClick=${async () => {
- try {
- const transport = transportRef.current;
- const out = await transport.requestDeviceAdd(userId);
- setDeviceCode(out.code);
- } catch (err) { setError(err.message); }
- }}>${t('device.add_btn')}</button>
- `}
- ${deviceCode && html`
- <p class="settings-hint">${t('device.add_show')}</p>
- <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px">
- ${deviceCode}
- </p>
- `}
- </div>
- `}
- ${needsCode && html`
- <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
- <h4>${t('group.join_code_title')}</h4>
- <p class="settings-hint">${t('group.join_code_hint')}</p>
- <div style="display:flex;gap:8px">
- <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
- value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required />
- <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button>
- </div>
- </form>
- `}
- ${/* 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`
- <div class="group-tabs">
- <button class="group-tab ${tab === 'chat' ? 'active' : ''}"
- onClick=${() => setTab('chat')} title=${t('group.tab_chat')}>
- <${Icon} name="chat" cls="tab-icon" /></button>
- <button class="group-tab ${tab === 'files' ? 'active' : ''}"
- onClick=${() => setTab('files')} title=${t('group.tab_files')}>
- <${Icon} name="folder" cls="tab-icon" /></button>
- <button class="group-tab ${tab === 'settings' ? 'active' : ''}"
- onClick=${() => setTab('settings')} title=${t('group.tab_settings')}>
- <${Icon} name="gear" cls="tab-icon" /></button>
- </div>
-
- ${tab === 'files' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
- `}
- ${tab === 'files' && status === 'offline' && html`
- <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
- `}
-
- ${tab === 'files' && status === 'connected' && html`
- <div class="file-toolbar">
- <div class="toolbar-group">
- ${mayUpload && html`
- <label class="tb-btn primary">
- <${Icon} name="upload" /> ${t('group.upload')}
- <input type="file" multiple style="display:none"
- onChange=${uploadFile} />
- </label>
- `}
- ${canCreateDir && html`
- <button class="tb-btn" onClick=${makeDirectory}>
- <${Icon} name="folder-plus" /> ${t('group.mkdir')}
- </button>
- `}
- </div>
-
- <div class="breadcrumbs">
- <a class="crumb" onClick=${() => setCurrentPath('')}>
- <${Icon} name="home" />
- </a>
- ${breadcrumbs.map((seg, i) => {
- const path = breadcrumbs.slice(0, i + 1).join('/');
- return html`
- <span class="crumb-sep">/</span>
- <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a>
- `;
- })}
- </div>
-
- <div class="toolbar-group right">
- <div class="tb-search">
- <${Icon} name="search" />
- <input type="text" placeholder="${t('group.filter')}"
- value=${filter} onInput=${e => setFilter(e.target.value)} />
- </div>
- <button class="tb-btn ${selecting ? 'active' : ''}"
- onClick=${() => {
- setSelecting(v => !v);
- setSelected(new Set());
- }}>
- <${Icon} name=${selecting ? 'check' : 'checkbox'} />
- ${selecting ? t('group.select_done') : t('group.select')}
- </button>
- ${selecting && html`<div class="tb-actions">${actionItems}</div>`}
- </div>
- </div>
- <table class="file-table">
- <thead>
- <tr>
- ${selecting && html`<th class="sel-cell"></th>`}
- <th></th>
- <th class="sortable" onClick=${() => toggleSort('name')}>
- ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- <th class="sortable" onClick=${() => toggleSort('size')}>
- ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- <th class="sortable th-type" onClick=${() => toggleSort('type')}>
- ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- <th class="sortable th-date" onClick=${() => toggleSort('date')}>
- ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- </tr>
- </thead>
- <tbody>
- ${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`
- <tr class="file-row dir-row" key=${full} onClick=${() =>
- selecting ? toggle(dirKey(d)) : setCurrentPath(full)}>
- ${selecting && html`
- <td class="sel-cell">
- <input type="checkbox" checked=${selected.has(dirKey(d))}
- onClick=${(ev) => ev.stopPropagation()}
- onChange=${() => toggle(dirKey(d))} />
- </td>
- `}
- <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${unavailableHere.includes(d) ? html`
- <span class="root-offline"> ${t('group.root_unavailable')}</span>
- ` : ''}</td>
- <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
- <td class="td-type"></td>
- <td class="td-date"></td>
- </tr>
- `; })}
- ${sorted.map(e => html`
- <tr class="file-row" key=${e.id}
- onClick=${() => selecting && toggle(e.id)}>
- ${selecting && html`
- <td class="sel-cell">
- <input type="checkbox" checked=${selected.has(e.id)}
- onClick=${(ev) => ev.stopPropagation()}
- onChange=${() => toggle(e.id)} />
- </td>
- `}
- <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
- <td class="file-name">
- ${!selecting && canPreview(e)
- ? html`<a class="file-link" onClick=${() => {
- if (e.type === 'video') setVideoEntry(e);
- else setPreviewEntry(e);
- }}>${e.name}</a>`
- : e.name
- }
- </td>
- <td class="file-size">${formatSize(e.size)}</td>
- <td class="file-type td-type">${e.type}</td>
- <td class="file-date td-date">${formatDate(e.added_at)}</td>
- </tr>
- `)}
- ${sorted.length === 0 && subdirs.length === 0 && html`
- <tr><td colspan=${selecting ? 6 : 5} class="file-empty">
- ${filter ? t('group.empty_filter') : t('group.empty_dir')}
- </td></tr>
- `}
- </tbody>
- </table>
- `}
-
- ${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`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
- `}
- ${tab === 'chat' && status === 'offline' && html`
- <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
- `}
-
- ${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`
- <p class="page-message">
- ${t('group.offline_title')}
- ${' '}${t('group.offline_hint')}
- </p>
- `}
- ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
- `}
- ${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)} />
- `}
- </div>
- `;
-}
-
-// ── 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`
- <div class="video-overlay" onClick=${(e) => {
- if (e.target.classList.contains('video-overlay')) onClose();
- }}>
- <div class="video-top-bar">
- <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
- ${onDownload && html`
- <button class="video-close ${downloading ? 'dl-active' : ''}"
- onClick=${() => {
- if (!downloading) {
- setDownloading(true);
- onDownload();
- setTimeout(() => setDownloading(false), 1500);
- }
- }}
- title="${t('group.download')}" disabled=${downloading}>
- ${downloading
- ? html`<span class="spinner"></span>`
- : html`<${Icon} name="download" />`}</button>
- `}
- <button class="video-close" onClick=${onClose} title="${t('video.close')}">
- <${Icon} name="close" /></button>
- </div>
- ${phase === 'loading' && html`
- <div class="video-loading">
- <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
- <div class="video-progress-bar">
- <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div>
- </div>
- </div>
- `}
- ${phase === 'ready' && content?.type === 'pdf' && html`
- <object data=${blobUrlRef.current} type="application/pdf"
- class="preview-pdf" aria-label=${entry.name}>
- <p class="page-message">${t('preview.pdf_fallback')}</p>
- </object>
- `}
- ${phase === 'ready' && content?.type === 'image' && html`
- <div class="preview-image-wrap">
- <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} />
- </div>
- `}
- ${phase === 'ready' && content?.type === 'text' && html`
- <div class="preview-text-wrap">
- <pre class="preview-text">${content.text}</pre>
- </div>
- `}
- ${phase === 'error' && html`
- <div class="video-error">${error}</div>
- `}
- </div>
- `;
-}
-
-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`<p class="page-message">${t('explore.loading')}</p>`;
-
- const isOwner = Boolean(isAdmin);
-
- return html`
- <div class="members-panel">
- ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
-
- ${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.invite_title')}</h3>
- ${!connected ? html`
- <p class="settings-hint">${t('group.offline_title')}</p>
- ` : !operatorPaired ? html`
- <p class="settings-hint">
- ${isNodeAdmin ? t('members.invite_needs_pairing')
- : t('members.invite_ask_operator')}
- </p>
- ` : ''}
- <form onSubmit=${doInvite}>
- ${inviteCode && html`
- <div class="success-msg" style="margin-bottom:8px">
- <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
- <p class="code-display">${inviteCode.code}</p>
- <p>${t('members.invite_code_hint')}</p>
- </div>
- `}
- <div class="form-row">
- <input type="text" placeholder="${t('members.username_placeholder')}"
- value=${inviteUser} onInput=${e => setInviteUser(e.target.value)}
- disabled=${!connected || !operatorPaired} required />
- <button class="admin-btn" type="submit"
- disabled=${inviting || !connected || !operatorPaired}>
- ${inviting ? '...' : t('members.invite_btn')}
- </button>
- </div>
- </form>
- </div>
- `}
-
- ${isNodeAdmin && !operatorPaired && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.pair_title')}</h3>
- <p class="settings-hint">${t('members.pair_hint')}</p>
- ${pairStatus && html`
- <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
- ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
- </p>
- `}
- <form class="form-row" onSubmit=${doPair}>
- <input type="text" placeholder="XXXX-XXXX" class="code-input"
- value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
- <button class="admin-btn" type="submit" disabled=${pairing}>
- ${pairing ? '...' : t('members.pair_btn')}
- </button>
- </form>
- </div>
- `}
-
- ${/* 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`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
- <div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${uploadBusy}
- onClick=${() => setUploads(!memberUpload)}>
- ${uploadBusy ? '...'
- : (memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable'))}
- </button>
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
- </div>
- `}
-
- ${connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('device.mine_title')}</h3>
- <p class="settings-hint">${t('device.mine_hint')}</p>
- ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
- ${devices.length === 0
- ? html`<p class="settings-hint">${t('device.mine_empty')}</p>`
- : html`
- <ul class="device-list">
- ${devices.map(d => html`
- <li class="device-row" key=${d.pk_ed25519}>
- <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span>
- <span class="device-meta">
- ${d.is_this_one && html`
- <span class="badge">${t('device.this_one')}</span>${' '}
- `}
- ${d.pinned_via}${d.label ? ' · ' + d.label : ''}
- </span>
- ${!d.is_this_one && devices.length > 1 && html`
- <button class="admin-btn" onClick=${() => revokeDevice(d)}>
- ${t('device.revoke')}
- </button>
- `}
- </li>
- `)}
- </ul>
- `}
- <form onSubmit=${approveDevice} class="settings-subform">
- <p class="settings-hint">${t('device.approve_hint')}</p>
- <div class="form-row">
- <input type="text" placeholder="XXXX-XXXX" class="code-input"
- value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
- <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
- </div>
- </form>
- </div>
- `}
-
- ${/* Roots management (Electron-only, when node is local) */
- nodeDetected && nodeRoots.length > 0 && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.roots')}</h3>
- ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
- <div class="node-roots">
- ${nodeRoots.map(r => html`
- <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
- key=${r.name}>
- <div class="node-root-info">
- <span class="node-root-name">
- <${Icon} name="folder" />
- ${r.name}
- </span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
- <span class="node-root-badge node-root-badge-warn">
- ${t('node.unavailable')}</span>`}
- </div>
- ${nodeRoots.length > 1 && !r.upload && html`
- <button class="btn btn-small btn-danger"
- disabled=${nodeBusy}
- onClick=${async () => {
- if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
- setNodeBusy(true); setNodeMsg('');
- try {
- await platform.node.call('DELETE',
- '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
- await platform.node.call('POST', '/api/reload');
- setNodeMsg(t('node.root_removed'));
- await loadNodeInfo();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- ${t('node.remove_root')}</button>`}
- </div>
- `)}
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${nodeBusy}
- onClick=${async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- setNodeBusy(true); setNodeMsg('');
- try {
- await platform.node.call('POST',
- '/api/groups/' + groupId + '/roots',
- { path: chosen.path, name: chosen.name });
- await platform.node.call('POST', '/api/reload');
- setNodeMsg(t('node.root_added'));
- await loadNodeInfo();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- <${Icon} name="folder-plus" /> ${t('node.add_root')}
- </button>
- </div>
- </div>
- `}
-
- ${/* Upload toggle via loopback when MNP not connected */
- nodeDetected && !connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
- <div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${nodeBusy}
- onClick=${async () => {
- setNodeBusy(true); setNodeMsg('');
- try {
- const newVal = !memberUpload;
- await platform.node.call('PUT',
- '/api/groups/' + groupId + '/member-upload',
- { allowed: newVal });
- if (onMemberUpload) onMemberUpload(newVal);
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- ${memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable')}
- </button>
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- </div>
- `}
-
- ${/* Delete/leave — node detach first (reversible), then hub delete
- (irreversible). */ html`
- <div class="settings-section">
- <h3 class="settings-heading">
- ${isOwner ? t('group.delete_group') : t('group.leave')}
- </h3>
- <div class="settings-row">
- <span class="settings-label">
- ${isOwner ? t('members.danger_delete_hint')
- : t('members.danger_leave_hint')}
- </span>
- ${isOwner
- ? html`
- <button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
- try {
- // Node detach first (reversible), then hub delete (irreversible)
- if (nodeDetected && nodeGroupName) {
- try {
- await platform.node.call('POST', '/api/groups/detach',
- { name: nodeGroupName });
- } catch (detachErr) {
- if (!confirm(t('settings_node.detach_failed_continue'))) return;
- }
- }
- await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
- navigate('/');
- window.location.reload();
- } catch (err) { setError(err.message); }
- }}>${t('group.delete_group')}</button>
- `
- : html`
- <button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
- try {
- await hubFetch('/v1/groups/' + groupId + '/leave',
- { method: 'POST', token });
- if (onLeft) onLeft(groupId);
- } catch (err) { setError(err.message); }
- }}>${t('group.leave')}</button>
- `}
- </div>
- </div>
- `}
-
- <div class="settings-section">
- <h3 class="settings-heading">
- ${t('group.tab_members')} (${members.length})
- </h3>
- <table class="admin-table">
- <thead>
- <tr>
- <th>${t('admin.col_username')}</th>
- <th>${t('members.group_role')}</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- ${members.map(m => html`
- <tr key=${m.user_id}>
- <td>${m.username}</td>
- <td>
- ${m.user_id === adminId
- ? html`<span class="badge badge-owner">${t('members.owner')}</span>`
- : html`<span class="badge">${t('members.member')}</span>`
- }
- </td>
- <td class="admin-actions">
- ${isAdmin && m.user_id !== adminId && html`
- <button class="admin-btn danger" disabled=${removing === m.user_id}
- onClick=${() => {
- if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
- removeMember(m);
- }}>
- ${removing === m.user_id ? '...' : t('members.remove')}
- </button>
- `}
- </td>
- </tr>
- `)}
- </tbody>
- </table>
- ${isAdmin && members.length > 1 && html`
- <p class="settings-hint">${t('members.remove_hint')}</p>
- `}
- </div>
- </div>
- `;
-}
-
-// ── 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`<a href=${url} target="_blank" rel="noopener noreferrer"
- class="chat-link">${url}</a>`);
- 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`<div class="chat-att-thumb"><span class="spinner"></span></div>`;
- if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`;
- return html`<img class="chat-att-thumb" src=${blobUrl} alt=${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`
- <div class="chat-panel" ref=${panelRef}>
- <div class="chat-messages" ref=${listRef} onScroll=${onScroll}>
- ${hasMore && html`
- <div class="chat-older-row">
- <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}>
- ${loadingOlder
- ? html`<span class="spinner"></span>`
- : html`<${Icon} name="chevron" cls="chat-older-icon" />`}
- ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })}
- </button>
- </div>
- `}
- ${!hasMore && messages.length > 0 && html`
- <div class="chat-start">${t('chat.start_of_history')}</div>
- `}
- ${messages.length === 0 && html`
- <div class="chat-empty">${t('chat.empty')}</div>
- `}
- ${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`
- <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div>
- `}
- ${unreadFrom && unreadFrom === m.id && html`
- <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div>
- `}
- <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}
- ${showSender || daySep ? '' : 'chat-msg-tight'}">
- ${showSender && html`
- <div class="chat-sender">${displayName}</div>
- `}
- <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
- ${att ? html`
- <div class="chat-attachment" style="cursor:pointer" onClick=${() => {
- 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`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>`
- : html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>`
- }
- <div class="chat-att-size">${formatSize(att.size)}</div>
- </div>
- ` : html`
- <span class="chat-text">
- ${linkify(parsed && typeof parsed.text === 'string'
- ? parsed.text : m.payload)}
- </span>
- `}
- <span class="chat-time">${formatTime(m.timestamp)}</span>
- </div>
- </div>
- `;
- })}
- </div>
- ${!atBottom && messages.length > 0 && html`
- <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}>
- <${Icon} name="chevron" cls="chat-jump-icon" />
- ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')}
- </button>
- `}
- <div class="chat-input-row">
- ${mayUpload && html`
- <label class="chat-attach" title="${t('chat.attach')}">
- ${attaching ? html`<span class="spinner"></span>`
- : html`<${Icon} name="clip" />`}
- <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
- </label>
- `}
- <textarea class="chat-input" rows="1" ref=${inputRef}
- placeholder="${t('chat.placeholder')}"
- value=${input}
- onInput=${e => setInput(e.target.value)}
- onKeyDown=${onKeyDown}
- disabled=${sending} />
- <button class="chat-send" onClick=${sendMessage}
- disabled=${sending || !input.trim()}>
- ${t('chat.send')}
- </button>
- </div>
- </div>
- `;
-}
-
-// ── Video Player (MSE streaming) ────────────────────────────────────────
-
-function _mseSupported(codec) {
- if (!window.MediaSource) return false;
- const mime = `video/mp4; codecs="${codec}"`;
- return MediaSource.isTypeSupported(mime);
-}
-
-/** Seconds as h:mm:ss, or m:ss under an hour. */
-function formatClock(seconds) {
- const s = Math.max(0, Math.floor(seconds || 0));
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = String(s % 60).padStart(2, '0');
- return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
-}
-
-/**
- * Where *this account on this device* last left off in a given file.
- *
- * localStorage rather than the node: it needs no protocol, no storage anyone
- * else has to keep, and nothing new learns what you watch. The cost is that
- * the position does not follow you from the laptop to the phone.
- *
- * The account has to be in the key. Without it the position is per *device* —
- * so a second person signing in on the same machine was offered "resume where
- * you left off" in a film they had never opened, which is both wrong and a
- * small disclosure of what someone else watches. Found by signing in with a
- * fresh account and being offered a resume point.
- */
-function resumeKey(fileId) {
- const auth = loadAuth();
- return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null;
-}
-
-function readResumePosition(fileId) {
- try {
- const key = resumeKey(fileId);
- if (!key) return 0;
- const raw = localStorage.getItem(key);
- const at = raw ? parseFloat(raw) : 0;
- return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0;
- } catch {
- return 0; // private browsing, or storage disabled
- }
-}
-
-function writeResumePosition(fileId, at, duration) {
- try {
- const key = resumeKey(fileId);
- if (!key) return;
- if (!Number.isFinite(at) || at < RESUME_MIN_S
- || (duration && at > duration * RESUME_MAX_FRACTION)) {
- localStorage.removeItem(key);
- return;
- }
- localStorage.setItem(key, String(Math.floor(at)));
- } catch { /* nothing to be done, and nothing worth failing over */ }
-}
-
-/**
- * Drop the positions written before they were scoped to an account.
- *
- * Re-keying them is not possible — there is no record of whose they were, and
- * guessing would hand them to whoever signs in next, which is the bug. They go.
- */
-function purgeUnscopedResumePositions() {
- try {
- const stale = [];
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current.
- if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) {
- stale.push(key);
- }
- }
- stale.forEach((key) => localStorage.removeItem(key));
- } catch { /* storage disabled: nothing was written either */ }
-}
-
-purgeUnscopedResumePositions();
-
-function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
- const [dlBusy, setDlBusy] = useState(false);
- const [phase, setPhase] = useState('loading');
- const [error, setError] = useState('');
- const videoRef = useRef(null);
- const msRef = useRef(null);
- const sbRef = useRef(null);
- const blobUrlRef = useRef(null);
- const queueRef = useRef([]);
- const appendingRef = useRef(false);
- const endedRef = useRef(false);
- const durationRef = useRef(0);
- // Segments the node is allowed to have in flight but has not sent yet, and
- // when we last said anything to it at all.
- const outstandingRef = useRef(0);
- const lastPokeRef = useRef(0);
- // Diagnostics reported to the node: how many appends the browser refused for
- // want of room, and whether the element itself says it is starved.
- const quotaRef = useRef(0);
- const stalledRef = useRef(false);
- // Seeking. `awaitingInit` is true from the moment we ask the node to restart
- // somewhere else until its new `stream_init` arrives: the channel is ordered,
- // so everything in between belongs to the stream we just abandoned and would
- // otherwise be appended on top of the new one. `seekTarget` is where to put
- // the playhead once the buffer actually covers it.
- const awaitingInitRef = useRef(false);
- const seekTargetRef = useRef(null);
- const seekTimerRef = useRef(null);
- // The seek is built inside the effect, where the transport and `cancelled`
- // live; the render needs to reach it for "start from the beginning".
- const requestSeekRef = useRef(null);
- const [resumedFrom, setResumedFrom] = useState(0);
- const [castActive, setCastActive] = useState(false);
- const [castUrl, setCastUrl] = useState(null);
- const [castPickerOpen, setCastPickerOpen] = useState(false);
- const [castDevices, setCastDevices] = useState([]);
- const [castScanning, setCastScanning] = useState(false);
- const [castDeviceName, setCastDeviceName] = useState(null);
- const castActiveRef = useRef(false);
- const castCodecRef = useRef(null);
- const initSegmentRef = useRef(null);
- const castRestartPendingRef = useRef(false);
- const castDeviceRef = useRef(null);
- const castRestartGenRef = useRef(0);
- const landingPlayheadRef = useRef(false);
-
- /**
- * The buffered range the playhead is actually in, or null.
- *
- * Seeking makes the buffer discontinuous, and "the last range" stops meaning
- * "the one being watched" the moment there is more than one: measuring the
- * read-ahead against a range on the far side of a gap reports a full buffer
- * while the player starves.
- */
- const currentRange = useCallback(() => {
- const sb = sbRef.current;
- const v = videoRef.current;
- if (!sb || !v) return null;
- try {
- const t = v.currentTime;
- for (let i = 0; i < sb.buffered.length; i++) {
- // Half a second of slack: the playhead sits exactly on a boundary
- // often enough, and a strict test there reports nothing buffered.
- if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) {
- return [sb.buffered.start(i), sb.buffered.end(i)];
- }
- }
- } catch { /* the SourceBuffer went away under us */ }
- return null;
- }, []);
-
- /**
- * Drop what has already been watched.
- *
- * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes
- * and refuse the append that goes past. Keeping a minute behind the playhead
- * is enough for a small seek backwards and bounded for a three-hour film.
- */
- const evictBehind = useCallback(() => {
- const sb = sbRef.current;
- const v = videoRef.current;
- if (!sb || !v || sb.updating || !sb.buffered.length) return false;
- const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
- // The range being watched, not the first one: after a seek backwards the
- // first range is somewhere else entirely, and removing from its start to
- // just behind the playhead would take out everything in between —
- // including what is playing.
- const range = currentRange();
- const start = range ? range[0] : sb.buffered.start(0);
- if (keepFrom - start < 10) return false;
- try {
- sb.remove(start, keepFrom);
- return true;
- } catch {
- return false;
- }
- }, [currentRange]);
-
- /** Seconds of film held past the playhead. */
- const bufferedAhead = useCallback(() => {
- const v = videoRef.current;
- const range = currentRange();
- if (!v || !range) return 0;
- return Math.max(0, range[1] - v.currentTime);
- }, [currentRange]);
-
- const flushQueue = useCallback(() => {
- const sb = sbRef.current;
- if (!sb || appendingRef.current || sb.updating) return;
- if (queueRef.current.length === 0) {
- if (endedRef.current && msRef.current?.readyState === 'open') {
- try { msRef.current.endOfStream(); } catch {}
- }
- return;
- }
- appendingRef.current = true;
- const chunk = queueRef.current[0];
- try {
- sb.appendBuffer(chunk);
- queueRef.current.shift();
- } catch (e) {
- appendingRef.current = false;
- if (e.name === 'QuotaExceededError') {
- quotaRef.current += 1;
- // The segment stays at the head of the queue and is tried again once
- // there is room. Dropping it — which is what this used to do — leaves a
- // hole in the middle of the film and no error anywhere.
- if (!evictBehind()) {
- console.warn('[MSE] buffer full and nothing to evict yet');
- }
- return;
- }
- queueRef.current.shift();
- console.error('[MSE] appendBuffer error:', e);
- }
- }, [evictBehind]);
-
- /**
- * Decide whether the node may send more, and keep the pipeline moving.
- *
- * This is the only place credit is granted, and the only thing that can
- * restart a pipeline the buffer ceiling has stopped. That second job is why
- * it exists: an append refused for quota fires no `updateend`, so it grants
- * no credit, so the node sends nothing, so no segment arrives to call
- * `flushQueue` again. Every wakeup the append path had was downstream of the
- * append that just failed — the player deadlocked against itself and sat on
- * "buffering" for good, which is what a 500 MB film did at around 100 MB.
- *
- * So the clock drives this, not the data.
- */
- const pump = useCallback(() => {
- if (awaitingInitRef.current) return;
- const transport = transportRef.current;
- evictBehind();
- flushQueue();
- if (endedRef.current && queueRef.current.length === 0) return;
- if (!transport || !transport.connected) return;
-
- if (bufferedAhead() > BUFFER_AHEAD_S
- || queueRef.current.length > QUEUE_HIGH_WATER) {
- // Far enough ahead. Grant nothing, but do not go silent: two minutes of
- // silence is how the node decides nobody is watching, and pausing a film
- // for two minutes is an ordinary thing to do.
- const now = Date.now();
- if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) {
- lastPokeRef.current = now;
- transport.grantStreamCredit(0);
- }
- return;
- }
-
- // Top the window back up to what is allowed in flight, rather than paying
- // off everything owed at once. Called on every arriving segment as well as
- // on the clock, so credit trickles out as room appears instead of being
- // released in one gulp when the buffer finally drains.
- const room = STREAM_WINDOW - outstandingRef.current;
- if (room > 0) {
- outstandingRef.current += room;
- lastPokeRef.current = Date.now();
- transport.grantStreamCredit(room);
- }
- }, [evictBehind, flushQueue, bufferedAhead]);
-
- useEffect(() => {
- let cancelled = false;
- // Reset here, not in the teardown of the run before: switching video while
- // an append was in flight left `appendingRef` true, and flushQueue bails
- // out on it. The new SourceBuffer then never appended anything, so no
- // `updateend` ever cleared the flag, no credit went back to the node, and
- // the player sat on "buffering" for good. `endedRef` surviving is the same
- // shape of bug — the next stream would call endOfStream() the first time
- // its queue ran dry and truncate the film.
- appendingRef.current = false;
- endedRef.current = false;
- queueRef.current = [];
- outstandingRef.current = 0;
- lastPokeRef.current = Date.now();
- quotaRef.current = 0;
- stalledRef.current = false;
- // The same shape again, and the seek refs are worse than the others.
- // Switching film while a seek was in flight leaves `awaitingInit` true,
- // and only reinitAt() ever lowers it — which the next film does not go
- // through, because it builds a new SourceBuffer. Every segment of the new
- // film is then dropped as though it belonged to the one we left, for good.
- // A stale `seekTarget` is milder: the new film jumps to a position from
- // the old one the moment that much is buffered.
- awaitingInitRef.current = false;
- seekTargetRef.current = null;
- clearTimeout(seekTimerRef.current);
- const transport = transportRef.current;
- if (!transport || !transport.connected) {
- setError(t('video.err_transport'));
- setPhase('error');
- return;
- }
-
- const onStarved = () => { stalledRef.current = true; pump(); };
- const onFed = () => { stalledRef.current = false; };
-
- /** The buffered ranges, short enough for a log line. */
- const describeRanges = () => {
- const sb = sbRef.current;
- if (!sb) return '(no buffer)';
- try {
- let s = '';
- for (let i = 0; i < sb.buffered.length; i++) {
- s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
- }
- return s.trim() || '(empty)';
- } catch {
- return '?';
- }
- };
-
- /**
- * Ask the node to restart the film somewhere else.
- *
- * Debounced, because dragging the scrubber fires `seeking` continuously and
- * each request kills an ffmpeg and spawns another. Only the position the
- * finger stops on is worth acting on.
- */
- const requestSeek = (target) => {
- clearTimeout(seekTimerRef.current);
- seekTimerRef.current = setTimeout(() => {
- const t = transportRef.current;
- if (cancelled || !t || !t.connected) return;
- // Everything arriving from here until the new `stream_init` belongs to
- // the stream being abandoned. The channel is ordered, so this flag is
- // enough to tell them apart without a sequence number in the protocol.
- // Rare enough to report every time, and the node logs it at INFO. A
- // seek nobody asked for is the kind of thing only this line can show:
- // from the node's side it is indistinguishable from a viewer dragging
- // the scrubber.
- t.sendStreamDiag({
- event: 'seek', target: +target.toFixed(1),
- t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
- ready: videoRef.current ? videoRef.current.readyState : null,
- offset: sbRef.current ? sbRef.current.timestampOffset : null,
- ranges: describeRanges(),
- });
- awaitingInitRef.current = true;
- seekTargetRef.current = target;
- outstandingRef.current = STREAM_WINDOW;
- setPhase('loading');
- console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW);
- t.requestStream(entry.id, STREAM_WINDOW, target);
- }, SEEK_DEBOUNCE_MS);
- };
- requestSeekRef.current = requestSeek;
-
- /**
- * Move the playhead onto a seek once the data for it has arrived.
- *
- * Setting `currentTime` into a region that is not buffered yet leaves the
- * element waiting with nothing to show, and on a seek backwards it would
- * be overwritten by the playhead the browser restores. So the position is
- * remembered and applied on the first append that actually covers it.
- */
- const landPlayhead = () => {
- const target = seekTargetRef.current;
- const v = videoRef.current, sb = sbRef.current;
- if (target === null || !v || !sb) return;
- try {
- for (let i = 0; i < sb.buffered.length; i++) {
- const a = sb.buffered.start(i), b = sb.buffered.end(i);
- if (target >= a - 1 && target < b) {
- seekTargetRef.current = null;
- // ffmpeg lands on the keyframe at or before what we asked for, so
- // the range can begin slightly later than the target; never seek
- // behind what is actually there.
- if (Math.abs(v.currentTime - target) > 0.5) {
- landingPlayheadRef.current = true;
- v.currentTime = Math.max(target, a);
- }
- v.play().catch(() => {});
- return;
- }
- }
- } catch { /* the SourceBuffer went away */ }
- };
-
- /** Wait for whatever the SourceBuffer is doing to finish. */
- const settled = (sb) => new Promise((resolve) => {
- if (!sb.updating) return resolve();
- sb.addEventListener('updateend', resolve, { once: true });
- });
-
- /**
- * Put the SourceBuffer back to an empty state that starts at `start`.
- *
- * Everything buffered is dropped rather than kept alongside the new
- * material. A discontinuous buffer is legal and every piece of code that
- * reads `buffered` then has to reason about which range it means — the
- * eviction, the read-ahead, the seek test — for the sake of a few
- * megabytes of film the viewer has just navigated away from.
- *
- * `abort()` first: ffmpeg was killed mid-fragment, so the parser is
- * holding half of one, and appending the next stream's header on top of
- * that is a decode error.
- */
- const reinitAt = async (start) => {
- const sb = sbRef.current;
- if (!sb) return;
- try { sb.abort(); } catch { /* not in a state that needs it */ }
- await settled(sb);
- try {
- sb.remove(0, Infinity);
- await settled(sb);
- } catch { /* nothing buffered */ }
- // ffmpeg restarts its timestamps at zero however far in we asked it to
- // seek, so this is what puts the fragments back on the film's timeline.
- try { sb.timestampOffset = start; } catch { /* older browsers */ }
- const tr = transportRef.current;
- if (tr) {
- tr.sendStreamDiag({
- event: 'reinit', target: start, offset: sb.timestampOffset,
- t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
- ranges: describeRanges(),
- });
- }
- queueRef.current = [];
- appendingRef.current = false;
- endedRef.current = false;
- quotaRef.current = 0;
- awaitingInitRef.current = false;
- seekTargetRef.current = start;
- console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length);
- setPhase('streaming');
- pump();
- };
-
- const onSeeking = () => {
- if (landingPlayheadRef.current) {
- landingPlayheadRef.current = false;
- return;
- }
- const v = videoRef.current;
- if (!v || cancelled) return;
- const target = v.currentTime;
- // Inside what is buffered, the browser handles it and the node need not
- // hear about it at all — unless a cast is active, because the relay
- // cannot seek within its HTTP stream and must be restarted.
- if (!castActiveRef.current) {
- const sb = sbRef.current;
- if (sb) {
- try {
- for (let i = 0; i < sb.buffered.length; i++) {
- if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) {
- return;
- }
- }
- } catch { /* fall through and ask the node */ }
- }
- }
- requestSeek(target);
- };
-
- const startStream = async () => {
- transport.onStreamError = (msg) => {
- if (cancelled) return;
- // Say what the node said. Sitting on "buffering" with the reason
- // already delivered is the worst of both.
- setError(msg.detail || t('video.err_transport'));
- setPhase('error');
- };
-
- transport.onStreamInit = (msg) => {
- if (cancelled) return;
- if (msg.file_id && msg.file_id !== entry.id) return;
- const mime = `video/mp4; codecs="${msg.codec}"`;
- castCodecRef.current = msg.codec;
-
- if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
- setError(t('video.err_mse', { codec: msg.codec }));
- setPhase('error');
- return;
- }
-
- durationRef.current = msg.duration || 0;
-
- // A second init on a live SourceBuffer is a seek landing, not a new
- // film. Reuse what is there: rebuilding the MediaSource would reset the
- // element's src, blank the picture and throw away the duration the
- // scrubber is drawn from.
- if (sbRef.current && msRef.current
- && msRef.current.readyState === 'open') {
- console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current);
- initSegmentRef.current = null;
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.stop().catch(() => {});
- castRestartPendingRef.current = true;
- }
- reinitAt(msg.start || 0).catch(() => {
- setError(t('video.err_transport'));
- setPhase('error');
- });
- return;
- }
-
- // If we reach here during a seek (readyState was 'ended' after the
- // previous stream finished), the seek-landing path above could not run.
- // A fresh MediaSource is needed, but the seek state must still be reset
- // or awaitingInit stays true and every segment is dropped forever.
- awaitingInitRef.current = false;
- endedRef.current = false;
- appendingRef.current = false;
- queueRef.current = [];
- sbRef.current = null;
- initSegmentRef.current = null;
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.stop().catch(() => {});
- castRestartPendingRef.current = true;
- }
-
- const ms = new MediaSource();
- msRef.current = ms;
- if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
- const url = URL.createObjectURL(ms);
- blobUrlRef.current = url;
-
- ms.addEventListener('sourceopen', () => {
- if (cancelled) return;
- if (durationRef.current > 0) {
- ms.duration = durationRef.current;
- }
- const sb = ms.addSourceBuffer(mime);
- sbRef.current = sb;
- // 'segments', not 'sequence': the fragments must land where they
- // belong on the film's timeline rather than one after another, or a
- // stream that started at 40 minutes would be buffered at zero and
- // the scrubber would lie about everything.
- sb.mode = 'segments';
- try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ }
- if (msg.start) seekTargetRef.current = msg.start;
- transport.sendStreamDiag({
- event: 'first-init', target: msg.start || 0,
- offset: sb.timestampOffset, duration: durationRef.current,
- t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
- });
- sb.addEventListener('updateend', () => {
- // No credit is granted here, deliberately. Appending is not the
- // same question as having room, and tying the two meant `remove()`
- // — which fires this event too — paid the node for the player's own
- // evictions. What may be in flight is decided from the buffer, in
- // pump(), and nowhere else.
- appendingRef.current = false;
- landPlayhead();
- pump();
- });
- setPhase('streaming');
- flushQueue();
- });
-
- if (videoRef.current) {
- videoRef.current.src = url;
- videoRef.current.addEventListener('seeking', onSeeking);
- videoRef.current.addEventListener('timeupdate', pump);
- // The element's own verdict. "buffering" on screen is this, and it
- // is the one thing the node cannot infer from a stream it is feeding.
- videoRef.current.addEventListener('waiting', onStarved);
- videoRef.current.addEventListener('stalled', onStarved);
- videoRef.current.addEventListener('playing', onFed);
- videoRef.current.addEventListener('canplay', onFed);
- }
- };
-
- transport.onStreamData = async (msg) => {
- if (cancelled) return;
- // A segment arrived, so it is no longer in flight — whatever we go on
- // to do with it. This has to come before every early return below, and
- // it did not: skipping the count for segments we discard leaks a slot
- // out of the window each time, and the window never grows back.
- //
- // `reinitAt` is asynchronous — it waits for two `updateend` events —
- // and a seek's first segments arrive during that gap and are dropped
- // by the flag below. Lose all eight and the player believes a full
- // window is in flight, grants nothing ever again, and the node waits
- // for credit that cannot come. A race, which is why the same seek
- // worked twice and hung on the third.
- outstandingRef.current = Math.max(0, outstandingRef.current - 1);
- if (awaitingInitRef.current) {
- console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
- }
- // Between asking for a seek and its `stream_init`, everything on the
- // channel is the film we just left. Same file, so `file_id` cannot
- // tell them apart — ordering can.
- if (awaitingInitRef.current) return;
- // Late segments from the stream we just left. The DataChannel is
- // ordered, so they arrive before the new stream's first segment and
- // would otherwise be decrypted against the wrong file — which fails,
- // loudly, in the console, for something that is simply not ours.
- if (msg.file_id && msg.file_id !== entry.id) return;
- try {
- const plaintext = await window.MeshBayCrypto.decryptChunkBin(
- gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
- if (initSegmentRef.current === null) {
- initSegmentRef.current = plaintext;
- if (castRestartPendingRef.current && castActiveRef.current
- && platform.cast.available) {
- castRestartPendingRef.current = false;
- const gen = ++castRestartGenRef.current;
- const device = castDeviceRef.current;
- platform.cast.start({
- codec: castCodecRef.current,
- initSegment: plaintext,
- }).then(async (result) => {
- if (castRestartGenRef.current !== gen) return;
- if (!result) return;
- setCastUrl(result.url);
- const status = await platform.cast.status();
- if (status && status.chromecast && status.chromecast.connected) {
- await platform.cast.chromecastReload({ mediaUrl: result.url });
- } else if (device) {
- await platform.cast.chromecastConnect({
- deviceId: device.id, mediaUrl: result.url,
- });
- setCastDeviceName(device.name);
- } else {
- navigator.clipboard.writeText(result.url).catch(() => {});
- }
- }).catch((err) => {
- if (castRestartGenRef.current !== gen) return;
- console.error('[cast] restart failed:', err);
- platform.cast.stop().catch(() => {});
- setCastActive(false); castActiveRef.current = false;
- setCastUrl(null); setCastDeviceName(null);
- });
- }
- }
- if (castActiveRef.current && !castRestartPendingRef.current
- && platform.cast.available) {
- platform.cast.push(plaintext).catch(() => {});
- }
- queueRef.current.push(plaintext);
- // pump(), not flushQueue(): arriving data is the moment to top the
- // window back up, and that is what keeps the stream continuous.
- pump();
- } catch (e) {
- console.error('[MSE] decrypt error:', e);
- }
- };
-
- transport.onStreamEnd = (msg) => {
- if (cancelled) return;
- // The end of the previous film is not the end of this one.
- if (msg && msg.file_id && msg.file_id !== entry.id) return;
- // Nor is the end of the stream we abandoned by seeking: taking it
- // would call endOfStream() and truncate the film at the seek point.
- if (awaitingInitRef.current) return;
- endedRef.current = true;
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.finish().catch(() => {});
- }
- flushQueue();
- };
-
- // The opening window, and the count that tracks it. Asking for more here
- // than pump() maintains would leave the node holding credit this side
- // does not know about, which is the whole window's worth of overshoot on
- // the very first breath of the stream.
- outstandingRef.current = STREAM_WINDOW;
- const resumeAt = readResumePosition(entry.id);
- if (resumeAt) setResumedFrom(resumeAt);
- transport.requestStream(entry.id, STREAM_WINDOW, resumeAt);
- };
-
- // Closing the tab, or backgrounding it on a phone, never runs a React
- // cleanup — so the node hears nothing and keeps transcoding. `pagehide`
- // fires in both cases and is the one event mobile browsers honour on the
- // way out; `visibilitychange` covers switching apps. The node stops the
- // stream by itself when the connection drops, but that costs a round of
- // detection, and this message is a single datagram already in flight.
- const leave = (why) => {
- const t = transportRef.current;
- console.log('[MeshBay] stopStream:', why);
- if (t && t.connected) t.stopStream();
- };
- const onPageHide = () => leave('pagehide');
- // NOT wired to stopStream. Android fires visibilitychange when a video goes
- // fullscreen, so cutting the stream here killed the film the moment it was
- // watched properly. Logged only, until that is confirmed or ruled out.
- const onVisibility = () => {
- console.log('[MeshBay] visibilitychange:', document.visibilityState);
- };
- window.addEventListener('pagehide', onPageHide);
- document.addEventListener('visibilitychange', onVisibility);
-
- // `timeupdate` is silent while the film is paused, and the append path
- // cannot wake itself once the ceiling has refused a segment. This is the
- // clock that guarantees something is still driving the pipeline.
- const pumpTimer = setInterval(pump, 1000);
-
- // What the player sees, into the node's log. A hang on a phone shows the
- // node feeding a stream quite happily; the half that says otherwise is in
- // here, and there is no console to read it from.
- const diagTimer = setInterval(() => {
- const v = videoRef.current, sb = sbRef.current;
- const t = transportRef.current;
- if (!t || !v) return;
- // Cheap, and the only thing that makes "resume where I stopped" work
- // when the tab is closed rather than the player.
- if (!v.paused) {
- writeResumePosition(entry.id, v.currentTime, durationRef.current);
- }
- let ranges = '';
- try {
- for (let i = 0; sb && i < sb.buffered.length; i++) {
- ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
- }
- } catch { ranges = '?'; }
- t.sendStreamDiag({
- t: +v.currentTime.toFixed(1),
- ahead: +bufferedAhead().toFixed(1),
- ranges: ranges.trim(),
- ready: v.readyState, // 0 = nothing, 4 = enough to play through
- paused: v.paused,
- stalled: stalledRef.current,
- q: queueRef.current.length,
- inflight: outstandingRef.current,
- appending: appendingRef.current,
- updating: sb ? sb.updating : null,
- quota: quotaRef.current,
- ms: msRef.current ? msRef.current.readyState : null,
- err: v.error ? `${v.error.code}:${v.error.message}` : null,
- });
- }, 5000);
-
- startStream().catch(err => {
- if (!cancelled) { setError(err.message); setPhase('error'); }
- });
-
- return () => {
- cancelled = true;
- clearInterval(pumpTimer);
- clearInterval(diagTimer);
- clearTimeout(seekTimerRef.current);
- // Closing the player is the commonest way to stop watching, so this is
- // the write that matters most.
- if (videoRef.current) {
- writeResumePosition(entry.id, videoRef.current.currentTime,
- durationRef.current);
- }
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.chromecastDisconnect().catch(() => {});
- platform.cast.stop().catch(() => {});
- castActiveRef.current = false;
- }
- window.removeEventListener('pagehide', onPageHide);
- document.removeEventListener('visibilitychange', onVisibility);
- if (videoRef.current) {
- videoRef.current.removeEventListener('seeking', onSeeking);
- videoRef.current.removeEventListener('timeupdate', pump);
- videoRef.current.removeEventListener('waiting', onStarved);
- videoRef.current.removeEventListener('stalled', onStarved);
- videoRef.current.removeEventListener('playing', onFed);
- videoRef.current.removeEventListener('canplay', onFed);
- }
- if (transport) {
- // Tell the node first: dropping the handlers only makes us deaf, and a
- // stream nobody is listening to still occupies a transcode slot.
- transport.stopStream();
- transport.onStreamInit = null;
- transport.onStreamData = null;
- transport.onStreamEnd = null;
- transport.onStreamError = null;
- }
- // The queue can hold several megabytes of decrypted video.
- queueRef.current = [];
- const ms = msRef.current;
- if (ms && ms.readyState === 'open') {
- try { ms.endOfStream(); } catch { /* already ended */ }
- }
- if (blobUrlRef.current) {
- URL.revokeObjectURL(blobUrlRef.current);
- blobUrlRef.current = null;
- }
- sbRef.current = null;
- msRef.current = null;
- };
- }, [entry, flushQueue, pump]);
-
- useEffect(() => {
- if (phase === 'streaming' && videoRef.current) {
- videoRef.current.play().catch(() => {});
- }
- }, [phase]);
-
- useEffect(() => {
- return () => {
- if (blobUrlRef.current) {
- URL.revokeObjectURL(blobUrlRef.current);
- blobUrlRef.current = null;
- }
- };
- }, []);
-
- useEffect(() => {
- const onKey = (e) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [onClose]);
-
- return html`
- <div class="video-overlay" onClick=${(e) => {
- if (e.target.classList.contains('video-overlay')) onClose();
- }}>
- <div class="video-top-bar">
- <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
- ${platform.capabilities.lanCast && html`
- <div class="cast-wrapper" style="position:relative">
- <button class="video-close ${castActive ? 'cast-active' : ''}"
- onClick=${async () => {
- if (castActive) {
- await platform.cast.chromecastDisconnect().catch(() => {});
- await platform.cast.stop();
- setCastActive(false); castActiveRef.current = false;
- setCastUrl(null);
- setCastDeviceName(null);
- castDeviceRef.current = null;
- } else if (castCodecRef.current && initSegmentRef.current) {
- if (castPickerOpen) {
- setCastPickerOpen(false);
- } else {
- setCastPickerOpen(true);
- setCastScanning(true);
- setCastDevices([]);
- platform.cast.discover().then((devices) => {
- setCastDevices(devices || []);
- setCastScanning(false);
- }).catch(() => setCastScanning(false));
- }
- }
- }}
- title="${castActive ? t('cast.stop') : t('cast.start')}">
- <${Icon} name="cast" /></button>
- ${castPickerOpen && html`
- <div class="cast-picker">
- ${castScanning && html`
- <div class="cast-picker-item cast-picker-scanning">
- <span class="spinner" style="width:14px;height:14px"></span>
- ${t('cast.scanning')}
- </div>
- `}
- ${castDevices.map(d => html`
- <button class="cast-picker-item" onClick=${() => {
- setCastPickerOpen(false);
- setCastDeviceName(d.name);
- setCastActive(true); castActiveRef.current = true;
- castRestartPendingRef.current = true;
- castDeviceRef.current = d;
- if (videoRef.current && requestSeekRef.current) {
- requestSeekRef.current(videoRef.current.currentTime);
- }
- }}>
- <${Icon} name="cast" /> ${d.name}
- </button>
- `)}
- ${!castScanning && castDevices.length === 0 && html`
- <div class="cast-picker-item cast-picker-empty">
- ${t('cast.no_devices')}
- </div>
- `}
- <div class="cast-picker-sep"></div>
- <button class="cast-picker-item" onClick=${async () => {
- setCastPickerOpen(false);
- setCastActive(true); castActiveRef.current = true;
- castRestartPendingRef.current = true;
- if (videoRef.current && requestSeekRef.current) {
- requestSeekRef.current(videoRef.current.currentTime);
- }
- }}>
- <${Icon} name="clip" /> ${t('cast.copy_url')}
- </button>
- </div>
- `}
- </div>
- `}
- ${castUrl && html`
- <span class="cast-status-label">
- ${castDeviceName
- ? castDeviceName
- : html`<input class="cast-url-input" readOnly value=${castUrl}
- onClick=${(e) => {
- e.target.select();
- navigator.clipboard.writeText(castUrl).catch(() => {});
- }}
- title="${t('cast.copy_url')}" />`
- }
- </span>
- `}
- ${onDownload && html`
- <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
- onClick=${() => {
- if (!dlBusy) {
- setDlBusy(true);
- onDownload();
- setTimeout(() => setDlBusy(false), 1500);
- }
- }}
- title="${t('group.download')}">
- ${dlBusy
- ? html`<span class="spinner"></span>`
- : html`<${Icon} name="download" />`}</button>
- `}
- <button class="video-close" onClick=${onClose} title="${t('video.close')}">
- <${Icon} name="close" /></button>
- </div>
-
- ${phase === 'loading' && html`
- <div class="video-loading">
- <div class="video-loading-label">
- <span class="spinner"></span>${' '}${t('video.buffering')}
- </div>
- </div>
- `}
-
- ${(phase === 'streaming' || phase === 'loading') && html`
- <div class="video-container">
- <video ref=${videoRef} controls autoplay />
- ${resumedFrom > 0 && html`
- <div class="video-resumed">
- ${t('video.resumed_at', { time: formatClock(resumedFrom) })}
- <button class="linklike" onClick=${() => {
- setResumedFrom(0);
- writeResumePosition(entry.id, 0, durationRef.current);
- if (requestSeekRef.current) requestSeekRef.current(0);
- }}>${t('video.from_start')}</button>
- </div>
- `}
- </div>
- `}
-
- ${phase === 'error' && html`
- <div class="video-error">${error}</div>
- `}
- </div>
- `;
-}
// ── Search Page (cross-group file search) ───────────────────────────────────
@@ -5740,7 +2233,7 @@ function NodePage({ token, username, userId, groups }) {
setStatus('connecting');
setError('');
try {
- if (!_bundleKey) _bundleKey = await _loadBundleKey();
+ if (!session.bundleKey) session.bundleKey = await _loadBundleKey();
const live = (await ensureFreshToken()) || token;
const nodeCandidates = (await Promise.all(
@@ -5777,7 +2270,7 @@ function NodePage({ token, username, userId, groups }) {
console.log('[NodePage] connecting via group', groupId.slice(0, 8),
'node', nodeId.slice(0, 8));
await transport.connect(nodeId, live, groupId,
- null, null, _bundleKey, username, userId);
+ null, null, session.bundleKey, username, userId);
console.log('[NodePage] connected, fetching status');
const result = await transport.fetchNodeStatus();
console.log('[NodePage] got status:', result.groups?.length, 'groups');
@@ -6273,7 +2766,7 @@ function App() {
// A renewal can happen inside hubFetch, well away from any render. This is
// how the component learns about it — including a failed one, which sets
// null and lands on the login page instead of failing every later call.
- _onAuthChange = (auth) => setUser(auth);
+ setAuthChangeListener((auth) => setUser(auth));
// On mount above all: a tab reopened tomorrow holds an hour-old access
// token and a refresh token good for a month, and used to greet its owner
@@ -6289,7 +2782,7 @@ function App() {
};
document.addEventListener('visibilitychange', onVisible);
return () => {
- _onAuthChange = null;
+ setAuthChangeListener(null);
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
@@ -6469,8 +2962,8 @@ function App() {
refreshToken = data.refreshToken;
// The only thing sign-in produces: the key that opens a node's bundle.
// Which identity we use is decided per node, when we get there.
- _bundleKey = data.bundleKey;
- await _storeBundleKey(_bundleKey);
+ session.bundleKey = data.bundleKey;
+ await _storeBundleKey(session.bundleKey);
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
new file mode 100644
index 0000000..0db713c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -0,0 +1,28 @@
+import { ChatPanel } from './chat-app.js';
+import { FilesPanel } from './files-app.js';
+
+/**
+ * Every group "application", in tab order.
+ *
+ * Adding one (Videos, Music, Photos — none of them need an MNP change, see
+ * the node's indexer classifying video/audio/image already) means a new file
+ * exporting a component and one entry here. Nothing in group-page.js changes:
+ * every registered component receives the same shared context (see its
+ * `commonProps`) and renders itself into the active tab.
+ *
+ * `key` doubles as the identifier the node's `apps_enabled` setting uses, so
+ * it must match `ALLOWED_APPS` in the node's webrtc_server.py.
+ */
+const APPS = [
+ { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
+ { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
+];
+
+/** The registry filtered to what this group has enabled, in registry order. */
+function visibleApps(enabledKeys) {
+ const enabled = new Set(
+ enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key));
+ return APPS.filter(a => enabled.has(a.key));
+}
+
+export { APPS, visibleApps };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
new file mode 100644
index 0000000..0babf51
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -0,0 +1,449 @@
+import {
+ html, useState, useEffect, useLayoutEffect, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t, getLocale } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
+
+/**
+ * Message text with its links made clickable.
+ *
+ * Only http and https, and built as elements rather than markup: a message is
+ * something another member wrote, so it must never become HTML. `javascript:`
+ * and `data:` are not matched at all, and the anchors carry noopener so the new
+ * tab cannot reach back into this one.
+ */
+const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
+
+function 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`<a href=${url} target="_blank" rel="noopener noreferrer"
+ class="chat-link">${url}</a>`);
+ 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`<div class="chat-att-thumb"><span class="spinner"></span></div>`;
+ if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`;
+ return html`<img class="chat-att-thumb" src=${blobUrl} alt=${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`
+ <div class="chat-panel" ref=${panelRef}>
+ <div class="chat-messages" ref=${listRef} onScroll=${onScroll}>
+ ${hasMore && html`
+ <div class="chat-older-row">
+ <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}>
+ ${loadingOlder
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="chevron" cls="chat-older-icon" />`}
+ ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })}
+ </button>
+ </div>
+ `}
+ ${!hasMore && messages.length > 0 && html`
+ <div class="chat-start">${t('chat.start_of_history')}</div>
+ `}
+ ${messages.length === 0 && html`
+ <div class="chat-empty">${t('chat.empty')}</div>
+ `}
+ ${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`
+ <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div>
+ `}
+ ${unreadFrom && unreadFrom === m.id && html`
+ <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div>
+ `}
+ <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}
+ ${showSender || daySep ? '' : 'chat-msg-tight'}">
+ ${showSender && html`
+ <div class="chat-sender">${displayName}</div>
+ `}
+ <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
+ ${att ? html`
+ <div class="chat-attachment" style="cursor:pointer" onClick=${() => {
+ 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`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>`
+ : html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>`
+ }
+ <div class="chat-att-size">${formatSize(att.size)}</div>
+ </div>
+ ` : html`
+ <span class="chat-text">
+ ${linkify(parsed && typeof parsed.text === 'string'
+ ? parsed.text : m.payload)}
+ </span>
+ `}
+ <span class="chat-time">${formatTime(m.timestamp)}</span>
+ </div>
+ </div>
+ `;
+ })}
+ </div>
+ ${!atBottom && messages.length > 0 && html`
+ <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}>
+ <${Icon} name="chevron" cls="chat-jump-icon" />
+ ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')}
+ </button>
+ `}
+ <div class="chat-input-row">
+ ${mayUpload && html`
+ <label class="chat-attach" title="${t('chat.attach')}">
+ ${attaching ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="clip" />`}
+ <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
+ </label>
+ `}
+ <textarea class="chat-input" rows="1" ref=${inputRef}
+ placeholder="${t('chat.placeholder')}"
+ value=${input}
+ onInput=${e => setInput(e.target.value)}
+ onKeyDown=${onKeyDown}
+ disabled=${sending} />
+ <button class="chat-send" onClick=${sendMessage}
+ disabled=${sending || !input.trim()}>
+ ${t('chat.send')}
+ </button>
+ </div>
+ </div>
+ `;
+}
+
+// ── Video Player (MSE streaming) ────────────────────────────────────────
+
+
+export { ChatPanel };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
new file mode 100644
index 0000000..4541290
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -0,0 +1,207 @@
+import * as downloads from './downloads.js';
+import * as platform from './platform.js';
+
+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',
+ });
+}
+
+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;
+// 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 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);
+}
+
+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;
+}
+
+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;
+}
+
+/**
+ * Download one file through the transfers widget: picks a target, streams
+ * and decrypts it, and falls back to a blob when there is nowhere to stream
+ * to. Shared by the Files table/toolbar and the video/preview modals' own
+ * download button — both just want "get this entry to disk".
+ */
+async function downloadEntry(transfers, transport, gek, entry) {
+ 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);
+ }
+ },
+ });
+}
+
+export {
+ FILE_ICONS,
+ formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE,
+ _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry,
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
new file mode 100644
index 0000000..561d8a7
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -0,0 +1,620 @@
+import {
+ html, useState, useEffect, useRef, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { ZipStream, entriesUnder } from './zipstream.js';
+import { transfers } from './transfers.js';
+import {
+ FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE,
+ _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry,
+} from './file-utils.js';
+
+// ── Files ────────────────────────────────────────────────────────────────────
+//
+// The group's file browser: toolbar (upload, mkdir, filter, select), the
+// table itself, and every file/directory operation. `entries`/`nodeDirs`/
+// `nodeRoots` are owned by the group shell (group-page.js) — Chat needs the
+// same index for its image attachments — and handed down here read-only
+// alongside `applyIndex`/the raw setters to write back through, the same
+// shape ChatPanel already takes for `onRefreshIndex`.
+//
+// `onPreview` opens a file in the shell's video/preview modal rather than
+// this component owning that state itself, again because more than one tab
+// (Chat's attachments) can trigger it.
+
+function FilesPanel({
+ groupId, transportRef, gekRef, status,
+ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+}) {
+ const [selecting, setSelecting] = useState(false);
+ const [selected, setSelected] = useState(() => new Set());
+ const [sortKey, setSortKey] = useState('name');
+ const [sortAsc, setSortAsc] = useState(true);
+ const [filter, setFilter] = useState('');
+ const [currentPath, setCurrentPath] = useState('');
+
+ // 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 downloadFile = useCallback(async (entry) => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ // 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 — so this stays
+ // a click handler all the way down into downloadEntry's own picker call.
+ await downloadEntry(transfers, transport, gekRef.current, entry);
+ }, []);
+
+ 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]);
+
+ /**
+ * 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 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 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));
+
+ 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`
+ <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}"
+ title=${label} aria-label=${label}
+ disabled=${!!opts.disabled} onClick=${onClick}>
+ <${Icon} name=${icon} />
+ </button>
+ `;
+
+ 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(() => onPreview(onlyFile)), { disabled: !canPlay })}
+ ${action('eye', t('group.view'),
+ () => run(() => onPreview(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`
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
+ `}
+ ${status === 'offline' && html`
+ <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
+ `}
+
+ ${status === 'connected' && html`
+ <div class="file-toolbar">
+ <div class="toolbar-group">
+ ${mayUpload && html`
+ <label class="tb-btn primary">
+ <${Icon} name="upload" /> ${t('group.upload')}
+ <input type="file" multiple style="display:none"
+ onChange=${uploadFile} />
+ </label>
+ `}
+ ${canCreateDir && html`
+ <button class="tb-btn" onClick=${makeDirectory}>
+ <${Icon} name="folder-plus" /> ${t('group.mkdir')}
+ </button>
+ `}
+ </div>
+
+ <div class="breadcrumbs">
+ <a class="crumb" onClick=${() => setCurrentPath('')}>
+ <${Icon} name="home" />
+ </a>
+ ${breadcrumbs.map((seg, i) => {
+ const path = breadcrumbs.slice(0, i + 1).join('/');
+ return html`
+ <span class="crumb-sep">/</span>
+ <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a>
+ `;
+ })}
+ </div>
+
+ <div class="toolbar-group right">
+ <div class="tb-search">
+ <${Icon} name="search" />
+ <input type="text" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${e => setFilter(e.target.value)} />
+ </div>
+ <button class="tb-btn ${selecting ? 'active' : ''}"
+ onClick=${() => {
+ setSelecting(v => !v);
+ setSelected(new Set());
+ }}>
+ <${Icon} name=${selecting ? 'check' : 'checkbox'} />
+ ${selecting ? t('group.select_done') : t('group.select')}
+ </button>
+ ${selecting && html`<div class="tb-actions">${actionItems}</div>`}
+ </div>
+ </div>
+ <table class="file-table">
+ <thead>
+ <tr>
+ ${selecting && html`<th class="sel-cell"></th>`}
+ <th></th>
+ <th class="sortable" onClick=${() => toggleSort('name')}>
+ ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable" onClick=${() => toggleSort('size')}>
+ ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable th-type" onClick=${() => toggleSort('type')}>
+ ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable th-date" onClick=${() => toggleSort('date')}>
+ ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ ${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`
+ <tr class="file-row dir-row" key=${full} onClick=${() =>
+ selecting ? toggle(dirKey(d)) : setCurrentPath(full)}>
+ ${selecting && html`
+ <td class="sel-cell">
+ <input type="checkbox" checked=${selected.has(dirKey(d))}
+ onClick=${(ev) => ev.stopPropagation()}
+ onChange=${() => toggle(dirKey(d))} />
+ </td>
+ `}
+ <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
+ <td>${d}${unavailableHere.includes(d) ? html`
+ <span class="root-offline"> ${t('group.root_unavailable')}</span>
+ ` : ''}</td>
+ <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
+ <td class="td-type"></td>
+ <td class="td-date"></td>
+ </tr>
+ `; })}
+ ${sorted.map(e => html`
+ <tr class="file-row" key=${e.id}
+ onClick=${() => selecting && toggle(e.id)}>
+ ${selecting && html`
+ <td class="sel-cell">
+ <input type="checkbox" checked=${selected.has(e.id)}
+ onClick=${(ev) => ev.stopPropagation()}
+ onChange=${() => toggle(e.id)} />
+ </td>
+ `}
+ <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
+ <td class="file-name">
+ ${!selecting && canPreview(e)
+ ? html`<a class="file-link" onClick=${() => onPreview(e)}>${e.name}</a>`
+ : e.name
+ }
+ </td>
+ <td class="file-size">${formatSize(e.size)}</td>
+ <td class="file-type td-type">${e.type}</td>
+ <td class="file-date td-date">${formatDate(e.added_at)}</td>
+ </tr>
+ `)}
+ ${sorted.length === 0 && subdirs.length === 0 && html`
+ <tr><td colspan=${selecting ? 6 : 5} class="file-empty">
+ ${filter ? t('group.empty_filter') : t('group.empty_dir')}
+ </td></tr>
+ `}
+ </tbody>
+ </table>
+ `}
+ `;
+}
+
+// ── 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`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-top-bar">
+ <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${onDownload && html`
+ <button class="video-close ${downloading ? 'dl-active' : ''}"
+ onClick=${() => {
+ if (!downloading) {
+ setDownloading(true);
+ onDownload();
+ setTimeout(() => setDownloading(false), 1500);
+ }
+ }}
+ title="${t('group.download')}" disabled=${downloading}>
+ ${downloading
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
+ `}
+ <button class="video-close" onClick=${onClose} title="${t('video.close')}">
+ <${Icon} name="close" /></button>
+ </div>
+ ${phase === 'loading' && html`
+ <div class="video-loading">
+ <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
+ <div class="video-progress-bar">
+ <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div>
+ </div>
+ </div>
+ `}
+ ${phase === 'ready' && content?.type === 'pdf' && html`
+ <object data=${blobUrlRef.current} type="application/pdf"
+ class="preview-pdf" aria-label=${entry.name}>
+ <p class="page-message">${t('preview.pdf_fallback')}</p>
+ </object>
+ `}
+ ${phase === 'ready' && content?.type === 'image' && html`
+ <div class="preview-image-wrap">
+ <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} />
+ </div>
+ `}
+ ${phase === 'ready' && content?.type === 'text' && html`
+ <div class="preview-text-wrap">
+ <pre class="preview-text">${content.text}</pre>
+ </div>
+ `}
+ ${phase === 'error' && html`
+ <div class="video-error">${error}</div>
+ `}
+ </div>
+ `;
+}
+
+export { FilesPanel, FilePreview };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
new file mode 100644
index 0000000..1528213
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -0,0 +1,484 @@
+import {
+ html, useState, useEffect, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { transfers } from './transfers.js';
+import { downloadEntry } from './file-utils.js';
+import {
+ HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
+} from './hub-client.js';
+import { visibleApps } from './apps.js';
+import { FilePreview } from './files-app.js';
+import { VideoPlayer } from './video-player.js';
+import { GroupSettingsPanel } from './group-settings.js';
+
+/**
+ * The group shell: everything a group's "applications" (Chat, Files, and
+ * whatever registers in apps.js next) share — the WebRTC connection, the file
+ * index, and the tab bar that switches between them — plus the group header
+ * and the Settings tab, which is not itself an app (disabling it would strand
+ * an operator with no way to re-enable anything).
+ */
+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 [videoEntry, setVideoEntry] = useState(null);
+ const [previewEntry, setPreviewEntry] = useState(null);
+ const [editingDesc, setEditingDesc] = useState(false);
+ const [descDraft, setDescDraft] = useState('');
+ const [savingDesc, setSavingDesc] = useState(false);
+ const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat';
+ const [tab, setTab] = useState(defaultTab);
+ useEffect(() => { setTab(defaultTab); }, [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);
+ // Which applications this group has enabled, from the node. Falls back to
+ // every registered app when a node predates the setting (or hasn't answered
+ // yet), so nothing disappears for an existing group.
+ const [enabledApps, setEnabledApps] = useState(null);
+ // 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 group: if a fresh token still says we are not a member, we
+ // really are not, and retrying forever would hide that. `GroupPage` is
+ // rendered without a `key` on the `/group/:id` route (switching groups does
+ // not remount it — see the `[groupId]`-keyed effects below), so this has to
+ // be reset explicitly per group rather than relying on a fresh mount: a ref
+ // set to `true` while looking at one group would otherwise silently disable
+ // the retry for every group opened afterward in the same session, forever.
+ const refreshedRef = useRef(false);
+ useEffect(() => { refreshedRef.current = false; }, [groupId]);
+
+ const submitJoinCode = useCallback((e) => {
+ e.preventDefault();
+ const code = codeInput.trim();
+ if (!code) return;
+ session.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 (!session.bundleKey) session.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, session.bundleKey, username,
+ userId, session.pendingJoinCode);
+ session.pendingJoinCode = null;
+ if (cancelled) return;
+ setIsNodeAdmin(!!ack.is_node_admin);
+ setMemberUpload(ack.member_upload !== false);
+ setEnabledApps(ack.enabled_apps || null);
+ // 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);
+ transport.onAppsEnabled = (apps) => setEnabledApps(apps);
+ 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 downloadFileForModal = useCallback(async (entry) => {
+ // The video/preview modals' own download button — the table's row and
+ // toolbar actions call the same shared helper from files-app.js, since
+ // only a single open file/video is ever in play here.
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ await downloadEntry(transfers, transport, gekRef.current, entry);
+ }, []);
+
+ const refreshIndex = useCallback(async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ applyIndex(await transport.fetchIndex());
+ } catch {}
+ }, [applyIndex]);
+
+ 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]);
+
+ // 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;
+
+ // A single dispatcher so any app can open the right modal without owning
+ // video/preview state itself — Files' table and Chat's attachments both
+ // call this the same way.
+ const onPreview = useCallback((entry) => {
+ if (entry.type === 'video') setVideoEntry(entry); else setPreviewEntry(entry);
+ }, []);
+
+ const apps = visibleApps(enabledApps);
+ const commonProps = {
+ groupId, transportRef, gekRef, status, username,
+ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ onRefreshIndex: refreshIndex, onActivity: touchActivity,
+ };
+
+ return html`
+ <div>
+ <div class="group-header">
+ <div>
+ <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}">
+ ${group ? group.name : t('group.default_name')}
+ </h2>
+ ${editingDesc
+ ? html`
+ <form class="group-desc-edit" onSubmit=${saveDescription}>
+ <textarea rows="2" maxlength="512" autofocus
+ placeholder="${t('group.desc_placeholder')}"
+ value=${descDraft}
+ onInput=${e => setDescDraft(e.target.value)}></textarea>
+ <div>
+ <button class="admin-btn" type="submit" disabled=${savingDesc}>
+ ${savingDesc ? '...' : t('group.desc_save')}
+ </button>
+ <button class="btn-secondary" type="button"
+ onClick=${() => setEditingDesc(false)}>${t('group.desc_cancel')}</button>
+ </div>
+ </form>
+ `
+ : html`
+ ${group && group.description && html`
+ <p class="group-desc">${group.description}</p>
+ `}
+ ${group && group.is_admin && html`
+ <button class="link-btn" title=${t('group.desc_edit')}
+ onClick=${() => { setDescDraft(group.description || '');
+ setEditingDesc(true); }}>
+ <${Icon} name="pencil" />${' '}
+ ${group.description ? t('group.desc_edit') : t('group.desc_add')}
+ </button>
+ `}
+ `}
+ </div>
+ ${group && html`
+ <button class="group-mute-btn" onClick=${toggleGroupMute}
+ title=${groupMuted ? t('group.unmute') : t('group.mute')}>
+ <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} />
+ </button>
+ `}
+ </div>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '}
+ <button class="admin-btn" style="margin-left:8px;font-size:0.9em"
+ onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
+ </div>`}
+ ${needsDevice && html`
+ <div class="invite-form" style="margin-bottom:12px">
+ <h4>${t('device.add_title')}</h4>
+ <p class="settings-hint">${t('device.add_hint')}</p>
+ ${!deviceCode && html`
+ <button class="admin-btn" onClick=${async () => {
+ try {
+ const transport = transportRef.current;
+ const out = await transport.requestDeviceAdd(userId);
+ setDeviceCode(out.code);
+ } catch (err) { setError(err.message); }
+ }}>${t('device.add_btn')}</button>
+ `}
+ ${deviceCode && html`
+ <p class="settings-hint">${t('device.add_show')}</p>
+ <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px">
+ ${deviceCode}
+ </p>
+ `}
+ </div>
+ `}
+ ${needsCode && html`
+ <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
+ <h4>${t('group.join_code_title')}</h4>
+ <p class="settings-hint">${t('group.join_code_hint')}</p>
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button>
+ </div>
+ </form>
+ `}
+ ${/* 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. The apps
+ below still need the node and say so. */ group && html`
+ <div class="group-tabs">
+ ${apps.map(a => html`
+ <button key=${a.key} class="group-tab ${tab === a.key ? 'active' : ''}"
+ onClick=${() => setTab(a.key)} title=${t(a.labelKey)}>
+ <${Icon} name=${a.icon} cls="tab-icon" /></button>
+ `)}
+ <button class="group-tab ${tab === 'settings' ? 'active' : ''}"
+ onClick=${() => setTab('settings')} title=${t('group.tab_settings')}>
+ <${Icon} name="gear" cls="tab-icon" /></button>
+ </div>
+
+ ${apps.map(a => tab === a.key && html`
+ <${a.Component} key=${a.key} ...${commonProps} />
+ `)}
+
+ ${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)}
+ enabledApps=${enabledApps}
+ onEnabledApps=${(keys) => setEnabledApps(keys)}
+ onLeft=${onLeft}
+ onPaired=${() => setOperatorPaired(true)} />
+ `}
+ `}
+ ${status === 'offline' && !group && html`
+ <p class="page-message">
+ ${t('group.offline_title')}
+ ${' '}${t('group.offline_hint')}
+ </p>
+ `}
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
+ `}
+ ${previewEntry && html`
+ <${FilePreview}
+ entry=${previewEntry}
+ transportRef=${transportRef}
+ gekRef=${gekRef}
+ onClose=${() => setPreviewEntry(null)}
+ onDownload=${() => downloadFileForModal(previewEntry)} />
+ `}
+ ${videoEntry && html`
+ <${VideoPlayer}
+ entry=${videoEntry}
+ transportRef=${transportRef}
+ gekRef=${gekRef}
+ onClose=${() => setVideoEntry(null)}
+ onDownload=${() => downloadFileForModal(videoEntry)} />
+ `}
+ </div>
+ `;
+}
+
+export { GroupPage };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
new file mode 100644
index 0000000..a14fd21
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -0,0 +1,635 @@
+import {
+ html, useState, useEffect, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { hubFetch, navigate } from './hub-client.js';
+import { APPS } from './apps.js';
+import * as platform from './platform.js';
+
+// ── 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,
+ enabledApps, onEnabledApps,
+ 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 [appsBusy, setAppsBusy] = useState(false);
+ const [appsMsg, setAppsMsg] = useState('');
+ const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key);
+
+ /**
+ * Toggle one app in or out of the group's enabled set. Same shape as
+ * `setUploads`: signed, and the checkbox does not move until the node has
+ * said it did it. Refuses to submit an empty set client-side — the node
+ * refuses it too, but there is no reason to make a round trip to learn that.
+ */
+ const toggleApp = useCallback(async (key) => {
+ const next = activeApps.includes(key)
+ ? activeApps.filter(k => k !== key)
+ : [...activeApps, key];
+ if (next.length === 0) {
+ setAppsMsg(t('members.apps_need_one'));
+ return;
+ }
+ const transport = transportRef && transportRef.current;
+ setAppsMsg('');
+ setAppsBusy(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.setAppsEnabled(next, signFn);
+ if (onEnabledApps) onEnabledApps(next);
+ } catch (err) {
+ setAppsMsg(err.message);
+ } finally {
+ setAppsBusy(false);
+ }
+ }, [transportRef, onEnabledApps, activeApps]);
+
+ 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`<p class="page-message">${t('explore.loading')}</p>`;
+
+ const isOwner = Boolean(isAdmin);
+
+ return html`
+ <div class="members-panel">
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+
+ ${/* 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`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.invite_title')}</h3>
+ ${!connected ? html`
+ <p class="settings-hint">${t('group.offline_title')}</p>
+ ` : !operatorPaired ? html`
+ <p class="settings-hint">
+ ${isNodeAdmin ? t('members.invite_needs_pairing')
+ : t('members.invite_ask_operator')}
+ </p>
+ ` : ''}
+ <form onSubmit=${doInvite}>
+ ${inviteCode && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
+ <p class="code-display">${inviteCode.code}</p>
+ <p>${t('members.invite_code_hint')}</p>
+ </div>
+ `}
+ <div class="form-row">
+ <input type="text" placeholder="${t('members.username_placeholder')}"
+ value=${inviteUser} onInput=${e => setInviteUser(e.target.value)}
+ disabled=${!connected || !operatorPaired} required />
+ <button class="admin-btn" type="submit"
+ disabled=${inviting || !connected || !operatorPaired}>
+ ${inviting ? '...' : t('members.invite_btn')}
+ </button>
+ </div>
+ </form>
+ </div>
+ `}
+
+ ${isNodeAdmin && !operatorPaired && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.pair_title')}</h3>
+ <p class="settings-hint">${t('members.pair_hint')}</p>
+ ${pairStatus && html`
+ <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
+ </p>
+ `}
+ <form class="form-row" onSubmit=${doPair}>
+ <input type="text" placeholder="XXXX-XXXX" class="code-input"
+ value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${pairing}>
+ ${pairing ? '...' : t('members.pair_btn')}
+ </button>
+ </form>
+ </div>
+ `}
+
+ ${/* Which group "applications" members see. New ones (Videos, Music,
+ Photos) show up here automatically as they register in apps.js —
+ nothing about this section changes to add one. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.apps_title')}</h3>
+ <p class="settings-hint">${t('members.apps_hint')}</p>
+ <ul class="apps-toggle-list">
+ ${APPS.map(a => html`
+ <li key=${a.key} class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${activeApps.includes(a.key)}
+ disabled=${appsBusy}
+ onChange=${() => toggleApp(a.key)} />
+ ${' '}${t(a.labelKey)}
+ </label>
+ </li>
+ `)}
+ </ul>
+ ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+ </div>
+ `}
+
+ ${/* Roots management (Electron-only, when node is local) */
+ nodeDetected && nodeRoots.length > 0 && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.roots')}</h3>
+ ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
+ <div class="node-roots">
+ ${nodeRoots.map(r => html`
+ <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
+ key=${r.name}>
+ <div class="node-root-info">
+ <span class="node-root-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.upload && html`
+ <span class="node-root-badge">${t('node.upload_root')}</span>`}
+ ${!r.available && html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('node.unavailable')}</span>`}
+ </div>
+ ${nodeRoots.length > 1 && !r.upload && html`
+ <button class="btn btn-small btn-danger"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('DELETE',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_removed'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${t('node.remove_root')}</button>`}
+ </div>
+ `)}
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('POST',
+ '/api/groups/' + groupId + '/roots',
+ { path: chosen.path, name: chosen.name });
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_added'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ </div>
+ </div>
+ `}
+
+ ${/* 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`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${uploadBusy}
+ onClick=${() => setUploads(!memberUpload)}>
+ ${uploadBusy ? '...'
+ : (memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable'))}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
+ </div>
+ `}
+
+ ${/* Upload toggle via loopback when MNP not connected */
+ nodeDetected && !connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${nodeBusy}
+ onClick=${async () => {
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ const newVal = !memberUpload;
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/member-upload',
+ { allowed: newVal });
+ if (onMemberUpload) onMemberUpload(newVal);
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable')}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ </div>
+ `}
+
+ ${/* Delete/leave — node detach first (reversible), then hub delete
+ (irreversible). */ html`
+ <div class="settings-section">
+ <h3 class="settings-heading">
+ ${isOwner ? t('group.delete_group') : t('group.leave')}
+ </h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${isOwner ? t('members.danger_delete_hint')
+ : t('members.danger_leave_hint')}
+ </span>
+ ${isOwner
+ ? html`
+ <button class="admin-btn danger" onClick=${async () => {
+ if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
+ try {
+ // Node detach first (reversible), then hub delete (irreversible)
+ if (nodeDetected && nodeGroupName) {
+ try {
+ await platform.node.call('POST', '/api/groups/detach',
+ { name: nodeGroupName });
+ } catch (detachErr) {
+ if (!confirm(t('settings_node.detach_failed_continue'))) return;
+ }
+ }
+ await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
+ navigate('/');
+ window.location.reload();
+ } catch (err) { setError(err.message); }
+ }}>${t('group.delete_group')}</button>
+ `
+ : html`
+ <button class="admin-btn danger" onClick=${async () => {
+ if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
+ try {
+ await hubFetch('/v1/groups/' + groupId + '/leave',
+ { method: 'POST', token });
+ if (onLeft) onLeft(groupId);
+ } catch (err) { setError(err.message); }
+ }}>${t('group.leave')}</button>
+ `}
+ </div>
+ </div>
+ `}
+
+ ${connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('device.mine_title')}</h3>
+ <p class="settings-hint">${t('device.mine_hint')}</p>
+ ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
+ ${devices.length === 0
+ ? html`<p class="settings-hint">${t('device.mine_empty')}</p>`
+ : html`
+ <ul class="device-list">
+ ${devices.map(d => html`
+ <li class="device-row" key=${d.pk_ed25519}>
+ <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span>
+ <span class="device-meta">
+ ${d.is_this_one && html`
+ <span class="badge">${t('device.this_one')}</span>${' '}
+ `}
+ ${d.pinned_via}${d.label ? ' · ' + d.label : ''}
+ </span>
+ ${!d.is_this_one && devices.length > 1 && html`
+ <button class="admin-btn" onClick=${() => revokeDevice(d)}>
+ ${t('device.revoke')}
+ </button>
+ `}
+ </li>
+ `)}
+ </ul>
+ `}
+ <form onSubmit=${approveDevice} class="settings-subform">
+ <p class="settings-hint">${t('device.approve_hint')}</p>
+ <div class="form-row">
+ <input type="text" placeholder="XXXX-XXXX" class="code-input"
+ value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
+ <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
+ </div>
+ </form>
+ </div>
+ `}
+
+ <div class="settings-section">
+ <h3 class="settings-heading">
+ ${t('group.tab_members')} (${members.length})
+ </h3>
+ <table class="admin-table">
+ <thead>
+ <tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('members.group_role')}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${members.map(m => html`
+ <tr key=${m.user_id}>
+ <td>${m.username}</td>
+ <td>
+ ${m.user_id === adminId
+ ? html`<span class="badge badge-owner">${t('members.owner')}</span>`
+ : html`<span class="badge">${t('members.member')}</span>`
+ }
+ </td>
+ <td class="admin-actions">
+ ${isAdmin && m.user_id !== adminId && html`
+ <button class="admin-btn danger" disabled=${removing === m.user_id}
+ onClick=${() => {
+ if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
+ removeMember(m);
+ }}>
+ ${removing === m.user_id ? '...' : t('members.remove')}
+ </button>
+ `}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${isAdmin && members.length > 1 && html`
+ <p class="settings-hint">${t('members.remove_hint')}</p>
+ `}
+ </div>
+ </div>
+ `;
+}
+
+// ── 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;
+
+
+export { GroupSettingsPanel };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
new file mode 100644
index 0000000..aaf7ddb
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -0,0 +1,269 @@
+import * as platform from './platform.js';
+
+// 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;
+const IDB_NAME = 'meshbay';
+const IDB_VERSION = 1;
+const IDB_STORE = 'group_indexes';
+
+function navigate(path) {
+ window.location.hash = path;
+}
+
+// ── 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, and a
+// one-time pairing/join code the user just typed — consumed by the next
+// connection attempt. Neither is persisted: the bundle key is re-derived (or
+// re-fetched from a node's own backup, see `_loadBundleKey`) each session, and
+// a code is single-use and short-lived. A plain object, not two bare `let`s,
+// so importing modules can update either field without this module handing
+// out a rebindable export.
+const session = { bundleKey: null, 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 {}
+}
+
+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);
+ session.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 any 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
+
+/** App() calls this once, to hear about a renewal that happened in the background. */
+function setAuthChangeListener(fn) {
+ _onAuthChange = fn;
+}
+
+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();
+}
+
+// ── 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();
+}
+
+export {
+ HUB, navigate, session,
+ cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes,
+ _storeBundleKey, _loadBundleKey, _clearKeyDB,
+ loadAuth, saveAuth, setAuth, setAuthChangeListener,
+ tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch,
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
new file mode 100644
index 0000000..4dc24c0
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -0,0 +1,91 @@
+import { html } from './vendor/htm-preact.js';
+
+// ── 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`
+ <svg class="icon ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"
+ fill="none" stroke="currentColor" stroke-width="1.6"
+ stroke-linecap="round" stroke-linejoin="round">
+ ${paths.map((d, i) => html`<path key=${i} d=${d} />`)}
+ </svg>
+ `;
+}
+
+export { Icon };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 1a151e2..59127af 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -85,6 +85,9 @@ export default {
'members.uploads_disable': "Ausschalten",
'members.uploads_enable': "Einschalten",
'members.uploads_hint': "Gilt für alle außer Ihnen. Der Knoten lehnt den Upload selbst ab — es geht nicht darum, eine Schaltfläche zu verbergen.",
+ 'members.apps_title': "Anwendungen",
+ 'members.apps_hint': "Welche Teile dieser Gruppe Mitglieder sehen. Mindestens eine muss aktiviert bleiben.",
+ 'members.apps_need_one': "Mindestens eine Anwendung muss aktiviert bleiben.",
'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.",
'group.filter': 'Dateien filtern …',
'group.col_name': 'Name',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index dfd400b..58590b1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -86,6 +86,9 @@ export default {
'members.uploads_disable': "Turn off",
'members.uploads_enable': "Turn on",
'members.uploads_hint': "Applies to everyone but you. The node refuses the upload itself, so this is not a matter of hiding a button.",
+ 'members.apps_title': "Applications",
+ 'members.apps_hint': "Which parts of this group members see. At least one must stay on.",
+ 'members.apps_need_one': "At least one application must stay enabled.",
'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.",
'group.filter': 'Filter files...',
'group.col_name': 'Name',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 498fc38..75a67b6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -83,6 +83,9 @@ export default {
'members.uploads_disable': "Desactivar",
'members.uploads_enable': "Activar",
'members.uploads_hint': "Se aplica a todos menos a usted. El nodo rechaza la subida por sí mismo: no se trata de ocultar un botón.",
+ 'members.apps_title': "Aplicaciones",
+ 'members.apps_hint': "Qué partes de este grupo ven los miembros. Al menos una debe permanecer activada.",
+ 'members.apps_need_one': "Al menos una aplicación debe permanecer activada.",
'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.",
'group.filter': 'Filtrar archivos...',
'group.col_name': 'Nombre',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 9e21135..d60c5d0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -84,6 +84,9 @@ export default {
'members.uploads_disable': "Désactiver",
'members.uploads_enable': "Activer",
'members.uploads_hint': "S’applique à tout le monde sauf vous. C’est le nœud qui refuse l’envoi : il ne s’agit pas de masquer un bouton.",
+ 'members.apps_title': "Applications",
+ 'members.apps_hint': "Les parties de ce groupe visibles par les membres. Au moins une doit rester activée.",
+ 'members.apps_need_one': "Au moins une application doit rester activée.",
'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.",
'group.filter': 'Filtrer les fichiers...',
'group.col_name': 'Nom',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 7e2b100..b65e2ce 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -84,6 +84,9 @@ export default {
'members.uploads_disable': "Disattiva",
'members.uploads_enable': "Attiva",
'members.uploads_hint': "Vale per tutti tranne te. È il nodo a rifiutare il caricamento: non si tratta di nascondere un pulsante.",
+ 'members.apps_title': "Applicazioni",
+ 'members.apps_hint': "Quali parti di questo gruppo vedono i membri. Almeno una deve restare attiva.",
+ 'members.apps_need_one': "Almeno un'applicazione deve restare attiva.",
'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.",
'group.filter': 'Filtra i file...',
'group.col_name': 'Nome',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index ee55fc2..e965590 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -82,6 +82,9 @@ export default {
'members.uploads_disable': "無効にする",
'members.uploads_enable': "有効にする",
'members.uploads_hint': "あなた以外の全員に適用されます。ノード自身がアップロードを拒否するため、ボタンを隠すだけの話ではありません。",
+ 'members.apps_title': "アプリケーション",
+ 'members.apps_hint': "メンバーに表示されるこのグループの機能です。少なくとも1つは有効のままにしてください。",
+ 'members.apps_need_one': "少なくとも1つのアプリケーションを有効にしておく必要があります。",
'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。",
'group.filter': 'ファイルを絞り込み…',
'group.col_name': '名前',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 986d653..b37be08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -85,6 +85,9 @@ export default {
'members.uploads_disable': "Uitschakelen",
'members.uploads_enable': "Inschakelen",
'members.uploads_hint': "Geldt voor iedereen behalve u. De node weigert de upload zelf — het gaat niet om het verbergen van een knop.",
+ 'members.apps_title': "Toepassingen",
+ 'members.apps_hint': "Welke onderdelen van deze groep leden zien. Minstens één moet ingeschakeld blijven.",
+ 'members.apps_need_one': "Er moet minstens één toepassing ingeschakeld blijven.",
'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.",
'group.filter': 'Bestanden filteren...',
'group.col_name': 'Naam',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index ca3f60c..b445f79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -89,6 +89,9 @@ export default {
'members.uploads_disable': "Wyłącz",
'members.uploads_enable': "Włącz",
'members.uploads_hint': "Dotyczy wszystkich poza Tobą. To węzeł odrzuca przesłanie — nie chodzi o ukrycie przycisku.",
+ 'members.apps_title': "Aplikacje",
+ 'members.apps_hint': "Które części tej grupy widzą członkowie. Co najmniej jedna musi pozostać włączona.",
+ 'members.apps_need_one': "Co najmniej jedna aplikacja musi pozostać włączona.",
'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.",
'group.filter': 'Filtruj pliki...',
'group.col_name': 'Nazwa',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 8231daa..c3220ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -85,6 +85,9 @@ export default {
'members.uploads_disable': "Desativar",
'members.uploads_enable': "Ativar",
'members.uploads_hint': "Vale para todos menos você. O nó recusa o envio por conta própria: não se trata de esconder um botão.",
+ 'members.apps_title': "Aplicativos",
+ 'members.apps_hint': "Quais partes deste grupo os membros veem. Pelo menos um deve permanecer ativado.",
+ 'members.apps_need_one': "Pelo menos um aplicativo deve permanecer ativado.",
'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.",
'group.filter': 'Filtrar arquivos...',
'group.col_name': 'Nome',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 48122d3..0151c4b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -82,6 +82,9 @@ export default {
'members.uploads_disable': "关闭",
'members.uploads_enable': "开启",
'members.uploads_hint': "适用于除您之外的所有人。节点自身会拒绝上传,并非只是隐藏按钮。",
+ 'members.apps_title': "应用",
+ 'members.apps_hint': "成员可见的群组功能。至少需保留一个启用。",
+ 'members.apps_need_one': "至少需要保留一个已启用的应用。",
'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。",
'group.filter': '筛选文件…',
'group.col_name': '名称',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 2cdf15d..da98253 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -104,6 +104,7 @@ class MeshBayTransport {
set onStreamError(fn) { this._onStreamError = fn; }
set onIndexSync(fn) { this._onIndexSync = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
+ set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -613,6 +614,26 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * Turn a group "application" (Chat, Files, ...) on or off for everyone.
+ *
+ * Takes the whole set in one signed message rather than one op per app, so
+ * ticking several boxes in Settings costs one signature. `apps` is sorted
+ * and joined the same way on the node before it is shown for signing —
+ * `_authorizeAdminOp` below checks the two match.
+ */
+ async setAppsEnabled(apps, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'apps_enabled', v: '0.1', apps,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'apps_enabled', [...apps].sort().join(','), signFn);
+ }
+ return msg;
+ }
+
async revokeMember(userId, signFn) {
const msg = await this._sendAndWait({
type: 'member_revoke', v: '0.1', user_id: userId,
@@ -1224,6 +1245,12 @@ class MeshBayTransport {
this._onUploadPolicy(Boolean(msg.allowed));
}
+ // Same shape: the operator changed which apps are shown, and everyone
+ // connected hears about it without reconnecting.
+ if (msg.type === 'apps_enabled_ack' && this._onAppsEnabled) {
+ this._onAppsEnabled(msg.apps || []);
+ }
+
if (msg.type === 'index_sync' && msg.entries) {
if (this._onIndexSync) this._onIndexSync(msg);
const oldest = this._pending.entries().next();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
new file mode 100644
index 0000000..42842d2
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -0,0 +1,992 @@
+import {
+ html, useState, useEffect, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize } from './file-utils.js';
+import { loadAuth } from './hub-client.js';
+import * as platform from './platform.js';
+
+// 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;
+
+function _mseSupported(codec) {
+ if (!window.MediaSource) return false;
+ const mime = `video/mp4; codecs="${codec}"`;
+ return MediaSource.isTypeSupported(mime);
+}
+
+/** Seconds as h:mm:ss, or m:ss under an hour. */
+function formatClock(seconds) {
+ const s = Math.max(0, Math.floor(seconds || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = String(s % 60).padStart(2, '0');
+ return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
+}
+
+/**
+ * Where *this account on this device* last left off in a given file.
+ *
+ * localStorage rather than the node: it needs no protocol, no storage anyone
+ * else has to keep, and nothing new learns what you watch. The cost is that
+ * the position does not follow you from the laptop to the phone.
+ *
+ * The account has to be in the key. Without it the position is per *device* —
+ * so a second person signing in on the same machine was offered "resume where
+ * you left off" in a film they had never opened, which is both wrong and a
+ * small disclosure of what someone else watches. Found by signing in with a
+ * fresh account and being offered a resume point.
+ */
+function resumeKey(fileId) {
+ const auth = loadAuth();
+ return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null;
+}
+
+function readResumePosition(fileId) {
+ try {
+ const key = resumeKey(fileId);
+ if (!key) return 0;
+ const raw = localStorage.getItem(key);
+ const at = raw ? parseFloat(raw) : 0;
+ return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0;
+ } catch {
+ return 0; // private browsing, or storage disabled
+ }
+}
+
+function writeResumePosition(fileId, at, duration) {
+ try {
+ const key = resumeKey(fileId);
+ if (!key) return;
+ if (!Number.isFinite(at) || at < RESUME_MIN_S
+ || (duration && at > duration * RESUME_MAX_FRACTION)) {
+ localStorage.removeItem(key);
+ return;
+ }
+ localStorage.setItem(key, String(Math.floor(at)));
+ } catch { /* nothing to be done, and nothing worth failing over */ }
+}
+
+/**
+ * Drop the positions written before they were scoped to an account.
+ *
+ * Re-keying them is not possible — there is no record of whose they were, and
+ * guessing would hand them to whoever signs in next, which is the bug. They go.
+ */
+function purgeUnscopedResumePositions() {
+ try {
+ const stale = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current.
+ if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) {
+ stale.push(key);
+ }
+ }
+ stale.forEach((key) => localStorage.removeItem(key));
+ } catch { /* storage disabled: nothing was written either */ }
+}
+
+purgeUnscopedResumePositions();
+
+function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
+ const [dlBusy, setDlBusy] = useState(false);
+ const [phase, setPhase] = useState('loading');
+ const [error, setError] = useState('');
+ const videoRef = useRef(null);
+ const msRef = useRef(null);
+ const sbRef = useRef(null);
+ const blobUrlRef = useRef(null);
+ const queueRef = useRef([]);
+ const appendingRef = useRef(false);
+ const endedRef = useRef(false);
+ const durationRef = useRef(0);
+ // Segments the node is allowed to have in flight but has not sent yet, and
+ // when we last said anything to it at all.
+ const outstandingRef = useRef(0);
+ const lastPokeRef = useRef(0);
+ // Diagnostics reported to the node: how many appends the browser refused for
+ // want of room, and whether the element itself says it is starved.
+ const quotaRef = useRef(0);
+ const stalledRef = useRef(false);
+ // Seeking. `awaitingInit` is true from the moment we ask the node to restart
+ // somewhere else until its new `stream_init` arrives: the channel is ordered,
+ // so everything in between belongs to the stream we just abandoned and would
+ // otherwise be appended on top of the new one. `seekTarget` is where to put
+ // the playhead once the buffer actually covers it.
+ const awaitingInitRef = useRef(false);
+ const seekTargetRef = useRef(null);
+ const seekTimerRef = useRef(null);
+ // The seek is built inside the effect, where the transport and `cancelled`
+ // live; the render needs to reach it for "start from the beginning".
+ const requestSeekRef = useRef(null);
+ const [resumedFrom, setResumedFrom] = useState(0);
+ const [castActive, setCastActive] = useState(false);
+ const [castUrl, setCastUrl] = useState(null);
+ const [castPickerOpen, setCastPickerOpen] = useState(false);
+ const [castDevices, setCastDevices] = useState([]);
+ const [castScanning, setCastScanning] = useState(false);
+ const [castDeviceName, setCastDeviceName] = useState(null);
+ const castActiveRef = useRef(false);
+ const castCodecRef = useRef(null);
+ const initSegmentRef = useRef(null);
+ const castRestartPendingRef = useRef(false);
+ const castDeviceRef = useRef(null);
+ const castRestartGenRef = useRef(0);
+ const landingPlayheadRef = useRef(false);
+
+ /**
+ * The buffered range the playhead is actually in, or null.
+ *
+ * Seeking makes the buffer discontinuous, and "the last range" stops meaning
+ * "the one being watched" the moment there is more than one: measuring the
+ * read-ahead against a range on the far side of a gap reports a full buffer
+ * while the player starves.
+ */
+ const currentRange = useCallback(() => {
+ const sb = sbRef.current;
+ const v = videoRef.current;
+ if (!sb || !v) return null;
+ try {
+ const t = v.currentTime;
+ for (let i = 0; i < sb.buffered.length; i++) {
+ // Half a second of slack: the playhead sits exactly on a boundary
+ // often enough, and a strict test there reports nothing buffered.
+ if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) {
+ return [sb.buffered.start(i), sb.buffered.end(i)];
+ }
+ }
+ } catch { /* the SourceBuffer went away under us */ }
+ return null;
+ }, []);
+
+ /**
+ * Drop what has already been watched.
+ *
+ * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes
+ * and refuse the append that goes past. Keeping a minute behind the playhead
+ * is enough for a small seek backwards and bounded for a three-hour film.
+ */
+ const evictBehind = useCallback(() => {
+ const sb = sbRef.current;
+ const v = videoRef.current;
+ if (!sb || !v || sb.updating || !sb.buffered.length) return false;
+ const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
+ // The range being watched, not the first one: after a seek backwards the
+ // first range is somewhere else entirely, and removing from its start to
+ // just behind the playhead would take out everything in between —
+ // including what is playing.
+ const range = currentRange();
+ const start = range ? range[0] : sb.buffered.start(0);
+ if (keepFrom - start < 10) return false;
+ try {
+ sb.remove(start, keepFrom);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [currentRange]);
+
+ /** Seconds of film held past the playhead. */
+ const bufferedAhead = useCallback(() => {
+ const v = videoRef.current;
+ const range = currentRange();
+ if (!v || !range) return 0;
+ return Math.max(0, range[1] - v.currentTime);
+ }, [currentRange]);
+
+ const flushQueue = useCallback(() => {
+ const sb = sbRef.current;
+ if (!sb || appendingRef.current || sb.updating) return;
+ if (queueRef.current.length === 0) {
+ if (endedRef.current && msRef.current?.readyState === 'open') {
+ try { msRef.current.endOfStream(); } catch {}
+ }
+ return;
+ }
+ appendingRef.current = true;
+ const chunk = queueRef.current[0];
+ try {
+ sb.appendBuffer(chunk);
+ queueRef.current.shift();
+ } catch (e) {
+ appendingRef.current = false;
+ if (e.name === 'QuotaExceededError') {
+ quotaRef.current += 1;
+ // The segment stays at the head of the queue and is tried again once
+ // there is room. Dropping it — which is what this used to do — leaves a
+ // hole in the middle of the film and no error anywhere.
+ if (!evictBehind()) {
+ console.warn('[MSE] buffer full and nothing to evict yet');
+ }
+ return;
+ }
+ queueRef.current.shift();
+ console.error('[MSE] appendBuffer error:', e);
+ }
+ }, [evictBehind]);
+
+ /**
+ * Decide whether the node may send more, and keep the pipeline moving.
+ *
+ * This is the only place credit is granted, and the only thing that can
+ * restart a pipeline the buffer ceiling has stopped. That second job is why
+ * it exists: an append refused for quota fires no `updateend`, so it grants
+ * no credit, so the node sends nothing, so no segment arrives to call
+ * `flushQueue` again. Every wakeup the append path had was downstream of the
+ * append that just failed — the player deadlocked against itself and sat on
+ * "buffering" for good, which is what a 500 MB film did at around 100 MB.
+ *
+ * So the clock drives this, not the data.
+ */
+ const pump = useCallback(() => {
+ if (awaitingInitRef.current) return;
+ const transport = transportRef.current;
+ evictBehind();
+ flushQueue();
+ if (endedRef.current && queueRef.current.length === 0) return;
+ if (!transport || !transport.connected) return;
+
+ if (bufferedAhead() > BUFFER_AHEAD_S
+ || queueRef.current.length > QUEUE_HIGH_WATER) {
+ // Far enough ahead. Grant nothing, but do not go silent: two minutes of
+ // silence is how the node decides nobody is watching, and pausing a film
+ // for two minutes is an ordinary thing to do.
+ const now = Date.now();
+ if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) {
+ lastPokeRef.current = now;
+ transport.grantStreamCredit(0);
+ }
+ return;
+ }
+
+ // Top the window back up to what is allowed in flight, rather than paying
+ // off everything owed at once. Called on every arriving segment as well as
+ // on the clock, so credit trickles out as room appears instead of being
+ // released in one gulp when the buffer finally drains.
+ const room = STREAM_WINDOW - outstandingRef.current;
+ if (room > 0) {
+ outstandingRef.current += room;
+ lastPokeRef.current = Date.now();
+ transport.grantStreamCredit(room);
+ }
+ }, [evictBehind, flushQueue, bufferedAhead]);
+
+ useEffect(() => {
+ let cancelled = false;
+ // Reset here, not in the teardown of the run before: switching video while
+ // an append was in flight left `appendingRef` true, and flushQueue bails
+ // out on it. The new SourceBuffer then never appended anything, so no
+ // `updateend` ever cleared the flag, no credit went back to the node, and
+ // the player sat on "buffering" for good. `endedRef` surviving is the same
+ // shape of bug — the next stream would call endOfStream() the first time
+ // its queue ran dry and truncate the film.
+ appendingRef.current = false;
+ endedRef.current = false;
+ queueRef.current = [];
+ outstandingRef.current = 0;
+ lastPokeRef.current = Date.now();
+ quotaRef.current = 0;
+ stalledRef.current = false;
+ // The same shape again, and the seek refs are worse than the others.
+ // Switching film while a seek was in flight leaves `awaitingInit` true,
+ // and only reinitAt() ever lowers it — which the next film does not go
+ // through, because it builds a new SourceBuffer. Every segment of the new
+ // film is then dropped as though it belonged to the one we left, for good.
+ // A stale `seekTarget` is milder: the new film jumps to a position from
+ // the old one the moment that much is buffered.
+ awaitingInitRef.current = false;
+ seekTargetRef.current = null;
+ clearTimeout(seekTimerRef.current);
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) {
+ setError(t('video.err_transport'));
+ setPhase('error');
+ return;
+ }
+
+ const onStarved = () => { stalledRef.current = true; pump(); };
+ const onFed = () => { stalledRef.current = false; };
+
+ /** The buffered ranges, short enough for a log line. */
+ const describeRanges = () => {
+ const sb = sbRef.current;
+ if (!sb) return '(no buffer)';
+ try {
+ let s = '';
+ for (let i = 0; i < sb.buffered.length; i++) {
+ s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
+ }
+ return s.trim() || '(empty)';
+ } catch {
+ return '?';
+ }
+ };
+
+ /**
+ * Ask the node to restart the film somewhere else.
+ *
+ * Debounced, because dragging the scrubber fires `seeking` continuously and
+ * each request kills an ffmpeg and spawns another. Only the position the
+ * finger stops on is worth acting on.
+ */
+ const requestSeek = (target) => {
+ clearTimeout(seekTimerRef.current);
+ seekTimerRef.current = setTimeout(() => {
+ const t = transportRef.current;
+ if (cancelled || !t || !t.connected) return;
+ // Everything arriving from here until the new `stream_init` belongs to
+ // the stream being abandoned. The channel is ordered, so this flag is
+ // enough to tell them apart without a sequence number in the protocol.
+ // Rare enough to report every time, and the node logs it at INFO. A
+ // seek nobody asked for is the kind of thing only this line can show:
+ // from the node's side it is indistinguishable from a viewer dragging
+ // the scrubber.
+ t.sendStreamDiag({
+ event: 'seek', target: +target.toFixed(1),
+ t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
+ ready: videoRef.current ? videoRef.current.readyState : null,
+ offset: sbRef.current ? sbRef.current.timestampOffset : null,
+ ranges: describeRanges(),
+ });
+ awaitingInitRef.current = true;
+ seekTargetRef.current = target;
+ outstandingRef.current = STREAM_WINDOW;
+ setPhase('loading');
+ console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW);
+ t.requestStream(entry.id, STREAM_WINDOW, target);
+ }, SEEK_DEBOUNCE_MS);
+ };
+ requestSeekRef.current = requestSeek;
+
+ /**
+ * Move the playhead onto a seek once the data for it has arrived.
+ *
+ * Setting `currentTime` into a region that is not buffered yet leaves the
+ * element waiting with nothing to show, and on a seek backwards it would
+ * be overwritten by the playhead the browser restores. So the position is
+ * remembered and applied on the first append that actually covers it.
+ */
+ const landPlayhead = () => {
+ const target = seekTargetRef.current;
+ const v = videoRef.current, sb = sbRef.current;
+ if (target === null || !v || !sb) return;
+ try {
+ for (let i = 0; i < sb.buffered.length; i++) {
+ const a = sb.buffered.start(i), b = sb.buffered.end(i);
+ if (target >= a - 1 && target < b) {
+ seekTargetRef.current = null;
+ // ffmpeg lands on the keyframe at or before what we asked for, so
+ // the range can begin slightly later than the target; never seek
+ // behind what is actually there.
+ if (Math.abs(v.currentTime - target) > 0.5) {
+ landingPlayheadRef.current = true;
+ v.currentTime = Math.max(target, a);
+ }
+ v.play().catch(() => {});
+ return;
+ }
+ }
+ } catch { /* the SourceBuffer went away */ }
+ };
+
+ /** Wait for whatever the SourceBuffer is doing to finish. */
+ const settled = (sb) => new Promise((resolve) => {
+ if (!sb.updating) return resolve();
+ sb.addEventListener('updateend', resolve, { once: true });
+ });
+
+ /**
+ * Put the SourceBuffer back to an empty state that starts at `start`.
+ *
+ * Everything buffered is dropped rather than kept alongside the new
+ * material. A discontinuous buffer is legal and every piece of code that
+ * reads `buffered` then has to reason about which range it means — the
+ * eviction, the read-ahead, the seek test — for the sake of a few
+ * megabytes of film the viewer has just navigated away from.
+ *
+ * `abort()` first: ffmpeg was killed mid-fragment, so the parser is
+ * holding half of one, and appending the next stream's header on top of
+ * that is a decode error.
+ */
+ const reinitAt = async (start) => {
+ const sb = sbRef.current;
+ if (!sb) return;
+ try { sb.abort(); } catch { /* not in a state that needs it */ }
+ await settled(sb);
+ try {
+ sb.remove(0, Infinity);
+ await settled(sb);
+ } catch { /* nothing buffered */ }
+ // ffmpeg restarts its timestamps at zero however far in we asked it to
+ // seek, so this is what puts the fragments back on the film's timeline.
+ try { sb.timestampOffset = start; } catch { /* older browsers */ }
+ const tr = transportRef.current;
+ if (tr) {
+ tr.sendStreamDiag({
+ event: 'reinit', target: start, offset: sb.timestampOffset,
+ t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
+ ranges: describeRanges(),
+ });
+ }
+ queueRef.current = [];
+ appendingRef.current = false;
+ endedRef.current = false;
+ quotaRef.current = 0;
+ awaitingInitRef.current = false;
+ seekTargetRef.current = start;
+ console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length);
+ setPhase('streaming');
+ pump();
+ };
+
+ const onSeeking = () => {
+ if (landingPlayheadRef.current) {
+ landingPlayheadRef.current = false;
+ return;
+ }
+ const v = videoRef.current;
+ if (!v || cancelled) return;
+ const target = v.currentTime;
+ // Inside what is buffered, the browser handles it and the node need not
+ // hear about it at all — unless a cast is active, because the relay
+ // cannot seek within its HTTP stream and must be restarted.
+ if (!castActiveRef.current) {
+ const sb = sbRef.current;
+ if (sb) {
+ try {
+ for (let i = 0; i < sb.buffered.length; i++) {
+ if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) {
+ return;
+ }
+ }
+ } catch { /* fall through and ask the node */ }
+ }
+ }
+ requestSeek(target);
+ };
+
+ const startStream = async () => {
+ transport.onStreamError = (msg) => {
+ if (cancelled) return;
+ // Say what the node said. Sitting on "buffering" with the reason
+ // already delivered is the worst of both.
+ setError(msg.detail || t('video.err_transport'));
+ setPhase('error');
+ };
+
+ transport.onStreamInit = (msg) => {
+ if (cancelled) return;
+ if (msg.file_id && msg.file_id !== entry.id) return;
+ const mime = `video/mp4; codecs="${msg.codec}"`;
+ castCodecRef.current = msg.codec;
+
+ if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
+ setError(t('video.err_mse', { codec: msg.codec }));
+ setPhase('error');
+ return;
+ }
+
+ durationRef.current = msg.duration || 0;
+
+ // A second init on a live SourceBuffer is a seek landing, not a new
+ // film. Reuse what is there: rebuilding the MediaSource would reset the
+ // element's src, blank the picture and throw away the duration the
+ // scrubber is drawn from.
+ if (sbRef.current && msRef.current
+ && msRef.current.readyState === 'open') {
+ console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current);
+ initSegmentRef.current = null;
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.stop().catch(() => {});
+ castRestartPendingRef.current = true;
+ }
+ reinitAt(msg.start || 0).catch(() => {
+ setError(t('video.err_transport'));
+ setPhase('error');
+ });
+ return;
+ }
+
+ // If we reach here during a seek (readyState was 'ended' after the
+ // previous stream finished), the seek-landing path above could not run.
+ // A fresh MediaSource is needed, but the seek state must still be reset
+ // or awaitingInit stays true and every segment is dropped forever.
+ awaitingInitRef.current = false;
+ endedRef.current = false;
+ appendingRef.current = false;
+ queueRef.current = [];
+ sbRef.current = null;
+ initSegmentRef.current = null;
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.stop().catch(() => {});
+ castRestartPendingRef.current = true;
+ }
+
+ const ms = new MediaSource();
+ msRef.current = ms;
+ if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
+ const url = URL.createObjectURL(ms);
+ blobUrlRef.current = url;
+
+ ms.addEventListener('sourceopen', () => {
+ if (cancelled) return;
+ if (durationRef.current > 0) {
+ ms.duration = durationRef.current;
+ }
+ const sb = ms.addSourceBuffer(mime);
+ sbRef.current = sb;
+ // 'segments', not 'sequence': the fragments must land where they
+ // belong on the film's timeline rather than one after another, or a
+ // stream that started at 40 minutes would be buffered at zero and
+ // the scrubber would lie about everything.
+ sb.mode = 'segments';
+ try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ }
+ if (msg.start) seekTargetRef.current = msg.start;
+ transport.sendStreamDiag({
+ event: 'first-init', target: msg.start || 0,
+ offset: sb.timestampOffset, duration: durationRef.current,
+ t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
+ });
+ sb.addEventListener('updateend', () => {
+ // No credit is granted here, deliberately. Appending is not the
+ // same question as having room, and tying the two meant `remove()`
+ // — which fires this event too — paid the node for the player's own
+ // evictions. What may be in flight is decided from the buffer, in
+ // pump(), and nowhere else.
+ appendingRef.current = false;
+ landPlayhead();
+ pump();
+ });
+ setPhase('streaming');
+ flushQueue();
+ });
+
+ if (videoRef.current) {
+ videoRef.current.src = url;
+ videoRef.current.addEventListener('seeking', onSeeking);
+ videoRef.current.addEventListener('timeupdate', pump);
+ // The element's own verdict. "buffering" on screen is this, and it
+ // is the one thing the node cannot infer from a stream it is feeding.
+ videoRef.current.addEventListener('waiting', onStarved);
+ videoRef.current.addEventListener('stalled', onStarved);
+ videoRef.current.addEventListener('playing', onFed);
+ videoRef.current.addEventListener('canplay', onFed);
+ }
+ };
+
+ transport.onStreamData = async (msg) => {
+ if (cancelled) return;
+ // A segment arrived, so it is no longer in flight — whatever we go on
+ // to do with it. This has to come before every early return below, and
+ // it did not: skipping the count for segments we discard leaks a slot
+ // out of the window each time, and the window never grows back.
+ //
+ // `reinitAt` is asynchronous — it waits for two `updateend` events —
+ // and a seek's first segments arrive during that gap and are dropped
+ // by the flag below. Lose all eight and the player believes a full
+ // window is in flight, grants nothing ever again, and the node waits
+ // for credit that cannot come. A race, which is why the same seek
+ // worked twice and hung on the third.
+ outstandingRef.current = Math.max(0, outstandingRef.current - 1);
+ if (awaitingInitRef.current) {
+ console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
+ }
+ // Between asking for a seek and its `stream_init`, everything on the
+ // channel is the film we just left. Same file, so `file_id` cannot
+ // tell them apart — ordering can.
+ if (awaitingInitRef.current) return;
+ // Late segments from the stream we just left. The DataChannel is
+ // ordered, so they arrive before the new stream's first segment and
+ // would otherwise be decrypted against the wrong file — which fails,
+ // loudly, in the console, for something that is simply not ours.
+ if (msg.file_id && msg.file_id !== entry.id) return;
+ try {
+ const plaintext = await window.MeshBayCrypto.decryptChunkBin(
+ gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
+ if (initSegmentRef.current === null) {
+ initSegmentRef.current = plaintext;
+ if (castRestartPendingRef.current && castActiveRef.current
+ && platform.cast.available) {
+ castRestartPendingRef.current = false;
+ const gen = ++castRestartGenRef.current;
+ const device = castDeviceRef.current;
+ platform.cast.start({
+ codec: castCodecRef.current,
+ initSegment: plaintext,
+ }).then(async (result) => {
+ if (castRestartGenRef.current !== gen) return;
+ if (!result) return;
+ setCastUrl(result.url);
+ const status = await platform.cast.status();
+ if (status && status.chromecast && status.chromecast.connected) {
+ await platform.cast.chromecastReload({ mediaUrl: result.url });
+ } else if (device) {
+ await platform.cast.chromecastConnect({
+ deviceId: device.id, mediaUrl: result.url,
+ });
+ setCastDeviceName(device.name);
+ } else {
+ navigator.clipboard.writeText(result.url).catch(() => {});
+ }
+ }).catch((err) => {
+ if (castRestartGenRef.current !== gen) return;
+ console.error('[cast] restart failed:', err);
+ platform.cast.stop().catch(() => {});
+ setCastActive(false); castActiveRef.current = false;
+ setCastUrl(null); setCastDeviceName(null);
+ });
+ }
+ }
+ if (castActiveRef.current && !castRestartPendingRef.current
+ && platform.cast.available) {
+ platform.cast.push(plaintext).catch(() => {});
+ }
+ queueRef.current.push(plaintext);
+ // pump(), not flushQueue(): arriving data is the moment to top the
+ // window back up, and that is what keeps the stream continuous.
+ pump();
+ } catch (e) {
+ console.error('[MSE] decrypt error:', e);
+ }
+ };
+
+ transport.onStreamEnd = (msg) => {
+ if (cancelled) return;
+ // The end of the previous film is not the end of this one.
+ if (msg && msg.file_id && msg.file_id !== entry.id) return;
+ // Nor is the end of the stream we abandoned by seeking: taking it
+ // would call endOfStream() and truncate the film at the seek point.
+ if (awaitingInitRef.current) return;
+ endedRef.current = true;
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.finish().catch(() => {});
+ }
+ flushQueue();
+ };
+
+ // The opening window, and the count that tracks it. Asking for more here
+ // than pump() maintains would leave the node holding credit this side
+ // does not know about, which is the whole window's worth of overshoot on
+ // the very first breath of the stream.
+ outstandingRef.current = STREAM_WINDOW;
+ const resumeAt = readResumePosition(entry.id);
+ if (resumeAt) setResumedFrom(resumeAt);
+ transport.requestStream(entry.id, STREAM_WINDOW, resumeAt);
+ };
+
+ // Closing the tab, or backgrounding it on a phone, never runs a React
+ // cleanup — so the node hears nothing and keeps transcoding. `pagehide`
+ // fires in both cases and is the one event mobile browsers honour on the
+ // way out; `visibilitychange` covers switching apps. The node stops the
+ // stream by itself when the connection drops, but that costs a round of
+ // detection, and this message is a single datagram already in flight.
+ const leave = (why) => {
+ const t = transportRef.current;
+ console.log('[MeshBay] stopStream:', why);
+ if (t && t.connected) t.stopStream();
+ };
+ const onPageHide = () => leave('pagehide');
+ // NOT wired to stopStream. Android fires visibilitychange when a video goes
+ // fullscreen, so cutting the stream here killed the film the moment it was
+ // watched properly. Logged only, until that is confirmed or ruled out.
+ const onVisibility = () => {
+ console.log('[MeshBay] visibilitychange:', document.visibilityState);
+ };
+ window.addEventListener('pagehide', onPageHide);
+ document.addEventListener('visibilitychange', onVisibility);
+
+ // `timeupdate` is silent while the film is paused, and the append path
+ // cannot wake itself once the ceiling has refused a segment. This is the
+ // clock that guarantees something is still driving the pipeline.
+ const pumpTimer = setInterval(pump, 1000);
+
+ // What the player sees, into the node's log. A hang on a phone shows the
+ // node feeding a stream quite happily; the half that says otherwise is in
+ // here, and there is no console to read it from.
+ const diagTimer = setInterval(() => {
+ const v = videoRef.current, sb = sbRef.current;
+ const t = transportRef.current;
+ if (!t || !v) return;
+ // Cheap, and the only thing that makes "resume where I stopped" work
+ // when the tab is closed rather than the player.
+ if (!v.paused) {
+ writeResumePosition(entry.id, v.currentTime, durationRef.current);
+ }
+ let ranges = '';
+ try {
+ for (let i = 0; sb && i < sb.buffered.length; i++) {
+ ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
+ }
+ } catch { ranges = '?'; }
+ t.sendStreamDiag({
+ t: +v.currentTime.toFixed(1),
+ ahead: +bufferedAhead().toFixed(1),
+ ranges: ranges.trim(),
+ ready: v.readyState, // 0 = nothing, 4 = enough to play through
+ paused: v.paused,
+ stalled: stalledRef.current,
+ q: queueRef.current.length,
+ inflight: outstandingRef.current,
+ appending: appendingRef.current,
+ updating: sb ? sb.updating : null,
+ quota: quotaRef.current,
+ ms: msRef.current ? msRef.current.readyState : null,
+ err: v.error ? `${v.error.code}:${v.error.message}` : null,
+ });
+ }, 5000);
+
+ startStream().catch(err => {
+ if (!cancelled) { setError(err.message); setPhase('error'); }
+ });
+
+ return () => {
+ cancelled = true;
+ clearInterval(pumpTimer);
+ clearInterval(diagTimer);
+ clearTimeout(seekTimerRef.current);
+ // Closing the player is the commonest way to stop watching, so this is
+ // the write that matters most.
+ if (videoRef.current) {
+ writeResumePosition(entry.id, videoRef.current.currentTime,
+ durationRef.current);
+ }
+ if (castActiveRef.current && platform.cast.available) {
+ platform.cast.chromecastDisconnect().catch(() => {});
+ platform.cast.stop().catch(() => {});
+ castActiveRef.current = false;
+ }
+ window.removeEventListener('pagehide', onPageHide);
+ document.removeEventListener('visibilitychange', onVisibility);
+ if (videoRef.current) {
+ videoRef.current.removeEventListener('seeking', onSeeking);
+ videoRef.current.removeEventListener('timeupdate', pump);
+ videoRef.current.removeEventListener('waiting', onStarved);
+ videoRef.current.removeEventListener('stalled', onStarved);
+ videoRef.current.removeEventListener('playing', onFed);
+ videoRef.current.removeEventListener('canplay', onFed);
+ }
+ if (transport) {
+ // Tell the node first: dropping the handlers only makes us deaf, and a
+ // stream nobody is listening to still occupies a transcode slot.
+ transport.stopStream();
+ transport.onStreamInit = null;
+ transport.onStreamData = null;
+ transport.onStreamEnd = null;
+ transport.onStreamError = null;
+ }
+ // The queue can hold several megabytes of decrypted video.
+ queueRef.current = [];
+ const ms = msRef.current;
+ if (ms && ms.readyState === 'open') {
+ try { ms.endOfStream(); } catch { /* already ended */ }
+ }
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current);
+ blobUrlRef.current = null;
+ }
+ sbRef.current = null;
+ msRef.current = null;
+ };
+ }, [entry, flushQueue, pump]);
+
+ useEffect(() => {
+ if (phase === 'streaming' && videoRef.current) {
+ videoRef.current.play().catch(() => {});
+ }
+ }, [phase]);
+
+ useEffect(() => {
+ return () => {
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current);
+ blobUrlRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-top-bar">
+ <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${platform.capabilities.lanCast && html`
+ <div class="cast-wrapper" style="position:relative">
+ <button class="video-close ${castActive ? 'cast-active' : ''}"
+ onClick=${async () => {
+ if (castActive) {
+ await platform.cast.chromecastDisconnect().catch(() => {});
+ await platform.cast.stop();
+ setCastActive(false); castActiveRef.current = false;
+ setCastUrl(null);
+ setCastDeviceName(null);
+ castDeviceRef.current = null;
+ } else if (castCodecRef.current && initSegmentRef.current) {
+ if (castPickerOpen) {
+ setCastPickerOpen(false);
+ } else {
+ setCastPickerOpen(true);
+ setCastScanning(true);
+ setCastDevices([]);
+ platform.cast.discover().then((devices) => {
+ setCastDevices(devices || []);
+ setCastScanning(false);
+ }).catch(() => setCastScanning(false));
+ }
+ }
+ }}
+ title="${castActive ? t('cast.stop') : t('cast.start')}">
+ <${Icon} name="cast" /></button>
+ ${castPickerOpen && html`
+ <div class="cast-picker">
+ ${castScanning && html`
+ <div class="cast-picker-item cast-picker-scanning">
+ <span class="spinner" style="width:14px;height:14px"></span>
+ ${t('cast.scanning')}
+ </div>
+ `}
+ ${castDevices.map(d => html`
+ <button class="cast-picker-item" onClick=${() => {
+ setCastPickerOpen(false);
+ setCastDeviceName(d.name);
+ setCastActive(true); castActiveRef.current = true;
+ castRestartPendingRef.current = true;
+ castDeviceRef.current = d;
+ if (videoRef.current && requestSeekRef.current) {
+ requestSeekRef.current(videoRef.current.currentTime);
+ }
+ }}>
+ <${Icon} name="cast" /> ${d.name}
+ </button>
+ `)}
+ ${!castScanning && castDevices.length === 0 && html`
+ <div class="cast-picker-item cast-picker-empty">
+ ${t('cast.no_devices')}
+ </div>
+ `}
+ <div class="cast-picker-sep"></div>
+ <button class="cast-picker-item" onClick=${async () => {
+ setCastPickerOpen(false);
+ setCastActive(true); castActiveRef.current = true;
+ castRestartPendingRef.current = true;
+ if (videoRef.current && requestSeekRef.current) {
+ requestSeekRef.current(videoRef.current.currentTime);
+ }
+ }}>
+ <${Icon} name="clip" /> ${t('cast.copy_url')}
+ </button>
+ </div>
+ `}
+ </div>
+ `}
+ ${castUrl && html`
+ <span class="cast-status-label">
+ ${castDeviceName
+ ? castDeviceName
+ : html`<input class="cast-url-input" readOnly value=${castUrl}
+ onClick=${(e) => {
+ e.target.select();
+ navigator.clipboard.writeText(castUrl).catch(() => {});
+ }}
+ title="${t('cast.copy_url')}" />`
+ }
+ </span>
+ `}
+ ${onDownload && html`
+ <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
+ onClick=${() => {
+ if (!dlBusy) {
+ setDlBusy(true);
+ onDownload();
+ setTimeout(() => setDlBusy(false), 1500);
+ }
+ }}
+ title="${t('group.download')}">
+ ${dlBusy
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
+ `}
+ <button class="video-close" onClick=${onClose} title="${t('video.close')}">
+ <${Icon} name="close" /></button>
+ </div>
+
+ ${phase === 'loading' && html`
+ <div class="video-loading">
+ <div class="video-loading-label">
+ <span class="spinner"></span>${' '}${t('video.buffering')}
+ </div>
+ </div>
+ `}
+
+ ${(phase === 'streaming' || phase === 'loading') && html`
+ <div class="video-container">
+ <video ref=${videoRef} controls autoplay />
+ ${resumedFrom > 0 && html`
+ <div class="video-resumed">
+ ${t('video.resumed_at', { time: formatClock(resumedFrom) })}
+ <button class="linklike" onClick=${() => {
+ setResumedFrom(0);
+ writeResumePosition(entry.id, 0, durationRef.current);
+ if (requestSeekRef.current) requestSeekRef.current(0);
+ }}>${t('video.from_start')}</button>
+ </div>
+ `}
+ </div>
+ `}
+
+ ${phase === 'error' && html`
+ <div class="video-error">${error}</div>
+ `}
+ </div>
+ `;
+}
+
+// ── Search Page (cross-group file search) ───────────────────────────────────
+
+/**
+ * "3 hours ago", in the reader's language.
+ *
+ * The search page needs it because its results come from a cache: a file that
+ * was deleted an hour ago is still listed until the group is opened again, and
+ * the honest thing is to say how old the answer is rather than to imply it is
+ * live.
+ */
+
+export { VideoPlayer };