summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py8
-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
-rw-r--r--packages/meshbay-hub/tests/harness/scroll_probe.py4
-rw-r--r--packages/meshbay-hub/tests/harness/session_harness.mjs4
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py4
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py41
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py4
-rw-r--r--packages/meshbay-hub/tests/test_resume_position.py2
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py13
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py22
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py2
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py113
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py51
-rw-r--r--packages/meshbay-hub/tests/test_video_buffer_ceiling.py5
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py5
-rw-r--r--packages/meshbay-hub/tests/test_video_stream_switch.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py65
-rw-r--r--packages/meshbay-node/tests/test_apps_enabled_policy.py137
43 files changed, 4294 insertions, 3617 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 73f07d9..ad109b2 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -49,6 +49,11 @@ OP_MEMBER_UNPIN = "member_unpin"
# setting decides who may write to the operator's disk, so a node that took it
# from an unsigned message would let any member re-enable it for everyone.
OP_MEMBER_UPLOAD = "member_upload"
+# Which group "applications" (Chat, Files, and whatever registers later) are
+# shown to members. Signed like the rest: it decides what a member sees, not
+# anything about key material, but an unsigned toggle would let any member
+# turn a disabled one back on.
+OP_APPS_ENABLED = "apps_enabled"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
OP_GROUP_ATTACH = "group_attach"
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 06acb8b..53e5085 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -80,6 +80,8 @@ class MNP:
MEMBER_UNPIN_ACK = "member_unpin_ack"
MEMBER_UPLOAD = "member_upload" # operator → node: may members upload?
MEMBER_UPLOAD_ACK = "member_upload_ack"
+ APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show
+ APPS_ENABLED_ACK = "apps_enabled_ack"
# Device linking. A new device files a request bound to a code it displays;
# an already-pinned device of the same account approves it. Neither the hub
# nor the node can produce the countersignature.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 19d22d4..e11e718 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -28,7 +28,13 @@ router = APIRouter(tags=["webapp"])
# which is the failure this list exists to prevent, and it is silent.
_ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
"i18n.js", "downloads.js", "transfers.js", "zipstream.js",
- "platform.js", "meshbay-m.png")
+ "platform.js", "meshbay-m.png",
+ # Split out of app.js by the group-page refactor — each imported by
+ # app.js or group-page.js, so a change to any of them is a change
+ # to what the browser must fetch.
+ "icon.js", "file-utils.js", "hub-client.js", "apps.js",
+ "chat-app.js", "files-app.js", "video-player.js",
+ "group-settings.js", "group-page.js")
def _asset_version() -> str:
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 };
diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py
index 73ff0f1..46d8357 100644
--- a/packages/meshbay-hub/tests/harness/scroll_probe.py
+++ b/packages/meshbay-hub/tests/harness/scroll_probe.py
@@ -28,7 +28,9 @@ import time
from pathlib import Path
STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
-APP = STATIC / "app.js"
+# fit() is ChatPanel's own viewport-sizing logic, moved to chat-app.js in the
+# group-page refactor.
+APP = STATIC / "chat-app.js"
PORT = 8736
FRAG = Path(sys.argv[1]).read_text()
HEIGHTS = ([int(h) for h in sys.argv[2].split(",")]
diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs
index 3be862e..0334813 100644
--- a/packages/meshbay-hub/tests/harness/session_harness.mjs
+++ b/packages/meshbay-hub/tests/harness/session_harness.mjs
@@ -24,8 +24,8 @@ const between = (from, to) => {
if (j < 0) throw new Error(`not found: ${to}`);
return app.slice(i, j);
};
-const sessionBlock = between('let _auth = loadAuth();', '\n// ── Theme');
-const hubFetchFn = between('async function hubFetch(', '\n// ── Router');
+const sessionBlock = between('let _auth = loadAuth();', '\n// ── Hub API');
+const hubFetchFn = between('async function hubFetch(', '\nexport {');
// ── The world ────────────────────────────────────────────────────────────────
const store = new Map();
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index 85bf488..80c3b05 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -145,7 +145,9 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path):
src = SW.read_text()
assert "if (entry.size > 0)" in src
- app = (STATIC / "app.js").read_text()
+ # The zip-directory download is Files' own, moved to files-app.js in the
+ # group-page refactor.
+ app = (STATIC / "files-app.js").read_text()
zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):]
zip_call = zip_call[:zip_call.index(");") + 2]
assert zip_call.rstrip().endswith(", 0);"), (
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index cd9a11e..dac1357 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -26,6 +26,17 @@ import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
APP = STATIC / "app.js"
+# One monolithic app.js used to hold every component; the group-page refactor
+# split it into one file per "application" (chat-app.js, files-app.js,
+# video-player.js, group-settings.js) plus the group shell (group-page.js).
+# A future Videos/Music/Photos app lands in its own file the same way — add it
+# here so this test keeps seeing it, since `_all_components` below only walks
+# the files named in this list.
+STATIC_FILES = [
+ "app.js", "group-page.js", "chat-app.js", "files-app.js",
+ "video-player.js", "group-settings.js",
+]
+
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
# `const NAME = useCallback(` / `useMemo(` — the declarations that both define a
@@ -40,18 +51,28 @@ def app():
return APP.read_text()
-def _components(app: str):
- """Each top-level component, with the offset it starts at."""
- for m in re.finditer(r"^function ([A-Z]\w*)\(", app, re.M):
+def _components(src: str):
+ """Each top-level component in one file, with the offset it starts at."""
+ for m in re.finditer(r"^function ([A-Z]\w*)\(", src, re.M):
start = m.start()
- nxt = app.find("\nfunction ", start + 1)
- yield m.group(1), app[start:nxt if nxt > 0 else len(app)]
+ nxt = src.find("\nfunction ", start + 1)
+ yield m.group(1), src[start:nxt if nxt > 0 else len(src)]
+
+
+def _all_components():
+ """Every top-level component across every static file that can hold one."""
+ for name in STATIC_FILES:
+ path = STATIC / name
+ if not path.exists():
+ continue
+ for cname, body in _components(path.read_text()):
+ yield f"{name}:{cname}", body
-def test_no_hook_depends_on_something_declared_below_it(app):
- """The whole file, not just the player that was broken by it."""
+def test_no_hook_depends_on_something_declared_below_it():
+ """Every static file that can hold a component, not just app.js."""
problems = []
- for name, body in _components(app):
+ for name, body in _all_components():
# Where each hook binding becomes usable.
declared_at = {m.group(1): m.start() for m in DECL.finditer(body)}
for deps in DEPS.finditer(body):
@@ -68,13 +89,13 @@ def test_no_hook_depends_on_something_declared_below_it(app):
+ "\n ".join(problems))
-def test_the_check_would_notice(app):
+def test_the_check_would_notice():
"""A test that cannot fail proves nothing — so make it fail on purpose.
Swaps two declarations in the real file and confirms the rule fires. If
this stops working the rule above has quietly become decoration.
"""
- body = next(b for n, b in _components(app) if n == "VideoPlayer")
+ body = next(b for n, b in _all_components() if n == "video-player.js:VideoPlayer")
decls = list(DECL.finditer(body))
assert len(decls) >= 2, "VideoPlayer has too few hooks to test the check"
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py
index 0e2444c..11a6d05 100644
--- a/packages/meshbay-hub/tests/test_layout_responsive.py
+++ b/packages/meshbay-hub/tests/test_layout_responsive.py
@@ -112,7 +112,9 @@ def test_the_toolbar_still_fits_a_360px_screen(css):
# ── The chat panel's height ───────────────────────────────────────────────────
-APP = STATIC / "app.js"
+# fit() and its constants are ChatPanel's own viewport-sizing logic, moved to
+# chat-app.js in the group-page refactor.
+APP = STATIC / "chat-app.js"
@pytest.fixture(scope="module")
diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py
index ef239c5..07ac23a 100644
--- a/packages/meshbay-hub/tests/test_resume_position.py
+++ b/packages/meshbay-hub/tests/test_resume_position.py
@@ -21,7 +21,7 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-APP = STATIC / "app.js"
+APP = STATIC / "video-player.js"
pytestmark = pytest.mark.skipif(
shutil.which("node") is None or not APP.exists(),
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py
index 6c47f7a..a839f1e 100644
--- a/packages/meshbay-hub/tests/test_session_renewal.py
+++ b/packages/meshbay-hub/tests/test_session_renewal.py
@@ -34,7 +34,12 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-APP = STATIC / "app.js"
+APP = STATIC / "hub-client.js"
+# The session/token machinery lives in hub-client.js (APP, above); the group
+# shell's own WebRTC-connect effect that consumes it is in group-page.js; the
+# periodic re-check that catches a backgrounded tab is App() in app.js.
+GROUP_PAGE = STATIC / "group-page.js"
+APP_JS = STATIC / "app.js"
HARNESS = Path(__file__).parent / "harness" / "session_harness.mjs"
pytestmark = pytest.mark.skipif(
@@ -182,7 +187,7 @@ def test_renewal_happens_before_expiry_not_after():
assert margin >= 300, (
f"{margin} s of margin against a one-hour token is thin: a backgrounded "
"tab has its timers throttled and may not check for minutes")
- assert "visibilitychange" in src, (
+ assert "visibilitychange" in APP_JS.read_text(), (
"nothing re-checks when the tab comes back, which is exactly when the "
"token is most likely to have aged out unnoticed")
@@ -203,7 +208,7 @@ def test_renewing_does_not_tear_down_the_webrtc_connection():
Signing in or out must still re-run it, so the dependency is whether there
is a token, not which one.
"""
- src = APP.read_text()
+ src = GROUP_PAGE.read_text()
i = src.index("means tearing down the WebRTC connection")
deps = src[i:src.index(");", i)]
assert "Boolean(token)" in deps, (
@@ -218,7 +223,7 @@ def test_the_connection_signs_its_offer_with_a_live_token():
It signs the offer relayed through the hub, where an expired one is a 401
and no connection at all.
"""
- src = APP.read_text()
+ src = GROUP_PAGE.read_text()
connect = src[src.index("const connect = async () => {"):]
connect = connect[:connect.index("\n };")]
assert "await ensureFreshToken()" in connect, (
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index dfc92f9..d00b9a0 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -113,14 +113,26 @@ def test_the_ack_still_verifies_the_announced_node_key():
# Users tab referencing `doInvite`, `members` and `adminId` — none of which are
# defined there.
+# The group-page refactor split what used to be one app.js into one file per
+# "application" (chat-app.js, files-app.js, video-player.js) plus
+# group-settings.js and the group shell itself, group-page.js. AdminPage and
+# App() stayed in app.js. `_component` below is told which file to read a
+# given top-level component from.
APP = STATIC / "app.js"
+COMPONENT_FILES = {
+ "GroupSettingsPanel": STATIC / "group-settings.js",
+ "GroupPage": STATIC / "group-page.js",
+ "ChatPanel": STATIC / "chat-app.js",
+ "FilesPanel": STATIC / "files-app.js",
+ "VideoPlayer": STATIC / "video-player.js",
+}
def _component(name: str) -> str:
"""The source of one top-level `function Name(...)`, up to the next one."""
- source = APP.read_text()
+ source = COMPONENT_FILES.get(name, APP).read_text()
start = source.find(f"\nfunction {name}(")
- assert start != -1, f"{name} is gone from app.js — update this test"
+ assert start != -1, f"{name} is gone — update this test"
end = source.find("\nfunction ", start + 1)
return source[start:end if end != -1 else len(source)]
@@ -220,7 +232,7 @@ def test_no_caller_waits_for_one_chunk_at_a_time():
# two ends of that, since neither shows up in any Python test.
def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it():
- app = APP.read_text()
+ app = _component("GroupPage")
cleanup = app[app.index(" return () => {\n cancelled = true;"):]
cleanup = cleanup[:cleanup.index("\n }, [groupId")]
assert "releaseWhenIdle" in cleanup, (
@@ -252,7 +264,7 @@ def test_a_multi_file_download_waits_for_each_picker():
meant the first opened a dialog and the rest were rejected — two files
selected, one file downloaded.
"""
- app = APP.read_text()
+ app = STATIC.joinpath("files-app.js").read_text()
# Anchored on the loop rather than on the markup around it: the toolbar
# moved from a dropdown to icon buttons and took the old wrapper with it,
# while the property under test — one picker at a time — did not change.
@@ -269,7 +281,7 @@ def test_links_in_chat_are_built_as_elements_not_markup():
never HTML, and only for http(s) — otherwise javascript: would be one
message away from running here.
"""
- app = APP.read_text()
+ app = STATIC.joinpath("chat-app.js").read_text()
fn = app[app.index("function linkify("):]
fn = fn[:fn.index("\nfunction ", 1)]
assert "innerHTML" not in fn and "dangerouslySetInnerHTML" not in fn
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index 35fe1cc..86d58cd 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -209,7 +209,7 @@ def test_the_colour_is_defined_for_that_mark():
def test_a_folder_name_carries_no_trailing_slash():
"""The folder icon in the cell beside it already says what it is."""
- source = APP.read_text(encoding="utf-8")
+ source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8")
row = source[source.index('class="file-row dir-row"'):]
row = row[:row.index("</tr>")]
assert "${d}/" not in row, "the folder name is rendered with a trailing slash"
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 5dc3b7b..e014518 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -23,6 +23,12 @@ import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
TRANSPORT = STATIC / "transport.js"
APP = STATIC / "app.js"
+# Chat's history paging/scroll-anchoring logic and GroupPage's own connect()
+# effect moved out of app.js in the group-page refactor.
+CHAT_APP = STATIC / "chat-app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
+ STATIC / "video-player.js", STATIC / "group-settings.js"]
pytestmark = pytest.mark.skipif(
not TRANSPORT.exists(), reason="the SPA sources are not available")
@@ -38,6 +44,16 @@ def app():
return APP.read_text()
+@pytest.fixture(scope="module")
+def chat():
+ return CHAT_APP.read_text()
+
+
+@pytest.fixture(scope="module")
+def group_page():
+ return GROUP_PAGE.read_text()
+
+
def test_chat_history_pages_backwards(transport):
body = transport[transport.index("async fetchChatHistory"):]
body = body[:body.index("\n }")]
@@ -53,15 +69,15 @@ def test_chat_history_reports_whether_more_exists(transport):
"without it the 'load older' control cannot know when to stop offering")
-def test_the_browser_asks_for_the_newest_page_first(app):
+def test_the_browser_asks_for_the_newest_page_first(chat):
"""A group opens on the newest messages, not the oldest."""
- assert "fetchChatHistory({ limit: CHAT_PAGE })" in app
- assert re.search(r"const CHAT_PAGE\s*=\s*100", app)
- assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", app)
+ assert "fetchChatHistory({ limit: CHAT_PAGE })" in chat
+ assert re.search(r"const CHAT_PAGE\s*=\s*100", chat)
+ assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", chat)
-def test_older_pages_are_requested_with_a_cursor_not_an_offset(app):
- assert "before: messages[0].id" in app, (
+def test_older_pages_are_requested_with_a_cursor_not_an_offset(chat):
+ assert "before: messages[0].id" in chat, (
"paging by offset repeats or skips messages when one arrives mid-scroll")
@@ -88,20 +104,20 @@ def test_ping_can_time_out_sooner_than_a_transfer(transport):
assert "timeoutMs" in body
-def test_scroll_position_is_anchored_when_older_messages_are_prepended(app):
+def test_scroll_position_is_anchored_when_older_messages_are_prepended(chat):
"""Everything above the viewport grows, so scrollTop alone is not enough."""
- assert "scrollHeight - list.scrollTop" in app, "the anchor is measured from the bottom"
- assert "list.scrollTop = list.scrollHeight - anchorRef.current" in app
- assert "useLayoutEffect" in app, (
+ assert "scrollHeight - list.scrollTop" in chat, "the anchor is measured from the bottom"
+ assert "list.scrollTop = list.scrollHeight - anchorRef.current" in chat
+ assert "useLayoutEffect" in chat, (
"correcting after paint shows the jump it is meant to prevent")
-def test_the_view_only_follows_new_messages_when_already_at_the_bottom(app):
- assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in app, (
+def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat):
+ assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in chat, (
"scrolling unconditionally fights someone reading back through history")
-def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app):
+def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(chat):
"""scrollIntoView on a zero-height marker stops short of the true bottom.
The list has padding and a flex gap below the last bubble, so aligning an
@@ -110,15 +126,15 @@ def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app):
"""
# The call, not the word: a comment explaining why it is gone should not
# be able to fail this.
- assert ".scrollIntoView(" not in app
- assert "list.scrollTo({ top: list.scrollHeight" in app, (
+ assert ".scrollIntoView(" not in chat
+ assert "list.scrollTo({ top: list.scrollHeight" in chat, (
"jumping to the latest should also land on the real bottom")
-def test_messages_are_keyed_by_id_not_index(app):
+def test_messages_are_keyed_by_id_not_index(chat):
"""Index keys plus prepending makes Preact reuse the wrong bubbles."""
- assert "key=${m.id}" in app
- assert "key=${i}" not in app.split("function ChatPanel")[1].split("\n}")[0]
+ assert "key=${m.id}" in chat
+ assert "key=${i}" not in chat.split("function ChatPanel")[1].split("\n}")[0]
def test_presence_has_three_states_and_a_label_for_each(app):
@@ -137,9 +153,9 @@ def _string(source: str, key: str) -> str:
return source[start:end]
-def test_a_refusal_from_the_node_counts_as_present(app):
+def test_a_refusal_from_the_node_counts_as_present(group_page):
"""The node answering "no" proves it is up; only silence proves nothing."""
- assert "err.reason ? 'online' : 'offline'" in app
+ assert "err.reason ? 'online' : 'offline'" in group_page
# ── The create-group form ─────────────────────────────────────────────────────
@@ -191,7 +207,7 @@ def test_the_form_starts_on_a_combination_the_api_accepts(app):
# ── Dead references in the SPA ────────────────────────────────────────────────
-def test_no_setter_survives_the_state_it_belonged_to(app):
+def test_no_setter_survives_the_state_it_belonged_to():
"""A removed useState leaves its setter behind, and nothing complains.
`setActionsOpen` outlived `actionsOpen` when the Actions dropdown became a
@@ -202,28 +218,45 @@ def test_no_setter_survives_the_state_it_belonged_to(app):
A grep for the state name does not find it — `setActionsOpen` does not
contain `actionsOpen`, the capital breaks the match. That is exactly how it
got through.
+
+ Checked per file rather than on one concatenated blob: the group-page
+ refactor split what used to be one app.js into several, and a setter
+ defined in one (e.g. `setAuth`, imported from hub-client.js) must not be
+ mistaken for covering an orphan call of the same name in another. Lifting
+ state to the shared shell and passing its setter down as a prop is the
+ same idea one level lower — `FilesPanel`'s `setEntries` is real, just
+ declared in group-page.js's own `useState` rather than here — so a setter
+ named in a component's own destructured props is treated as defined too.
"""
import re
- declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app))
- # Names brought in from another module are defined, just not here.
- imported = set()
- for names in re.findall(r"import\s*\{([^}]*)\}\s*from", app):
- imported.update(n.strip().split(" as ")[-1].strip() for n in names.split(","))
- # A bare call only: `downloads.setMode(...)` and `view.setUint32(...)` belong
- # to their object, not to this component.
- called = set(re.findall(r"(?<![.\w])(set[A-Z]\w*)\s*\(", app))
- builtin = {"setTimeout", "setInterval"}
- # A `setX` that is a plain function of this module is not an orphan setter:
- # `setAuth` writes the session to localStorage and has no `useState` behind
- # it by design. Without this the rule reports every such helper, and a rule
- # that cries wolf is one someone eventually silences.
- defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
- defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M))
+ for path in SPLIT_FILES:
+ app = path.read_text()
+ declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app))
+ # Names brought in from another module are defined, just not here.
+ imported = set()
+ for names in re.findall(r"import\s*\{([^}]*)\}\s*from", app):
+ imported.update(n.strip().split(" as ")[-1].strip() for n in names.split(","))
+ # A bare call only: `downloads.setMode(...)` and `view.setUint32(...)`
+ # belong to their object, not to this component.
+ called = set(re.findall(r"(?<![.\w])(set[A-Z]\w*)\s*\(", app))
+ builtin = {"setTimeout", "setInterval"}
+ # A `setX` that is a plain function of this module is not an orphan
+ # setter: `setAuth` writes the session to localStorage and has no
+ # `useState` behind it by design. Without this the rule reports every
+ # such helper, and a rule that cries wolf is one someone eventually
+ # silences.
+ defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M))
+ defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M))
+ # A setter named in a `function Component({ ..., setX, ... })` prop
+ # list is handed down from wherever it is really declared.
+ for params in re.findall(r"^function [A-Z]\w*\(\{([^}]*)\}", app, re.M):
+ defined.update(re.findall(r"\b(set[A-Z]\w*)\b", params))
- orphans = sorted(called - declared - imported - builtin - defined)
- assert not orphans, (
- f"setter(s) called with no useState behind them: {orphans} — "
- "each one is a ReferenceError the moment that code path runs")
+ orphans = sorted(called - declared - imported - builtin - defined)
+ assert not orphans, (
+ f"{path.name}: setter(s) called with no useState behind them: "
+ f"{orphans} — each one is a ReferenceError the moment that code "
+ "path runs")
# ── Parallel uploads ──────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index d859125..9290a94 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -17,7 +17,15 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+# The group-page refactor split what used to be one app.js into one file per
+# "application" plus the group shell. mayUpload itself is still derived once,
+# in the shell (group-page.js) — Files and Chat each moved to their own file
+# and receive it as a prop, the same shape ChatPanel already took.
APP = STATIC / "app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+FILES_APP = STATIC / "files-app.js"
+CHAT_APP = STATIC / "chat-app.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
TRANSPORT = STATIC / "transport.js"
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
@@ -25,7 +33,7 @@ pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailabl
@pytest.fixture(scope="module")
def app() -> str:
- return APP.read_text(encoding="utf-8")
+ return GROUP_PAGE.read_text(encoding="utf-8")
def _component(app: str, name: str) -> str:
@@ -36,15 +44,15 @@ def _component(app: str, name: str) -> str:
# ── Both controls ───────────────────────────────────────────────────────────
-def test_the_files_toolbar_hides_its_upload_button(app):
- page = _component(app, "GroupPage")
+def test_the_files_toolbar_hides_its_upload_button():
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
toolbar = page[page.index("file-toolbar"):]
toolbar = toolbar[:toolbar.index("group.mkdir")]
assert "mayUpload &&" in toolbar, "the Upload button is offered regardless"
-def test_the_chat_composer_hides_its_paperclip(app):
- chat = _component(app, "ChatPanel")
+def test_the_chat_composer_hides_its_paperclip():
+ chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel")
composer = chat[chat.index("chat-input-row"):]
assert "mayUpload &&" in composer, (
"the chat attachment is the second way in and is still offered")
@@ -53,15 +61,19 @@ def test_the_chat_composer_hides_its_paperclip(app):
def test_both_read_the_same_answer(app):
"""Two derivations would eventually disagree, and the disagreement would
be one of them offering an upload the node refuses."""
- page = _component(app, "GroupPage")
- assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", page), (
+ assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), (
"mayUpload is no longer derived in one place")
- assert "mayUpload=${mayUpload}" in page, "the chat panel is told separately"
+ # Files and Chat both receive it from the same `commonProps` object the
+ # shell spreads into whichever app tab is active — one derivation feeding
+ # one object, rather than two hand-written prop attributes that could
+ # drift apart.
+ props = app[app.index("const commonProps = {"):app.index("return html`")]
+ assert "mayUpload," in props or "mayUpload:" in props, (
+ "mayUpload is not in the shared props object every app receives")
def test_the_operator_keeps_their_own_controls(app):
- page = _component(app, "GroupPage")
- assert "memberUpload || isNodeAdmin" in page, (
+ assert "memberUpload || isNodeAdmin" in app, (
"turning uploads off would hide the operator's own upload button")
@@ -70,27 +82,24 @@ def test_the_operator_keeps_their_own_controls(app):
def test_the_answer_comes_from_the_node(app):
"""Not from the hub, which has no say in what may be written to someone
else's disk, and no way to be believed about it."""
- page = _component(app, "GroupPage")
- assert "ack.member_upload !== false" in page, (
+ assert "ack.member_upload !== false" in app, (
"the handshake ack is what carries this")
- assert "hubFetch" not in page[page.index("ack.member_upload") - 400:
- page.index("ack.member_upload")]
+ assert "hubFetch" not in app[app.index("ack.member_upload") - 400:
+ app.index("ack.member_upload")]
def test_an_older_node_is_treated_as_permissive(app):
"""A node that predates the setting sends no such field. Reading a missing
field as "off" would close every group on the older half of the network."""
- page = _component(app, "GroupPage")
- assert "!== false" in page[page.index("ack.member_upload"):
- page.index("ack.member_upload") + 60]
+ assert "!== false" in app[app.index("ack.member_upload"):
+ app.index("ack.member_upload") + 60]
def test_a_change_reaches_people_already_connected(app):
"""The operator may be someone else entirely, changing it while you have
the group open. A button that survives until the next reconnection is a
button somebody presses."""
- page = _component(app, "GroupPage")
- assert "transport.onUploadPolicy" in page
+ assert "transport.onUploadPolicy" in app
transport = TRANSPORT.read_text(encoding="utf-8")
assert "member_upload_ack" in transport, "nothing routes the node's notice"
@@ -115,8 +124,8 @@ def test_changing_it_is_signed(app):
"an unsigned instruction would let any member turn uploads back on")
-def test_only_the_operator_is_offered_the_setting(app):
- panel = _component(app, "GroupSettingsPanel")
+def test_only_the_operator_is_offered_the_setting():
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel")
section = panel[panel.index("members.uploads_title") - 400:
panel.index("members.uploads_title")]
assert "isNodeAdmin && connected" in section
diff --git a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
index cf38117..59f97de 100644
--- a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
+++ b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py
@@ -45,7 +45,7 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-APP = STATIC / "app.js"
+APP = STATIC / "video-player.js"
NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
/ "meshbay_node" / "transport" / "webrtc_server.py")
@@ -61,7 +61,8 @@ def app():
def _player(app: str) -> str:
i = app.index("function VideoPlayer(")
- return app[i:app.index("\nfunction ", i + 1)]
+ nxt = app.find("\nfunction ", i + 1)
+ return app[i:nxt if nxt > 0 else len(app)]
# ── The shipped functions, run against a browser that has a ceiling ───────────
diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py
index 51f65ce..85e774d 100644
--- a/packages/meshbay-hub/tests/test_video_seek.py
+++ b/packages/meshbay-hub/tests/test_video_seek.py
@@ -36,7 +36,7 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-APP = STATIC / "app.js"
+APP = STATIC / "video-player.js"
TRANSPORT = STATIC / "transport.js"
NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
/ "meshbay_node" / "transport" / "webrtc_server.py")
@@ -51,7 +51,8 @@ def app():
def _player(app: str) -> str:
i = app.index("function VideoPlayer(")
- return app[i:app.index("\nfunction ", i + 1)]
+ nxt = app.find("\nfunction ", i + 1)
+ return app[i:nxt if nxt > 0 else len(app)]
# ── The node ──────────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py
index 464d1f7..80d9421 100644
--- a/packages/meshbay-hub/tests/test_video_stream_switch.py
+++ b/packages/meshbay-hub/tests/test_video_stream_switch.py
@@ -35,7 +35,7 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-APP = STATIC / "app.js"
+APP = STATIC / "video-player.js"
pytestmark = pytest.mark.skipif(
shutil.which("node") is None or not APP.exists(),
@@ -49,7 +49,8 @@ def app():
def _player(app: str) -> str:
i = app.index("function VideoPlayer(")
- return app[i:app.index("\nfunction ", i + 1)]
+ nxt = app.find("\nfunction ", i + 1)
+ return app[i:nxt if nxt > 0 else len(app)]
# ── The flag that stalled everything ──────────────────────────────────────────
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index b34e710..c68f999 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -264,6 +264,10 @@ class NodeDaemon:
# place, so the two never drift within a run.
"member_upload": await self._roster.member_upload_allowed(
group_cfg.id) if self._roster else True,
+ # Same reasoning: read once at load, kept current in place
+ # by the signed operation that changes it.
+ "enabled_apps": await self._roster.enabled_apps(
+ group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
}
if not groups_ctx:
@@ -584,6 +588,9 @@ class NodeDaemon:
"member_upload": (
await self._roster.member_upload_allowed(group_cfg.id)
if self._roster else True),
+ "enabled_apps": (
+ await self._roster.enabled_apps(group_cfg.id)
+ if self._roster else list(Roster.DEFAULT_APPS)),
"chat_store": store,
}
groups_ctx[group_cfg.id] = new_ctx
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index daddf17..c9d862a 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -711,6 +711,25 @@ async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict:
return {"allowed": allowed, "group_id": group_id}
+# ── Applications ─────────────────────────────────────────────────────────────
+
+async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
+ """
+ Which group "applications" (Chat, Files, ...) are shown to members.
+
+ Same shape as `set_member_upload`: lives on the node (roster.db), takes
+ effect without a restart, and is signed by the operator (webrtc_server.py
+ checks the caller's own admin-authority allow-list before this runs).
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_enabled_apps(group_id, apps,
+ set_by=state.get("node_user_id", ""))
+ ctx["enabled_apps"] = apps
+ log.info("Enabled apps for group %s: %s", group_id[:8], ",".join(sorted(apps)))
+ return {"apps": apps, "group_id": group_id}
+
+
# ── Reload ──────────────────────────────────────────────────────────────────
async def reload_config(state: dict) -> dict:
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 9a52b53..1cc8cae 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -22,6 +22,7 @@ The code is what binds a public key to an account without asking the hub
from __future__ import annotations
import hashlib
+import json
import logging
import os
import secrets
@@ -577,6 +578,27 @@ class Roster:
"1" if allowed else "0", set_by)
return allowed
+ # Which group "applications" (Chat, Files, and whatever registers later in
+ # apps.js) are shown to members. Unset means every app that exists — an
+ # existing group's tabs must not disappear because a node was upgraded.
+ SETTING_ENABLED_APPS = "enabled_apps"
+ DEFAULT_APPS = ("chat", "files")
+
+ async def enabled_apps(self, group_id: str) -> list[str]:
+ value = await self.get_setting(group_id, self.SETTING_ENABLED_APPS)
+ if value is None:
+ return list(self.DEFAULT_APPS)
+ try:
+ return list(json.loads(value))
+ except (ValueError, TypeError):
+ return list(self.DEFAULT_APPS)
+
+ async def set_enabled_apps(self, group_id: str, apps: list[str],
+ set_by: str = "") -> list[str]:
+ await self.set_setting(group_id, self.SETTING_ENABLED_APPS,
+ json.dumps(sorted(apps)), set_by)
+ return apps
+
async def create_invite(
self,
group_id: str,
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 947d1f9..b6f572a 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -62,6 +62,7 @@ from meshbay_common.adminop import (
OP_GEK_ROTATE,
OP_MEMBER_UNPIN,
OP_MEMBER_UPLOAD,
+ OP_APPS_ENABLED,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_GROUP_ATTACH,
@@ -406,6 +407,8 @@ class WebRTCPeerSession:
self._spawn(self._do_device_revoke(msg))
elif mtype == MNP.MEMBER_UPLOAD:
self._do_member_upload(msg)
+ elif mtype == MNP.APPS_ENABLED:
+ self._do_apps_enabled(msg)
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -651,6 +654,11 @@ class WebRTCPeerSession:
# permission — the node refuses regardless — but without it the
# only way to discover the answer is to try.
"member_upload": bool(self._group_ctx().get("member_upload", True)),
+ # Which group "applications" to show. Absent/empty falls back to
+ # every registered one client-side, so a node that predates this
+ # setting (or one whose context has not loaded it yet) hides
+ # nothing.
+ "enabled_apps": list(self._group_ctx().get("enabled_apps") or []),
}
if node_user_id:
ack["node_user_id"] = node_user_id
@@ -1586,6 +1594,60 @@ class WebRTCPeerSession:
except Exception:
pass
+ # Every "application" a group can show — Chat and Files today. Videos,
+ # Music, Photos join this set (and apps.js's registry, client-side) when
+ # they land; nothing else about this handler changes.
+ ALLOWED_APPS = frozenset({"chat", "files"})
+
+ def _do_apps_enabled(self, msg: dict) -> None:
+ """
+ Turn a group "application" on or off for everyone, for this group.
+
+ Signed like `member_upload`: this decides what a member sees, and an
+ unsigned message would let any member turn a disabled one back on.
+ """
+ apps = msg.get("apps")
+ if not isinstance(apps, list) or not apps:
+ self._send({"type": "error", "detail": "Missing or empty apps"})
+ return
+ unknown = set(apps) - self.ALLOWED_APPS
+ if unknown:
+ self._send({"type": "error",
+ "detail": f"Unknown app(s): {', '.join(sorted(unknown))}"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # The subject is what the operator is shown before signing, and what
+ # the client compares its own request against (transport.js) — a
+ # canonical form so both sides build the same transcript.
+ self._issue_admin_challenge(OP_APPS_ENABLED, ",".join(sorted(apps)))
+
+ async def _admin_exec_apps_enabled(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ apps = pending["subject"].split(",") if pending["subject"] else []
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"apps_enabled:{pending['subject']}")
+ return
+ try:
+ await self._run_op(
+ ops.set_enabled_apps, self._group_id or "", apps)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("apps_enabled", pending["subject"])
+
+ # Everyone already connected is told, so a disabled tab disappears
+ # without waiting for a reconnection.
+ notice = {"type": MNP.APPS_ENABLED_ACK, "v": MNP_VERSION, "apps": apps}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
# ── Node management (D5) ─────────────────────────────────────────────────
async def _do_node_status(self, msg: dict) -> None:
@@ -2561,6 +2623,9 @@ class WebRTCPeerSession:
elif pending["op"] == OP_MEMBER_UPLOAD:
self._spawn(
self._admin_exec_member_upload(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_APPS_ENABLED:
+ self._spawn(
+ self._admin_exec_apps_enabled(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_ADD:
self._spawn(
self._admin_exec_root_add(pending, transcript, sig_bytes))
diff --git a/packages/meshbay-node/tests/test_apps_enabled_policy.py b/packages/meshbay-node/tests/test_apps_enabled_policy.py
new file mode 100644
index 0000000..671005a
--- /dev/null
+++ b/packages/meshbay-node/tests/test_apps_enabled_policy.py
@@ -0,0 +1,137 @@
+"""
+The operator decides which group "applications" (Chat, Files, ...) are shown.
+
+Same shape as `test_member_upload_policy.py`, because it is the same kind of
+setting: changed by a signed operator instruction, stored on the node rather
+than the hub, and safe for an existing group to have never heard of. The two
+things specific to this one: the whole set is signed in one message rather
+than one op per app, and an empty or unrecognised set is refused before a
+challenge is ever issued — there is no file write to refuse afterwards the
+way an unsigned upload is refused, so the check has to happen up front.
+"""
+
+from pathlib import Path
+
+import pytest
+
+from meshbay_common.adminop import OP_APPS_ENABLED
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": one_root(shared_root),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+# ── Refused before a challenge is even issued ───────────────────────────────
+
+async def test_an_empty_set_is_refused(tmp_path):
+ """Never let the operator lock a group down to nothing — no round trip
+ to the operator's browser needed to learn that."""
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_apps_enabled({"apps": []})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_an_unknown_app_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_apps_enabled({"apps": ["chat", "videos"]})
+
+ assert not issued, "videos is not registered yet — accepting it would " \
+ "silently store a setting no client can act on"
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ session = _session(tmp_path, "member-1", operator="the-operator")
+ session._has_admin_authority = lambda: False
+
+ session._do_apps_enabled({"apps": ["chat"]})
+
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+# ── Who may change it ───────────────────────────────────────────────────────
+
+async def test_changing_it_needs_a_signature(tmp_path):
+ """The request only ever produces a challenge. Nothing is applied until a
+ signature over the transcript verifies — the same path as member_upload."""
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_apps_enabled({"apps": ["files"]})
+
+ assert issued == [(OP_APPS_ENABLED, "files")]
+
+
+async def test_the_subject_is_the_sorted_set_so_both_sides_build_the_same_transcript(tmp_path):
+ """The operator's browser and the node must independently arrive at the
+ same subject string to sign/verify — order in the request must not
+ matter."""
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_apps_enabled({"apps": ["files", "chat"]})
+
+ assert issued == [(OP_APPS_ENABLED, "chat,files")]
+
+
+# ── Where it is stored ──────────────────────────────────────────────────────
+
+async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ assert sorted(await roster.enabled_apps("g1")) == ["chat", "files"], (
+ "absent must mean every registered app, or an upgrade hides one "
+ "for every existing group")
+ await roster.set_enabled_apps("g1", ["chat"], set_by="op")
+ assert await roster.enabled_apps("g1") == ["chat"]
+ finally:
+ await roster.close()
+
+ reopened = Roster(db_path=tmp_path / "roster.db")
+ await reopened.open()
+ try:
+ assert await reopened.enabled_apps("g1") == ["chat"]
+ assert sorted(await reopened.enabled_apps("g2")) == ["chat", "files"], (
+ "one group's setting must not answer for another")
+ finally:
+ await reopened.close()