aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js3539
1 files changed, 16 insertions, 3523 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 36df0cd..d18d9a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -7,124 +7,23 @@ import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed } from './transfers.js';
import * as downloads from './downloads.js';
import * as platform from './platform.js';
+import { Icon } from './icon.js';
+import { FILE_ICONS, formatSize } from './file-utils.js';
+import {
+ HUB, navigate, session, getCachedGroupIndex, getAllCachedIndexes,
+ _storeBundleKey, _loadBundleKey, _clearKeyDB,
+ loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch,
+ refreshAccessToken,
+} from './hub-client.js';
+import { GroupPage } from './group-page.js';
// ── Constants ────────────────────────────────────────────────────────────────
-// Where the hub is. Empty in a browser — it served this page, so a relative
-// path cannot be pointed at the wrong place. In the installed app the page
-// comes from disk and has no origin of its own, so the base is configured.
-// See platform.js.
-const HUB = platform.hubBase();
-const AUTH_KEY = 'mb_auth';
-// Renew an access token with this much life left rather than waiting for it to
-// fail. Generous against a one-hour token: a film is watched without the hub
-// hearing a word, and coming back to a tab that has been asleep for an hour
-// should not cost a round trip before the first click works.
-const TOKEN_RENEW_MARGIN_S = 600;
// How often to look. Cheap — it reads a timestamp out of the token and almost
// always does nothing.
const TOKEN_CHECK_MS = 60000;
const THEME_KEY = 'mb_theme';
-const IDB_NAME = 'meshbay';
-const IDB_VERSION = 1;
-const IDB_STORE = 'group_indexes';
-
-// ── IndexedDB cache ─────────────────────────────────────────────────────────
-
-function openDB() {
- return new Promise((resolve, reject) => {
- const req = indexedDB.open(IDB_NAME, IDB_VERSION);
- req.onupgradeneeded = () => {
- const db = req.result;
- if (!db.objectStoreNames.contains(IDB_STORE)) {
- db.createObjectStore(IDB_STORE, { keyPath: 'groupId' });
- }
- };
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
- });
-}
-
-async function cacheGroupIndex(groupId, groupName, entries) {
- try {
- const db = await openDB();
- const tx = db.transaction(IDB_STORE, 'readwrite');
- tx.objectStore(IDB_STORE).put({
- groupId, groupName, entries, cachedAt: Date.now(),
- });
- await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; });
- db.close();
- } catch { /* best-effort */ }
-}
-
-async function getCachedGroupIndex(groupId) {
- try {
- const db = await openDB();
- const tx = db.transaction(IDB_STORE, 'readonly');
- const req = tx.objectStore(IDB_STORE).get(groupId);
- const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
- db.close();
- return result || null;
- } catch { return null; }
-}
-
-async function getAllCachedIndexes() {
- try {
- const db = await openDB();
- const tx = db.transaction(IDB_STORE, 'readonly');
- const req = tx.objectStore(IDB_STORE).getAll();
- const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
- db.close();
- return result || [];
- } catch { return []; }
-}
-
-// ── Auth persistence ─────────────────────────────────────────────────────────
-// The key that opens a node's keypair bundle, derived once at sign-in. There is
-// no global identity to keep: identity keys belong to a node and are fetched from
-// it (transport.js), so nothing of that kind lives here.
-let _bundleKey = null;
-// A one-time pairing code the user just typed, consumed by the next connection
-// attempt. Deliberately not persisted: it is single-use and short-lived.
-let _pendingJoinCode = null;
-
-function _openKeyDB() {
- return new Promise((resolve, reject) => {
- const req = indexedDB.open('meshbay_keys', 1);
- req.onupgradeneeded = () => req.result.createObjectStore('k');
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
- });
-}
-async function _storeBundleKey(key) {
- try {
- const db = await _openKeyDB();
- const tx = db.transaction('k', 'readwrite');
- tx.objectStore('k').put(key, 'bk');
- await new Promise(r => { tx.oncomplete = r; });
- db.close();
- } catch {}
-}
-async function _loadBundleKey() {
- try {
- const db = await _openKeyDB();
- const tx = db.transaction('k', 'readonly');
- const g = tx.objectStore('k').get('bk');
- const val = await new Promise(r => { g.onsuccess = () => r(g.result); });
- db.close();
- return val || null;
- } catch { return null; }
-}
-async function _clearKeyDB() {
- try {
- const db = await _openKeyDB();
- const tx = db.transaction('k', 'readwrite');
- tx.objectStore('k').clear();
- await new Promise(r => { tx.oncomplete = r; });
- db.close();
- } catch {}
-}
/**
* Rough passphrase strength, in bits, and what it is up against.
*
@@ -166,112 +65,6 @@ async function _pkXFromSk(skPkcs8B64) {
return pad ? b64 + '='.repeat(4 - pad) : b64;
}
-function loadAuth() {
- try {
- return JSON.parse(localStorage.getItem(AUTH_KEY));
- } catch {
- return null;
- }
-}
-
-function saveAuth(auth) {
- if (auth) {
- localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
- } else {
- localStorage.removeItem(AUTH_KEY);
- _bundleKey = null;
- _clearKeyDB();
- }
-}
-
-// ── Session ──────────────────────────────────────────────────────────────────
-//
-// The access token lasts an hour and the refresh token thirty days. Nothing was
-// using the second: `hubFetch` reported a 401 as an error like any other, so an
-// hour of watching a film — during which the hub hears nothing, because the
-// video comes over WebRTC — ended with "token expired or invalid" and no way
-// out but signing out and back in. Reopening the tab the next day did the same,
-// with a perfectly good refresh token sitting in localStorage beside the stale
-// access one.
-//
-// This lives outside the component because `hubFetch` is a plain function and
-// has to be able to renew a token mid-request without every caller passing the
-// machinery down to it.
-
-let _auth = loadAuth();
-let _onAuthChange = null; // set by App, so the UI follows a background renewal
-let _refreshing = null; // in flight, shared: see refreshAccessToken
-
-function setAuth(auth) {
- _auth = auth;
- saveAuth(auth);
- if (_onAuthChange) _onAuthChange(auth);
-}
-
-/** Seconds until this JWT expires, or null if it says nothing useful. */
-function tokenLifeLeft(token) {
- try {
- const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
- if (!payload.exp) return null;
- return payload.exp - Math.floor(Date.now() / 1000);
- } catch {
- return null; // not a JWT we can read; treat as unknown, never as expired
- }
-}
-
-/**
- * Trade the refresh token for a new pair.
- *
- * The hub rotates: it revokes the token presented and returns a new one, and a
- * revoked token presented again revokes the whole family. So the new one must
- * be stored — the previous code kept only the access token and dropped its
- * replacement, which burned the refresh token on first use and locked the
- * account out of renewal on the second. That is why signing out and in was the
- * only way back.
- *
- * Concurrent callers share one request. Two 401s racing would otherwise send
- * the same refresh token twice, and the second would look exactly like theft.
- */
-async function refreshAccessToken() {
- if (!_auth || !_auth.refreshToken) return null;
- if (_refreshing) return _refreshing;
- _refreshing = (async () => {
- try {
- const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ refresh_token: _auth.refreshToken }),
- });
- if (!r.ok) {
- // Expired, revoked, or the family was torn down. Nothing to salvage:
- // sign out cleanly rather than leave a session that fails every call.
- setAuth(null);
- return null;
- }
- const data = await r.json();
- setAuth({
- ..._auth,
- token: data.access_token,
- refreshToken: data.refresh_token || _auth.refreshToken,
- });
- return data.access_token;
- } catch {
- return null; // offline: keep the session, the next call can try again
- } finally {
- _refreshing = null;
- }
- })();
- return _refreshing;
-}
-
-/** Renew before it bites, rather than after. */
-async function ensureFreshToken() {
- if (!_auth || !_auth.token) return null;
- const left = tokenLifeLeft(_auth.token);
- if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token;
- return refreshAccessToken();
-}
-
// ── Theme ────────────────────────────────────────────────────────────────────
function getInitialTheme() {
@@ -287,38 +80,6 @@ function resolveTheme(pref) {
return pref;
}
-// ── Hub API ──────────────────────────────────────────────────────────────────
-
-async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) {
- const headers = {};
- if (body) headers['Content-Type'] = 'application/json';
- // Prefer the token the session currently holds. Callers read theirs from
- // React state, which is a render behind a renewal that happened in the
- // background — and sending the stale one would 401 for no reason.
- const bearer = token && _auth && _auth.token ? _auth.token : token;
- if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
- const opts = { method, headers };
- if (body) opts.body = JSON.stringify(body);
- const r = await platform.apiFetch(HUB + path, opts);
- if (r.status === 401 && bearer && !_retried) {
- // The one case worth a second attempt: the access token aged out while
- // nothing was talking to the hub. Renew once and replay. If the renewal
- // fails it signs out, and the replay below is skipped.
- const fresh = await refreshAccessToken();
- if (fresh) {
- return hubFetch(path, { method, body, token: fresh, _retried: true });
- }
- }
- if (!r.ok) {
- const err = await r.json().catch(() => ({ detail: r.statusText }));
- const detail = Array.isArray(err.detail)
- ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ')
- : (err.detail || r.statusText);
- throw new Error(String(detail));
- }
- return r.json();
-}
-
// ── Router ───────────────────────────────────────────────────────────────────
function useRoute() {
@@ -331,103 +92,16 @@ function useRoute() {
return hash;
}
-function navigate(path) {
- window.location.hash = path;
-}
-
// ── Context ──────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
function useAuth() { return useContext(AuthContext); }
-// ── Icons ────────────────────────────────────────────────────────────────────
-//
-// One stroked set, drawn in currentColor and sized in em, so an icon takes the
-// weight and colour of the text beside it. The Administration entry was already
-// an outline shield while the rest of the site was colour emoji — a different
-// drawing on every operating system, and never the same line weight twice.
-//
-// The explorer keeps its emoji on purpose. There the icon says what kind of file
-// this is, and the colour is doing real work; a wall of identical grey outlines
-// would be a worse file list.
-
-const ICON_PATHS = {
- menu: ['M4 7h16M4 12h16M4 17h16'],
- bell: ['M18 9a6 6 0 1 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9',
- 'M10.3 20a2 2 0 0 0 3.4 0'],
- shield: ['M12 3l7.5 3v5.2c0 4.6-3.1 8.6-7.5 10.3-4.4-1.7-7.5-5.7-7.5-10.3V6z'],
- globe: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18',
- 'M3.4 9.2h17.2M3.4 14.8h17.2',
- 'M12 3c-2.6 2.4-4 5.6-4 9s1.4 6.6 4 9c2.6-2.4 4-5.6 4-9s-1.4-6.6-4-9'],
- archive: ['M3 7.5h18v3H3z', 'M4.5 10.5V19a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-8.5',
- 'M10 14h4'],
- play: ['M8 5.5v13l11-6.5z'],
- eye: ['M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12',
- 'M12 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5'],
- trash: ['M4 7h16', 'M10 11v6M14 11v6',
- 'M6 7l1 12.5A1.5 1.5 0 0 0 8.5 21h7a1.5 1.5 0 0 0 1.5-1.5L18 7',
- 'M9.5 7V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v2'],
- user: ['M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8',
- 'M4.5 20a7.5 7.5 0 0 1 15 0'],
- gear: ['M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6',
- 'M19.2 14.4a1.7 1.7 0 0 0 .3 1.9 2 2 0 1 1-2.8 2.8 1.7 1.7 0 0 0-2.9 1.2 2 2 0 0 1-4 0 1.7 1.7 0 0 0-2.9-1.2 2 2 0 1 1-2.8-2.8 1.7 1.7 0 0 0-1.2-2.9 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.2-2.9 2 2 0 1 1 2.8-2.8 1.7 1.7 0 0 0 2.9-1.2 2 2 0 0 1 4 0 1.7 1.7 0 0 0 2.9 1.2 2 2 0 1 1 2.8 2.8 1.7 1.7 0 0 0 1.2 2.9 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1z'],
- sun: ['M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6',
- 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4'],
- moon: ['M20.8 13.4A8.6 8.6 0 1 1 10.6 3.2a6.9 6.9 0 0 0 10.2 10.2z'],
- power: ['M12 3.2v8.4', 'M6.9 6.6a7.6 7.6 0 1 0 10.2 0'],
- lock: ['M5.5 11h13a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1V12a1 1 0 0 1 1-1z',
- 'M8 11V7.4a4 4 0 0 1 8 0V11'],
- envelope: ['M4 5.5h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z',
- 'M3.4 6.6L12 13.4l8.6-6.8'],
- door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5',
- 'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'],
- download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'],
- upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'],
- transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5',
- 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'],
- search: ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5'],
- dots: ['M12 5.6h.01', 'M12 12h.01', 'M12 18.4h.01'],
- checkbox: ['M5.5 4h13a1.5 1.5 0 0 1 1.5 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-13A1.5 1.5 0 0 1 5.5 4z'],
- home: ['M4 11.2L12 4.5l8 6.7', 'M6.2 9.8V19a1 1 0 0 0 1 1h9.6a1 1 0 0 0 1-1V9.8'],
- 'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z',
- 'M12 11.4v5', 'M9.5 13.9h5'],
- plus: ['M12 5v14', 'M5 12h14'],
- clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'],
- pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3',
- 'M14.5 6.5l3 3'],
- check: ['M4.5 12.5l5 5 10-11'],
- chevron: ['M6 9.5l6 6 6-6'],
- close: ['M6 6l12 12M18 6L6 18'],
- chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
- folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'],
- 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9',
- 'M10.3 20a2 2 0 0 0 3.4 0',
- 'M4 4l16 16'],
- server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z',
- 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z',
- 'M8 7.5h.01', 'M8 16.5h.01'],
- cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21',
- 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6',
- 'M2 21h.01'],
-};
-
// The M of the wordmark is a picture; the rest is text. Resolved from this
// module's own URL so the hub's fingerprinted path and the application's
// app:// scheme both come out right without either being named here.
const BRAND_M = new URL('./meshbay-m.png', import.meta.url).href;
-function Icon({ name, cls = '' }) {
- const paths = ICON_PATHS[name];
- if (!paths) return null;
- return html`
- <svg class="icon ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"
- fill="none" stroke="currentColor" stroke-width="1.6"
- stroke-linecap="round" stroke-linejoin="round">
- ${paths.map((d, i) => html`<path key=${i} d=${d} />`)}
- </svg>
- `;
-}
-
// ── User Menu ────────────────────────────────────────────────────────────────
function UserMenu({ user, theme, onThemeChange, onLogout }) {
@@ -1298,7 +972,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
await platform.node.setPairingCode(pairResult.code);
- _pendingJoinCode = pairResult.code;
+ session.pendingJoinCode = pairResult.code;
}
update('done');
@@ -1471,3187 +1145,6 @@ function CreateGroupWizard({ token, username, onCreated }) {
</div>`;
}
-// ── Helpers ──────────────────────────────────────────────────────────────────
-
-const FILE_ICONS = {
- video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
- document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}',
-};
-
-function formatSize(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
- return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
-}
-
-function formatDate(ts) {
- return new Date(ts * 1000).toLocaleDateString(undefined, {
- year: 'numeric', month: 'short', day: 'numeric',
- });
-}
-
-// ── Group Page ──────────────────────────────────────────────────────────────
-
-const PREVIEWABLE_TEXT =
- /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
-
-function canPreview(e) {
- return ['image', 'video', 'document'].includes(e.type)
- || PREVIEWABLE_TEXT.test(e.name);
-}
-
-const CHUNK_SIZE = 1024 * 1024;
-// Seconds of already-watched video kept in the SourceBuffer, and the queue depth
-// past which we start making room before being forced to.
-const BUFFER_BEHIND_S = 60;
-// How far past the playhead we are willing to pull. The browser caps a video
-// SourceBuffer at a few hundred megabytes and refuses the append that goes
-// past, so "as fast as the network allows" is not a strategy for a film: the
-// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a
-// ten-megabit second fills the ceiling in the first minute. Buffering by time
-// rather than by bytes keeps a two-hour film and a two-minute clip alike.
-const BUFFER_AHEAD_S = 90;
-// While we deliberately hold credit back, the node must still hear from us: its
-// own stall timeout is two minutes, and a paused film is not a gone viewer.
-const CREDIT_KEEPALIVE_MS = 20000;
-// Segments allowed in flight while there is room to put them. This is a window,
-// topped up as segments land, and not a debt released in one go: accumulating a
-// credit per append and handing the lot over when the buffer finally had room
-// sent 6 MB in a burst, overshot the target by a minute of film, and then said
-// nothing for the next forty-six seconds. Measured in Chrome against real
-// fragmented MP4. A stream that arrives in gulps has no margin for a network
-// that hesitates, and looks like a hang while it is quiet.
-const STREAM_WINDOW = 8;
-// Dragging the scrubber fires `seeking` continuously, and every seek we act on
-// kills an ffmpeg and spawns another. Only where the finger stops is worth a
-// restart.
-const SEEK_DEBOUNCE_MS = 350;
-// A position is remembered per file, in this browser. Below the first threshold
-// there is nothing to resume; above the second the film is finished and
-// offering to resume thirty seconds before the credits is a nuisance.
-const RESUME_MIN_S = 30;
-const RESUME_MAX_FRACTION = 0.97;
-const QUEUE_HIGH_WATER = 12;
-const PIPELINE_WINDOW = 8;
-
-/**
- * Open somewhere to write, honouring the user's download setting.
- *
- * Returns a target ({writable, name}), null for "no stream available — collect
- * it and hand the browser a blob", or false for "the person dismissed the
- * dialog", which is not an error and must not start a transfer.
- */
-async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
- swSize = size) {
- // On a desktop build this is the whole answer, and it comes first.
- //
- // The two browser paths below are both unavailable there — `showDirectoryPicker`
- // does not exist, and Chromium refuses a service worker on a custom scheme —
- // so without this the chain fell all the way through to its floor, which
- // collects the file in the page and hands the browser a blob. A gigabyte of
- // film meant a gigabyte of RAM, and a Save As dialog at the *end*.
- if (platform.capabilities.nativeSave) {
- try {
- const native = await platform.nativeSave(
- filename, { auto: downloads.getMode() === 'auto' });
- // Null means the person dismissed the dialog, which is not an error and
- // must not start a transfer.
- return native || false;
- } catch (err) {
- console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err));
- return false;
- }
- }
-
- try {
- const target = await downloads.openTarget(filename);
- if (target) return target;
- } catch (err) {
- console.warn('[MeshBay] download folder unusable:', err.message);
- }
-
- // No granted folder. A service worker can still hand the browser a stream to
- // write, which is how this works at all in Firefox: the alternative there is
- // to collect gigabytes in a tab. It goes to the browser's own download
- // folder, without a dialog, which is what "save automatically" meant.
- if (downloads.getMode() === 'auto') {
- const streamed = await downloads.openStreamedDownload(filename, swSize);
- if (streamed) return streamed;
- // Nothing to stream to: small enough for memory, and no dialog.
- if (size < downloads.BLOB_LIMIT) return null;
- }
-
- if (!window.showSaveFilePicker) return null;
- try {
- const handle = await window.showSaveFilePicker({
- suggestedName: filename, ...pickerOpts,
- });
- return { writable: await handle.createWritable(), name: handle.name || filename };
- } catch (err) {
- if (err.name === 'AbortError') return false;
- throw err;
- }
-}
-
-/** The download of last resort, for browsers with no way to stream to disk. */
-function _saveBlob(blob, filename) {
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-}
-
-async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
- writable, signal) {
- const results = writable ? null : new Array(totalChunks);
- let nextSend = 0, nextRecv = 0;
- const inflight = new Array(totalChunks);
-
- const fire = () => {
- while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) {
- inflight[nextSend] = transport.fetchChunk(fileId, nextSend);
- nextSend++;
- }
- };
-
- fire();
- while (nextRecv < totalChunks) {
- if (signal && signal.aborted) {
- const err = new Error('Cancelled');
- err.name = 'AbortError';
- throw err;
- }
- const chunkMsg = await inflight[nextRecv];
- let plaintext;
- if (gekKey && chunkMsg.ct) {
- plaintext = await window.MeshBayCrypto.decryptChunkBin(
- gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct);
- } else if (gekKey && chunkMsg.ct_b64) {
- plaintext = await window.MeshBayCrypto.decryptChunk(
- gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64);
- } else {
- plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64);
- }
- if (writable) {
- await writable.write(plaintext);
- } else {
- results[nextRecv] = plaintext;
- }
- nextRecv++;
- fire();
- if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks);
- }
- return results;
-}
-
-function GroupPage({ groupId, group, token, username, userId, userPrefs,
- onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) {
- const [status, setStatus] = useState('idle');
- const [entries, setEntries] = useState([]);
-
- const [error, setError] = useState('');
- const [selecting, setSelecting] = useState(false);
- const [selected, setSelected] = useState(() => new Set());
- const [editingDesc, setEditingDesc] = useState(false);
- const [descDraft, setDescDraft] = useState('');
- const [savingDesc, setSavingDesc] = useState(false);
- const [sortKey, setSortKey] = useState('name');
- const [sortAsc, setSortAsc] = useState(true);
- const [filter, setFilter] = useState('');
- const [currentPath, setCurrentPath] = useState('');
- const [videoEntry, setVideoEntry] = useState(null);
- const [previewEntry, setPreviewEntry] = useState(null);
- const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat';
- const [tab, setTab] = useState(defaultTab);
- useEffect(() => { setTab(defaultTab); }, [groupId]);
- // A directory from the group just left rarely exists in the one just
- // entered (e.g. "outputs" in one group, absent in another) — Files would
- // otherwise show that stale path and list nothing.
- useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]);
- const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted));
-
- const _lastTouch = useRef(0);
- const touchActivity = useCallback(() => {
- const now = Date.now();
- if (now - _lastTouch.current < 60_000) return;
- _lastTouch.current = now;
- const ts = new Date().toISOString();
- if (onGroupUpdated) onGroupUpdated(groupId, { last_activity_at: ts });
- hubFetch(`/v1/groups/${groupId}/activity`, { method: 'POST', token }).catch(() => {});
- }, [groupId, token, onGroupUpdated]);
-
- const toggleGroupMute = useCallback(async () => {
- const next = !groupMuted;
- setGroupMuted(next);
- try {
- await hubFetch(`/v1/groups/${groupId}/mute`, {
- method: 'POST', token, body: { muted: next },
- });
- if (onGroupUpdated) onGroupUpdated(groupId, { muted: next });
- } catch (err) {
- setGroupMuted(!next);
- }
- }, [groupMuted, groupId, token, onGroupUpdated]);
-
- // Directories are not index entries, so a new empty one needs a nudge
- // to appear in the breadcrumb listing.
- const [nodeDirs, setNodeDirs] = useState([]);
- // The group's roots and whether each is readable. A root whose drive is
- // unplugged keeps its files listed — they are frozen, not deleted — so this is
- // the only thing that lets the UI say which of the two it is.
- const [nodeRoots, setNodeRoots] = useState([]);
-
- const [isNodeAdmin, setIsNodeAdmin] = useState(false);
- // Whether ordinary members may upload here. The node decides and enforces it;
- // this only says whether to offer the controls. Defaults to true so a node
- // that predates the setting behaves as it always did.
- const [memberUpload, setMemberUpload] = useState(true);
- // Paired ≠ operator account. `is_node_admin` says the hub account owning this
- // node is the one connecting; this says the node pinned *this browser's* key
- // as an operator key. Only the second one lets you sign an invite, and only
- // the second one should make the pairing form go away.
- const [operatorPaired, setOperatorPaired] = useState(false);
- const [needsCode, setNeedsCode] = useState(false);
- // This browser holds a key the node does not know, for an account it does.
- // Not the operator's problem: a device already paired here can admit it.
- const [needsDevice, setNeedsDevice] = useState(false);
- const [deviceCode, setDeviceCode] = useState('');
- const [codeInput, setCodeInput] = useState('');
- const [retryKey, setRetryKey] = useState(0);
- const transportRef = useRef(null);
- const gekRef = useRef(null);
- // One refresh per mount: if a fresh token still says we are not a member, we
- // really are not, and retrying forever would hide that.
- const refreshedRef = useRef(false);
-
- const submitJoinCode = useCallback((e) => {
- e.preventDefault();
- const code = codeInput.trim();
- if (!code) return;
- _pendingJoinCode = code;
- setCodeInput('');
- setNeedsCode(false);
- setError('');
- setRetryKey(k => k + 1);
- }, [codeInput]);
-
- // One place that takes an index from the node and puts it everywhere it has to
- // go. Deleting a file used to refresh the table and leave the cache alone, so
- // the search page went on offering a file that no longer existed until the
- // group was reconnected.
- const applyIndex = useCallback((indexMsg) => {
- const fresh = indexMsg.entries || [];
- setEntries(fresh);
- if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
- if (indexMsg.roots) setNodeRoots(indexMsg.roots);
- cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
- }, [groupId, group]);
-
- useEffect(() => {
- let cancelled = false;
-
- // The cache is written here and read only by the search page. It used to
- // seed this list too, which put a stale index on screen and then raced the
- // live one: IndexedDB is async, so a fast node could be overwritten by the
- // cache landing afterwards. Files shows what the node says, or says it
- // cannot reach the node.
-
- const connect = async () => {
- setStatus('discovering');
- setError('');
- gekRef.current = null;
- if (!_bundleKey) _bundleKey = await _loadBundleKey();
- try {
- const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
- if (cancelled) return;
- if (!nodesData.nodes || nodesData.nodes.length === 0) {
- setStatus('offline');
- if (onPresence) onPresence(groupId, 'offline');
- return;
- }
-
- // No keys are carried in: the transport fetches this node's identity
- // from the node, or creates one there on a first join.
- const sessionKeys = null;
-
- setStatus('connecting');
- const nodeId = nodesData.nodes[0].node_id;
- // Renewed here rather than taken from the prop. This effect no longer
- // re-runs when the token rotates (see the dependency list below), so
- // the captured one can be older than the session's — and it is used to
- // sign the offer to the hub, where an expired one is a 401 and no
- // connection at all. Renewals are shared, so if one is already in
- // flight this waits for it instead of starting a second.
- const live = (await ensureFreshToken()) || token;
- // The same base the API calls use: signaling is a hub endpoint like
- // any other, and two sources for one address is how they drift.
- const transport = new window.MeshBayTransport(HUB, live);
- transportRef.current = transport;
-
- const ack = await transport.connect(
- nodeId, live, groupId, null, sessionKeys, _bundleKey, username,
- userId, _pendingJoinCode);
- _pendingJoinCode = null;
- if (cancelled) return;
- setIsNodeAdmin(!!ack.is_node_admin);
- setMemberUpload(ack.member_upload !== false);
- // Changed while we are connected, by an operator who may be someone
- // else entirely. Without this the button stays until a reconnection,
- // and a button that is still there is a button people press.
- transport.onUploadPolicy = (allowed) => setMemberUpload(allowed);
- setOperatorPaired(transport.memberRole === 'operator');
-
- // A first join to this node generated an identity for it; leave it with
- // the node so any other browser can become the same person here with the
- // passphrase. It is this node's key and no other's.
- if (transport.connected && transport.newNodeBundle) {
- try {
- await transport.storeKeypairBundle(transport.newNodeBundle);
- transport.newNodeBundle = null;
- } catch (e) {
- console.warn('[MeshBay] could not leave our key with the node:', e.message);
- }
- }
-
- // Import GEK from transport (fetched from node during handshake)
- if (transport.gekRaw && window.MeshBayCrypto) {
- gekRef.current = await window.MeshBayCrypto.importGEK(
- window.MeshBayCrypto.b64encode(transport.gekRaw));
- }
-
- setStatus('fetching');
-
- transport.onIndexSync = (msg) => {
- if (cancelled) return;
- applyIndex(msg);
- };
-
- // We are in: an invitation to this group has served its purpose.
- if (onJoined) onJoined(groupId);
-
- const indexMsg = await transport.fetchIndex();
- if (cancelled) return;
- applyIndex(indexMsg);
- setStatus('connected');
- touchActivity();
- // First-hand evidence, and the strongest available: this browser spoke
- // to the node. It outranks whatever the hub said in the group list.
- if (onPresence) onPresence(groupId, 'online');
- } catch (err) {
- if (cancelled) return;
-
- // Our token predates being added to this group. Refresh once and retry
- // rather than telling someone who was just invited that they are not a
- // member — which is what the node honestly sees, and is useless to them.
- if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) {
- refreshedRef.current = true;
- try {
- if (await onRefreshAuth()) {
- setRetryKey(k => k + 1);
- return;
- }
- } catch { /* fall through to the message below */ }
- }
-
- // The node has never seen this browser for this account: it needs a
- // one-time code from the operator before it will hand over the group
- // key. Not an error to shout about — a step in joining.
- if (err.reason === 'code_required') setNeedsCode(true);
- // A key this node has never pinned, for an account it knows. The way in
- // is a device already trusted here, not an operator — which is the
- // whole point of device linking: a second browser or a native client
- // must not cost anyone a support request.
- if (err.reason === 'unknown_device') setNeedsDevice(true);
- setError(err.message);
- setStatus('error');
- if (transportRef.current) {
- try { transportRef.current.close(); } catch { /* already gone */ }
- transportRef.current = null;
- }
- // A refusal means the node answered, so it is up; only a failure to
- // reach it at all is evidence of absence.
- if (onPresence) {
- onPresence(groupId, err.reason ? 'online' : 'offline');
- }
- }
- };
-
- if (token && window.MeshBayTransport) {
- connect();
- } else if (!window.MeshBayTransport) {
- setStatus('error');
- setError(t('group.err_transport'));
- }
-
- return () => {
- cancelled = true;
- if (transportRef.current) {
- // Handed over rather than closed: a download running when you leave the
- // group keeps its connection, and the last transfer using it closes it.
- transfers.releaseWhenIdle(transportRef.current);
- transportRef.current = null;
- }
- };
- // applyIndex is deliberately not a dependency: its identity changes with the
- // `group` object, which the hub poll re-creates, and re-running this effect
- // means tearing down the WebRTC connection. groupId is here, so a real group
- // change still re-captures it.
- //
- // Neither is the token itself, only whether there is one. It used to be a
- // dependency and that was harmless while a token never changed during a
- // session — it only expired. Now that the session renews itself, the string
- // rotates, and this effect tore the WebRTC connection down and rebuilt it
- // every time. Worst on arrival: a stored token past its life is renewed the
- // instant the page mounts, which is exactly when the group page is
- // negotiating ICE, so the connection was abandoned mid-handshake and the
- // node sat in `connecting` for ever. The live token is read inside
- // `connect()` instead. Signing out unmounts this page; signing in mounts
- // it; nothing in between should disturb a working connection.
- }, [groupId, Boolean(token), retryKey]);
-
- const downloadFile = useCallback(async (entry) => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- const gek = gekRef.current;
-
- // Both of these have to happen inside the click: a browser grants a file
- // picker, and re-grants a folder, only from a user gesture.
- const target = await _openDownloadTarget(entry.name, entry.size);
- if (target === false) return; // the picker was dismissed
-
- const openRef = { url: null };
- transfers.start({
- kind: 'download', name: (target && target.name) || entry.name,
- total: entry.size, transport,
- open: target
- ? (target.open || null)
- : () => { if (openRef.url) window.open(openRef.url, '_blank'); },
- run: async ({ signal, onProgress }) => {
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- let done = 0;
- const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
-
- if (target) {
- try {
- await pipelinedDownload(transport, gek, entry.id, totalChunks,
- onChunk, target.writable, signal);
- await target.writable.close();
- } catch (err) {
- await target.writable.abort().catch(() => {});
- throw err;
- }
- } else {
- const chunks = await pipelinedDownload(
- transport, gek, entry.id, totalChunks, onChunk, null, signal);
- const blob = new Blob(chunks);
- _saveBlob(blob, entry.name);
- openRef.url = URL.createObjectURL(blob);
- }
- },
- });
- }, []);
-
- const uploadFile = useCallback((e) => {
- const files = [...(e.target.files || [])];
- e.target.value = '';
- const transport = transportRef.current;
- if (!files.length || !transport || !transport.connected) return;
- setError('');
-
- for (const file of files) {
- transfers.start({
- kind: 'upload', name: file.name, total: file.size, transport,
- run: async ({ signal, onProgress }) => {
- await transport.uploadFile(file, {
- // Bytes the node acknowledged, not bytes read locally.
- onProgress: (sent) => onProgress(sent, file.size),
- signal,
- });
- // The node re-indexes on a filesystem event, so there is nothing to
- // wait on but the clock. Refreshing here means the file appears in
- // the list without anyone reloading.
- await new Promise(r => setTimeout(r, 2500));
- if (transport.connected) applyIndex(await transport.fetchIndex());
- },
- });
- }
- }, [applyIndex]);
-
- const makeDirectory = useCallback(async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- const name = prompt(t('group.mkdir_prompt'));
- if (!name || !name.trim()) return;
- try {
- await transport.createDirectory(currentPath, name.trim());
- const indexMsg = await transport.fetchIndex();
- if (indexMsg.entries) setEntries(indexMsg.entries);
- if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
- if (indexMsg.roots) setNodeRoots(indexMsg.roots);
- } catch (err) {
- setError(err.message);
- }
- }, [currentPath]);
-
- const saveDescription = useCallback(async (e) => {
- e.preventDefault();
- setSavingDesc(true);
- try {
- const r = await hubFetch(`/v1/groups/${groupId}`, {
- method: 'PATCH', token, body: { description: descDraft },
- });
- if (onGroupUpdated) onGroupUpdated(groupId, { description: r.description });
- setEditingDesc(false);
- } catch (err) {
- setError(err.message);
- } finally {
- setSavingDesc(false);
- }
- }, [groupId, token, descDraft, onGroupUpdated]);
-
- /**
- * Download a directory as a zip, written straight to disk.
- *
- * An archive of a group directory is routinely tens of gigabytes, so it is
- * never held anywhere: each file is fetched chunk by chunk, decrypted, and
- * handed to the zip writer, which hands it to the file the browser opened.
- * Peak memory is one chunk plus one small record per file.
- *
- * Without the File System Access API there is nowhere to stream to, and the
- * only alternative is to build the whole thing in memory — so that path is
- * offered but says what it costs first.
- */
- const downloadDirectory = useCallback(async (dir) => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
-
- const files = entriesUnder(entries, dir);
- if (!files.length) {
- setError(t('group.zip_empty'));
- return;
- }
- const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
- const suggested = (dir.split('/').pop() || 'files') + '.zip';
-
- // totalBytes decides how this is delivered, but it is not the archive's
- // size — headers and the central directory come on top — so it is not
- // announced as a Content-Length that the download would then miss.
- const target = await _openDownloadTarget(suggested, totalBytes, {
- types: [{ description: 'ZIP archive',
- accept: { 'application/zip': ['.zip'] } }],
- }, 0);
- if (target === false) return;
- if (!target && !confirm(t('group.zip_no_stream', {
- size: formatSize(totalBytes), name: suggested,
- }))) {
- return;
- }
- const gek = gekRef.current;
- const zipOpenRef = { url: null };
-
- transfers.start({
- kind: 'download', name: (target && target.name) || suggested,
- total: totalBytes, transport,
- open: target
- ? (target.open || null)
- : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); },
- run: async ({ signal, onProgress }) => {
- const writable = target ? target.writable : null;
- const parts = writable ? null : [];
- let written = 0;
- try {
- const zip = new ZipStream(async (bytes) => {
- if (writable) await writable.write(bytes);
- else parts.push(bytes.slice());
- });
-
- for (const { entry, name } of files) {
- await zip.begin(name, entry.size,
- new Date((entry.added_at || 0) * 1000));
- // A zero-byte file has no chunk to ask for; the header and an empty
- // descriptor are the whole entry.
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- if (totalChunks > 0) await pipelinedDownload(
- transport, gek, entry.id, totalChunks,
- (bytes) => { written += bytes; onProgress(written, totalBytes); },
- // pipelinedDownload writes in order, which the archive needs.
- { write: (plaintext) => zip.write(plaintext) }, signal);
- await zip.end();
- }
- await zip.finish();
- if (writable) await writable.close();
- else {
- const blob = new Blob(parts, { type: 'application/zip' });
- _saveBlob(blob, suggested);
- zipOpenRef.url = URL.createObjectURL(blob);
- }
- } catch (err) {
- if (writable) await writable.abort().catch(() => {});
- throw err;
- }
- },
- });
- }, [entries]);
-
- const deleteDirectory = useCallback(async (dir) => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- try {
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.deleteDirectory(dir, signFn);
- applyIndex(await transport.fetchIndex());
- } catch (err) {
- setError(err.message);
- }
- }, [applyIndex]);
-
- const deleteFile = useCallback(async (entry) => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- try {
- // Signs an explicit transcript built by transport.js, not opaque bytes from
- // the node — see MeshBayCrypto.adminTranscript and finding H5.
- // Signed with the identity this node pinned for us — the only one it
- // will accept, and the only one we hold here.
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.deleteFile(entry.id, signFn);
- applyIndex(await transport.fetchIndex());
- } catch (err) {
- setError(err.message);
- }
- }, [applyIndex]);
-
- const refreshIndex = useCallback(async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- try {
- applyIndex(await transport.fetchIndex());
- } catch {}
- }, [applyIndex]);
-
- const toggleSort = useCallback((key) => {
- setSortAsc(prev => sortKey === key ? !prev : true);
- setSortKey(key);
- }, [sortKey]);
-
- const dirs = new Set();
- const filteredEntries = entries.filter(e => {
- const ePath = e.path || '';
- if (ePath === currentPath) {
- return !filter || e.name.toLowerCase().includes(filter.toLowerCase());
- }
- if (!currentPath && ePath) {
- dirs.add(ePath.split('/')[0]);
- } else if (currentPath && ePath.startsWith(currentPath + '/')) {
- const rest = ePath.slice(currentPath.length + 1);
- dirs.add(rest.split('/')[0]);
- }
- return false;
- });
-
- const sorted = [...filteredEntries].sort((a, b) => {
- let cmp = 0;
- if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
- else if (sortKey === 'size') cmp = a.size - b.size;
- else if (sortKey === 'type') cmp = a.type.localeCompare(b.type);
- else if (sortKey === 'date') cmp = a.added_at - b.added_at;
- return sortAsc ? cmp : -cmp;
- });
-
- // The node's own listing, so an empty folder is visible, plus anything implied
- // by a file path in case the two ever disagree.
- for (const d of nodeDirs) {
- if (!currentPath && !d.includes('/')) dirs.add(d);
- else if (currentPath && d.startsWith(currentPath + '/')) {
- const rest = d.slice(currentPath.length + 1);
- if (!rest.includes('/')) dirs.add(rest);
- }
- }
- const subdirs = [...dirs].sort();
-
- // At the top of a group the folders on screen ARE the roots, so their state
- // belongs there. Deeper in, everything shown lives inside one readable root
- // and there is nothing to flag.
- const rootState = new Map(nodeRoots.map(r => [r.name, r]));
- const unavailableHere = currentPath
- ? []
- : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false);
- // A member cannot create a folder at the top of a group: that level is the
- // set of roots, which is the operator's configuration and not a directory on
- // anyone's disk. The node refuses it, so offering it would only produce an
- // error nobody can act on.
- const canCreateDir = Boolean(currentPath) && isNodeAdmin;
-
- const baseLabel = {
- idle: t('status.idle'),
- discovering: t('status.discovering'),
- connecting: t('status.connecting'),
- fetching: t('status.fetching'),
- connected: t('status.files', { n: entries.length }),
- offline: t('status.offline'),
- error: t('status.error'),
- }[status] || status;
- const statusLabel = baseLabel;
-
- const breadcrumbs = currentPath ? currentPath.split('/') : [];
-
- // Selection is keyed globally — file ids, and 'dir:' plus a full path — so
- // walking into another folder keeps what was already ticked.
- const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name);
- const selectedFiles = entries.filter(e => selected.has(e.id));
- const selectedDirs = [...selected]
- .filter(k => typeof k === 'string' && k.startsWith('dir:'))
- .map(k => k.slice(4));
- const toggle = (key) => setSelected(prev => {
- const next = new Set(prev);
- if (next.has(key)) next.delete(key); else next.add(key);
- return next;
- });
-
- const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0
- ? selectedFiles[0] : null;
- const deletableFiles = selectedFiles.filter(
- e => isNodeAdmin || (userId && e.uploader_id === userId));
-
- // Asked in two places — the Files toolbar and the chat composer — so it is
- // answered once. The operator is never locked out of their own node.
- const mayUpload = memberUpload || isNodeAdmin;
- const run = (fn) => {
- setSelecting(false);
- setSelected(new Set());
- Promise.resolve().then(fn).catch(err => {
- if (err && err.name !== 'AbortError') setError(err.message);
- });
- };
-
- // Icon only, with the name in the tooltip: these sit in a toolbar that is
- // already narrow, and every one of them is a verb the icon carries on its
- // own. `title` gives the hover text and `aria-label` the accessible name —
- // an icon button with neither is unusable with a screen reader.
- //
- // Every action is rendered as soon as Select is on, and the ones that do not
- // apply are disabled rather than absent. Buttons appearing and vanishing as
- // the selection changed made the bar jump about and gave no clue that an
- // action existed at all before something was ticked.
- const action = (icon, label, onClick, opts = {}) => html`
- <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}"
- title=${label} aria-label=${label}
- disabled=${!!opts.disabled} onClick=${onClick}>
- <${Icon} name=${icon} />
- </button>
- `;
-
- const canPlay = !!(onlyFile && onlyFile.type === 'video');
- const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile));
- const deletableCount = deletableFiles.length
- + (operatorPaired ? selectedDirs.length : 0);
- // The operator can always delete; anyone else only ever sees the button if
- // something here is theirs to remove. Hiding it from an uploader would take
- // away a right the protocol grants them (draft-v5 §5.1), not just a control.
- const mayEverDelete = isNodeAdmin
- || (userId && entries.some(e => e.uploader_id === userId));
-
- const actionItems = html`
- ${action('play', t('group.play'),
- () => run(() => setVideoEntry(onlyFile)), { disabled: !canPlay })}
- ${action('eye', t('group.view'),
- () => run(() => setPreviewEntry(onlyFile)), { disabled: !canView })}
- ${action('download',
- selectedFiles.length
- ? t('group.download_n', { n: selectedFiles.length })
- : t('group.download'),
- () => run(async () => {
- // Awaited one at a time, and each returns as soon as its transfer is
- // registered — so the transfers still run together. Firing them without
- // awaiting meant every file asked the browser for a save dialog at
- // once, and a browser allows one: the rest were rejected and only the
- // first file ever downloaded.
- for (const e of selectedFiles) await downloadFile(e);
- }), { disabled: selectedFiles.length === 0 })}
- ${action('archive',
- selectedDirs.length
- ? t('group.download_zip_n', { n: selectedDirs.length })
- : t('group.download_zip_n', { n: 0 }),
- () => run(async () => {
- for (const d of selectedDirs) await downloadDirectory(d);
- }), { disabled: selectedDirs.length === 0 })}
- ${mayEverDelete && action('trash',
- deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'),
- () => {
- const names = [...deletableFiles.map(e => e.name),
- ...(operatorPaired ? selectedDirs : [])];
- if (!confirm(t('group.delete_n_confirm', { n: names.length,
- names: names.join(', ') }))) return;
- run(() => {
- for (const e of deletableFiles) deleteFile(e);
- if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d);
- });
- },
- { danger: true, disabled: status !== 'connected' || deletableCount === 0 })}
- `;
-
- return html`
- <div>
- <div class="group-header">
- <div>
- <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}">
- ${group ? group.name : t('group.default_name')}
- </h2>
- ${editingDesc
- ? html`
- <form class="group-desc-edit" onSubmit=${saveDescription}>
- <textarea rows="2" maxlength="512" autofocus
- placeholder="${t('group.desc_placeholder')}"
- value=${descDraft}
- onInput=${e => setDescDraft(e.target.value)}></textarea>
- <div>
- <button class="admin-btn" type="submit" disabled=${savingDesc}>
- ${savingDesc ? '...' : t('group.desc_save')}
- </button>
- <button class="btn-secondary" type="button"
- onClick=${() => setEditingDesc(false)}>${t('group.desc_cancel')}</button>
- </div>
- </form>
- `
- : html`
- ${group && group.description && html`
- <p class="group-desc">${group.description}</p>
- `}
- ${group && group.is_admin && html`
- <button class="link-btn" title=${t('group.desc_edit')}
- onClick=${() => { setDescDraft(group.description || '');
- setEditingDesc(true); }}>
- <${Icon} name="pencil" />${' '}
- ${group.description ? t('group.desc_edit') : t('group.desc_add')}
- </button>
- `}
- `}
- </div>
- ${group && html`
- <button class="group-mute-btn" onClick=${toggleGroupMute}
- title=${groupMuted ? t('group.unmute') : t('group.mute')}>
- <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} />
- </button>
- `}
- </div>
- ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '}
- <button class="admin-btn" style="margin-left:8px;font-size:0.9em"
- onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
- </div>`}
- ${needsDevice && html`
- <div class="invite-form" style="margin-bottom:12px">
- <h4>${t('device.add_title')}</h4>
- <p class="settings-hint">${t('device.add_hint')}</p>
- ${!deviceCode && html`
- <button class="admin-btn" onClick=${async () => {
- try {
- const transport = transportRef.current;
- const out = await transport.requestDeviceAdd(userId);
- setDeviceCode(out.code);
- } catch (err) { setError(err.message); }
- }}>${t('device.add_btn')}</button>
- `}
- ${deviceCode && html`
- <p class="settings-hint">${t('device.add_show')}</p>
- <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px">
- ${deviceCode}
- </p>
- `}
- </div>
- `}
- ${needsCode && html`
- <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
- <h4>${t('group.join_code_title')}</h4>
- <p class="settings-hint">${t('group.join_code_hint')}</p>
- <div style="display:flex;gap:8px">
- <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
- value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required />
- <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button>
- </div>
- </form>
- `}
- ${/* Not gated on the connection any more. Leaving a group, deleting it
- and seeing who is in it are hub-side, and moving them into this tab
- would otherwise have made them unreachable exactly when a node is
- down — which is when someone is most likely to want them. Files and
- chat still need the node and say so. */ group && html`
- <div class="group-tabs">
- <button class="group-tab ${tab === 'chat' ? 'active' : ''}"
- onClick=${() => setTab('chat')} title=${t('group.tab_chat')}>
- <${Icon} name="chat" cls="tab-icon" /></button>
- <button class="group-tab ${tab === 'files' ? 'active' : ''}"
- onClick=${() => setTab('files')} title=${t('group.tab_files')}>
- <${Icon} name="folder" cls="tab-icon" /></button>
- <button class="group-tab ${tab === 'settings' ? 'active' : ''}"
- onClick=${() => setTab('settings')} title=${t('group.tab_settings')}>
- <${Icon} name="gear" cls="tab-icon" /></button>
- </div>
-
- ${tab === 'files' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
- `}
- ${tab === 'files' && status === 'offline' && html`
- <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
- `}
-
- ${tab === 'files' && status === 'connected' && html`
- <div class="file-toolbar">
- <div class="toolbar-group">
- ${mayUpload && html`
- <label class="tb-btn primary">
- <${Icon} name="upload" /> ${t('group.upload')}
- <input type="file" multiple style="display:none"
- onChange=${uploadFile} />
- </label>
- `}
- ${canCreateDir && html`
- <button class="tb-btn" onClick=${makeDirectory}>
- <${Icon} name="folder-plus" /> ${t('group.mkdir')}
- </button>
- `}
- </div>
-
- <div class="breadcrumbs">
- <a class="crumb" onClick=${() => setCurrentPath('')}>
- <${Icon} name="home" />
- </a>
- ${breadcrumbs.map((seg, i) => {
- const path = breadcrumbs.slice(0, i + 1).join('/');
- return html`
- <span class="crumb-sep">/</span>
- <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a>
- `;
- })}
- </div>
-
- <div class="toolbar-group right">
- <div class="tb-search">
- <${Icon} name="search" />
- <input type="text" placeholder="${t('group.filter')}"
- value=${filter} onInput=${e => setFilter(e.target.value)} />
- </div>
- <button class="tb-btn ${selecting ? 'active' : ''}"
- onClick=${() => {
- setSelecting(v => !v);
- setSelected(new Set());
- }}>
- <${Icon} name=${selecting ? 'check' : 'checkbox'} />
- ${selecting ? t('group.select_done') : t('group.select')}
- </button>
- ${selecting && html`<div class="tb-actions">${actionItems}</div>`}
- </div>
- </div>
- <table class="file-table">
- <thead>
- <tr>
- ${selecting && html`<th class="sel-cell"></th>`}
- <th></th>
- <th class="sortable" onClick=${() => toggleSort('name')}>
- ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- <th class="sortable" onClick=${() => toggleSort('size')}>
- ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- <th class="sortable th-type" onClick=${() => toggleSort('type')}>
- ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- <th class="sortable th-date" onClick=${() => toggleSort('date')}>
- ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
- </th>
- </tr>
- </thead>
- <tbody>
- ${subdirs.map(d => {
- const full = currentPath ? currentPath + '/' + d : d;
- const inside = entriesUnder(entries, full);
- const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
- return html`
- <tr class="file-row dir-row" key=${full} onClick=${() =>
- selecting ? toggle(dirKey(d)) : setCurrentPath(full)}>
- ${selecting && html`
- <td class="sel-cell">
- <input type="checkbox" checked=${selected.has(dirKey(d))}
- onClick=${(ev) => ev.stopPropagation()}
- onChange=${() => toggle(dirKey(d))} />
- </td>
- `}
- <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${unavailableHere.includes(d) ? html`
- <span class="root-offline"> ${t('group.root_unavailable')}</span>
- ` : ''}</td>
- <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
- <td class="td-type"></td>
- <td class="td-date"></td>
- </tr>
- `; })}
- ${sorted.map(e => html`
- <tr class="file-row" key=${e.id}
- onClick=${() => selecting && toggle(e.id)}>
- ${selecting && html`
- <td class="sel-cell">
- <input type="checkbox" checked=${selected.has(e.id)}
- onClick=${(ev) => ev.stopPropagation()}
- onChange=${() => toggle(e.id)} />
- </td>
- `}
- <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
- <td class="file-name">
- ${!selecting && canPreview(e)
- ? html`<a class="file-link" onClick=${() => {
- if (e.type === 'video') setVideoEntry(e);
- else setPreviewEntry(e);
- }}>${e.name}</a>`
- : e.name
- }
- </td>
- <td class="file-size">${formatSize(e.size)}</td>
- <td class="file-type td-type">${e.type}</td>
- <td class="file-date td-date">${formatDate(e.added_at)}</td>
- </tr>
- `)}
- ${sorted.length === 0 && subdirs.length === 0 && html`
- <tr><td colspan=${selecting ? 6 : 5} class="file-empty">
- ${filter ? t('group.empty_filter') : t('group.empty_dir')}
- </td></tr>
- `}
- </tbody>
- </table>
- `}
-
- ${tab === 'chat' && status === 'connected' && html`
- <${ChatPanel} transportRef=${transportRef} username=${username}
- entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex}
- mayUpload=${mayUpload} onActivity=${touchActivity}
- onPreview=${(entry) => {
- if (entry.type === 'video') setVideoEntry(entry);
- else setPreviewEntry(entry);
- }} />
- `}
- ${tab === 'chat' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
- `}
- ${tab === 'chat' && status === 'offline' && html`
- <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
- `}
-
- ${tab === 'settings' && html`
- <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token}
- transportRef=${transportRef} gekRef=${gekRef}
- isNodeAdmin=${isNodeAdmin} userId=${userId}
- operatorPaired=${operatorPaired} connected=${status === 'connected'}
- memberUpload=${memberUpload}
- onMemberUpload=${(allowed) => setMemberUpload(allowed)}
- onLeft=${onLeft}
- onPaired=${() => setOperatorPaired(true)} />
- `}
- `}
- ${status === 'offline' && !group && html`
- <p class="page-message">
- ${t('group.offline_title')}
- ${' '}${t('group.offline_hint')}
- </p>
- `}
- ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
- `}
- ${previewEntry && html`
- <${FilePreview}
- entry=${previewEntry}
- transportRef=${transportRef}
- gekRef=${gekRef}
- onClose=${() => setPreviewEntry(null)}
- onDownload=${() => downloadFile(previewEntry)} />
- `}
- ${videoEntry && html`
- <${VideoPlayer}
- entry=${videoEntry}
- transportRef=${transportRef}
- gekRef=${gekRef}
- onClose=${() => setVideoEntry(null)}
- onDownload=${() => downloadFile(videoEntry)} />
- `}
- </div>
- `;
-}
-
-// ── File Preview (text, images) ─────────────────────────────────────────
-
-const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
-const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i;
-
-function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
- const [phase, setPhase] = useState('loading');
- const [progress, setProgress] = useState(0);
- const [content, setContent] = useState(null);
- const [error, setError] = useState('');
- const [downloading, setDownloading] = useState(false);
- const blobUrlRef = useRef(null);
-
- useEffect(() => {
- let cancelled = false;
- const load = async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) {
- setError(t('video.err_transport'));
- setPhase('error');
- return;
- }
- try {
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- let downloaded = 0;
- const chunks = await pipelinedDownload(
- transport, gekRef.current, entry.id, totalChunks,
- (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); },
- );
- if (cancelled) return;
-
- if (/\.pdf$/i.test(entry.name)) {
- // Decrypted here and shown from a blob: URL — the bytes never leave
- // the page, and the browser's own viewer renders them.
- const blob = new Blob(chunks, { type: 'application/pdf' });
- blobUrlRef.current = URL.createObjectURL(blob);
- setContent({ type: 'pdf' });
- } else if (entry.name.match(IMAGE_EXTS)) {
- const ext = entry.name.split('.').pop().toLowerCase();
- const mime = ext === 'svg' ? 'image/svg+xml'
- : ext === 'png' ? 'image/png'
- : ext === 'gif' ? 'image/gif'
- : ext === 'webp' ? 'image/webp'
- : 'image/jpeg';
- const blob = new Blob(chunks, { type: mime });
- blobUrlRef.current = URL.createObjectURL(blob);
- setContent({ type: 'image' });
- } else {
- const decoder = new TextDecoder('utf-8', { fatal: false });
- const text = chunks.map(c => decoder.decode(c, { stream: true })).join('');
- setContent({ type: 'text', text: text.slice(0, 500000) });
- }
- setPhase('ready');
- } catch (err) {
- if (!cancelled) { setError(err.message); setPhase('error'); }
- }
- };
- load();
- return () => { cancelled = true; };
- }, [entry]);
-
- useEffect(() => {
- const onKey = (e) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [onClose]);
-
- useEffect(() => {
- return () => {
- if (blobUrlRef.current) {
- URL.revokeObjectURL(blobUrlRef.current);
- blobUrlRef.current = null;
- }
- };
- }, []);
-
- return html`
- <div class="video-overlay" onClick=${(e) => {
- if (e.target.classList.contains('video-overlay')) onClose();
- }}>
- <div class="video-top-bar">
- <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
- ${onDownload && html`
- <button class="video-close ${downloading ? 'dl-active' : ''}"
- onClick=${() => {
- if (!downloading) {
- setDownloading(true);
- onDownload();
- setTimeout(() => setDownloading(false), 1500);
- }
- }}
- title="${t('group.download')}" disabled=${downloading}>
- ${downloading
- ? html`<span class="spinner"></span>`
- : html`<${Icon} name="download" />`}</button>
- `}
- <button class="video-close" onClick=${onClose} title="${t('video.close')}">
- <${Icon} name="close" /></button>
- </div>
- ${phase === 'loading' && html`
- <div class="video-loading">
- <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
- <div class="video-progress-bar">
- <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div>
- </div>
- </div>
- `}
- ${phase === 'ready' && content?.type === 'pdf' && html`
- <object data=${blobUrlRef.current} type="application/pdf"
- class="preview-pdf" aria-label=${entry.name}>
- <p class="page-message">${t('preview.pdf_fallback')}</p>
- </object>
- `}
- ${phase === 'ready' && content?.type === 'image' && html`
- <div class="preview-image-wrap">
- <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} />
- </div>
- `}
- ${phase === 'ready' && content?.type === 'text' && html`
- <div class="preview-text-wrap">
- <pre class="preview-text">${content.text}</pre>
- </div>
- `}
- ${phase === 'error' && html`
- <div class="video-error">${error}</div>
- `}
- </div>
- `;
-}
-
-function _b64ToU8(b64) {
- const bin = atob(b64);
- const arr = new Uint8Array(bin.length);
- for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
- return arr;
-}
-
-// ── Members Panel ────────────────────────────────────────────────────────
-
-/**
- * Everything about the group that is not its files or its chat.
- *
- * Was "Members", which was a list with three unrelated forms stacked on top of
- * it and the group's own controls somewhere else entirely — leaving or deleting
- * a group lived in the header, beside its title. One tab now, in sections, with
- * the roster last: it is the part that grows without limit, and burying the
- * controls under two hundred names is how a tab stops being usable.
- */
-function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
- isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload,
- onPaired, onLeft }) {
- const [members, setMembers] = useState([]);
- const [adminId, setAdminId] = useState('');
- const [loading, setLoading] = useState(true);
- const [inviteUser, setInviteUser] = useState('');
- const [inviting, setInviting] = useState(false);
- const [error, setError] = useState('');
-
- // Node loopback state (Electron-only)
- const [nodeDetected, setNodeDetected] = useState(false);
- const [nodeRoots, setNodeRoots] = useState([]);
- const [nodeGroupName, setNodeGroupName] = useState('');
- const [nodeBusy, setNodeBusy] = useState(false);
- const [nodeMsg, setNodeMsg] = useState('');
-
- const loadNodeInfo = useCallback(async () => {
- if (!platform.node.available) return;
- try {
- const detect = await platform.node.detect();
- if (!detect.detected) { setNodeDetected(false); return; }
- setNodeDetected(true);
- const data = await platform.node.call('GET', '/api/groups');
- const groups = data.groups || [];
- const ng = groups.find(g => g.id === groupId);
- if (ng) {
- setNodeRoots(ng.roots || []);
- setNodeGroupName(ng.name || '');
- }
- } catch { setNodeDetected(false); }
- }, [groupId]);
-
- useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]);
- const [inviteCode, setInviteCode] = useState(null);
- const [pairCode, setPairCode] = useState('');
- const [pairStatus, setPairStatus] = useState('');
- const [pairing, setPairing] = useState(false);
- // Your own devices on this node. Not a members feature — it is beside them
- // because this is where a live connection to the node exists.
- const [devices, setDevices] = useState([]);
- const [approveCode, setApproveCode] = useState('');
- const [deviceMsg, setDeviceMsg] = useState('');
-
- // Pairing lives here rather than in Settings because this is where a live
- // connection to the node exists — and it is offered only when the node itself
- // says this account is its operator (is_node_admin comes from the authenticated
- // handshake_ack, not from the hub).
- const loadDevices = useCallback(async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- try {
- const out = await transport.listDevices();
- setDevices(out.devices);
- } catch { /* a node that has none says so by listing none */ }
- }, [transportRef]);
-
- useEffect(() => { loadDevices(); }, [loadDevices]);
-
- const approveDevice = useCallback(async (e) => {
- e.preventDefault();
- const code = approveCode.trim();
- if (!code) return;
- setDeviceMsg('');
- try {
- await transportRef.current.approveDevice(userId, code);
- setApproveCode('');
- setDeviceMsg(t('device.approved'));
- await loadDevices();
- } catch (err) { setDeviceMsg(err.message); }
- }, [approveCode, userId, transportRef, loadDevices]);
-
- const revokeDevice = useCallback(async (device) => {
- if (!confirm(t('device.revoke_confirm'))) return;
- setDeviceMsg('');
- try {
- await transportRef.current.revokeDevice(
- userId, device.pk_ed25519, device.pk_x25519 || '');
- await loadDevices();
- } catch (err) { setDeviceMsg(err.message); }
- }, [userId, transportRef, loadDevices]);
-
- const doPair = useCallback(async (e) => {
- e.preventDefault();
- const code = pairCode.trim();
- if (!code) return;
- setPairing(true);
- setPairStatus('');
- try {
- const transport = transportRef && transportRef.current;
- if (!transport || !transport.connected) throw new Error('Not connected to the node');
- await transport.pairOperator(userId, code);
- setPairCode('');
- setPairStatus('paired');
- // The node has pinned this key as an operator key; the form has nothing
- // left to do. It used to stay put through a refresh, because what governed
- // it was the account, which pairing does not change.
- if (onPaired) onPaired();
- } catch (err) {
- setPairStatus(err.message);
- } finally {
- setPairing(false);
- }
- }, [pairCode, transportRef, userId]);
-
- const [uploadBusy, setUploadBusy] = useState(false);
- const [uploadMsg, setUploadMsg] = useState('');
-
- /**
- * Close or open uploading for everyone who is not the operator.
- *
- * Signed, like removing a member: the node refuses an unsigned instruction,
- * so this is a request to the node rather than a decision taken here. The
- * button does not move until the node has said it did it.
- */
- const setUploads = useCallback(async (allowed) => {
- const transport = transportRef && transportRef.current;
- setUploadMsg('');
- setUploadBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setMemberUpload(allowed, signFn);
- if (onMemberUpload) onMemberUpload(allowed);
- } catch (err) {
- setUploadMsg(err.message);
- } finally {
- setUploadBusy(false);
- }
- }, [transportRef, onMemberUpload]);
-
- const [removing, setRemoving] = useState('');
-
- /**
- * Take someone out of this group: both halves, in the order that fails safe.
- *
- * The node first, because that is the half that stops the group key being
- * wrapped for them; if the hub removal then fails, they are a member on paper
- * with no key. The other order would leave them able to reach a node that
- * still serves them.
- */
- const removeMember = useCallback(async (member) => {
- const transport = transportRef && transportRef.current;
- setError('');
- setRemoving(member.user_id);
- try {
- if (platform.node.available) {
- try {
- await platform.node.call('POST',
- `/api/members/${member.user_id}/revoke?group_id=${groupId}`);
- } catch { /* best effort — node may not host this group */ }
- try {
- await platform.node.call('POST',
- `/api/members/${member.user_id}/unpin`);
- } catch { /* best effort */ }
- } else if (transport && transport.connected && operatorPaired) {
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.revokeMember(member.user_id, signFn);
- }
- await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, {
- method: 'DELETE', token,
- });
- loadMembers();
- } catch (err) {
- setError(err.message);
- } finally {
- setRemoving('');
- }
- }, [groupId, token, transportRef, operatorPaired]);
-
- const loadMembers = useCallback(() => {
- setLoading(true);
- hubFetch(`/v1/groups/${groupId}/members`, { token })
- .then(data => {
- setMembers(data.members || []);
- setAdminId(data.admin_id || '');
- })
- .catch(() => {})
- .finally(() => setLoading(false));
- }, [groupId, token]);
-
- useEffect(() => { loadMembers(); }, [loadMembers]);
-
- const isAdmin = group && group.is_admin;
-
- const doInvite = useCallback(async (e) => {
- e.preventDefault();
- if (!inviteUser.trim()) return;
- setInviting(true);
- setError('');
- setInviteCode(null);
- try {
- const transport = transportRef && transportRef.current;
- const username = inviteUser.trim();
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node — it must be online to invite');
- }
-
- // The hub is asked for the account id, and nothing else. It is no longer
- // asked for the invitee's public key: the node wraps the group key itself,
- // for a key the invitee proves possession of when they connect (H3). A hub
- // that answered with the wrong account here would produce an invite whose
- // code it never learns — the code goes to a human, out of band.
- const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
-
- // Signed with the identity this node pinned for us — the only one it
- // will accept, and the only one we hold here.
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- const result = await transport.createInvite(
- account.user_id, groupId, username, signFn);
-
- // Membership on the hub is what lets them reach the node at all; the code
- // is what gets them the key.
- await hubFetch(`/v1/groups/${groupId}/members/${username}`, {
- method: 'POST', token, body: {},
- });
-
- setInviteCode({ username, code: result.code, expires: result.expires_at });
- setInviteUser('');
- loadMembers();
- } catch (err) {
- setError(err.message);
- } finally {
- setInviting(false);
- }
- }, [groupId, token, inviteUser, loadMembers, transportRef]);
-
- if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
-
- const isOwner = Boolean(isAdmin);
-
- return html`
- <div class="members-panel">
- ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
-
- ${/* Inviting needs the node: it is the node that wraps the group key and
- issues the code, not the hub. Public groups admit anyone — no invite.
- The form stays in the DOM so a brief reconnect does not destroy the
- input the user is typing into — controls are disabled instead. */
- isAdmin && group?.join_policy !== 'open' && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.invite_title')}</h3>
- ${!connected ? html`
- <p class="settings-hint">${t('group.offline_title')}</p>
- ` : !operatorPaired ? html`
- <p class="settings-hint">
- ${isNodeAdmin ? t('members.invite_needs_pairing')
- : t('members.invite_ask_operator')}
- </p>
- ` : ''}
- <form onSubmit=${doInvite}>
- ${inviteCode && html`
- <div class="success-msg" style="margin-bottom:8px">
- <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
- <p class="code-display">${inviteCode.code}</p>
- <p>${t('members.invite_code_hint')}</p>
- </div>
- `}
- <div class="form-row">
- <input type="text" placeholder="${t('members.username_placeholder')}"
- value=${inviteUser} onInput=${e => setInviteUser(e.target.value)}
- disabled=${!connected || !operatorPaired} required />
- <button class="admin-btn" type="submit"
- disabled=${inviting || !connected || !operatorPaired}>
- ${inviting ? '...' : t('members.invite_btn')}
- </button>
- </div>
- </form>
- </div>
- `}
-
- ${isNodeAdmin && !operatorPaired && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.pair_title')}</h3>
- <p class="settings-hint">${t('members.pair_hint')}</p>
- ${pairStatus && html`
- <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
- ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
- </p>
- `}
- <form class="form-row" onSubmit=${doPair}>
- <input type="text" placeholder="XXXX-XXXX" class="code-input"
- value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
- <button class="admin-btn" type="submit" disabled=${pairing}>
- ${pairing ? '...' : t('members.pair_btn')}
- </button>
- </form>
- </div>
- `}
-
- ${/* Operator only, and only with a live connection: the node is what
- holds and enforces this, so there is nothing to show or change
- without one. */ isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
- <div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${uploadBusy}
- onClick=${() => setUploads(!memberUpload)}>
- ${uploadBusy ? '...'
- : (memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable'))}
- </button>
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
- </div>
- `}
-
- ${connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('device.mine_title')}</h3>
- <p class="settings-hint">${t('device.mine_hint')}</p>
- ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
- ${devices.length === 0
- ? html`<p class="settings-hint">${t('device.mine_empty')}</p>`
- : html`
- <ul class="device-list">
- ${devices.map(d => html`
- <li class="device-row" key=${d.pk_ed25519}>
- <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span>
- <span class="device-meta">
- ${d.is_this_one && html`
- <span class="badge">${t('device.this_one')}</span>${' '}
- `}
- ${d.pinned_via}${d.label ? ' · ' + d.label : ''}
- </span>
- ${!d.is_this_one && devices.length > 1 && html`
- <button class="admin-btn" onClick=${() => revokeDevice(d)}>
- ${t('device.revoke')}
- </button>
- `}
- </li>
- `)}
- </ul>
- `}
- <form onSubmit=${approveDevice} class="settings-subform">
- <p class="settings-hint">${t('device.approve_hint')}</p>
- <div class="form-row">
- <input type="text" placeholder="XXXX-XXXX" class="code-input"
- value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
- <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
- </div>
- </form>
- </div>
- `}
-
- ${/* Roots management (Electron-only, when node is local) */
- nodeDetected && nodeRoots.length > 0 && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.roots')}</h3>
- ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
- <div class="node-roots">
- ${nodeRoots.map(r => html`
- <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
- key=${r.name}>
- <div class="node-root-info">
- <span class="node-root-name">
- <${Icon} name="folder" />
- ${r.name}
- </span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
- <span class="node-root-badge node-root-badge-warn">
- ${t('node.unavailable')}</span>`}
- </div>
- ${nodeRoots.length > 1 && !r.upload && html`
- <button class="btn btn-small btn-danger"
- disabled=${nodeBusy}
- onClick=${async () => {
- if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
- setNodeBusy(true); setNodeMsg('');
- try {
- await platform.node.call('DELETE',
- '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
- await platform.node.call('POST', '/api/reload');
- setNodeMsg(t('node.root_removed'));
- await loadNodeInfo();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- ${t('node.remove_root')}</button>`}
- </div>
- `)}
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${nodeBusy}
- onClick=${async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- setNodeBusy(true); setNodeMsg('');
- try {
- await platform.node.call('POST',
- '/api/groups/' + groupId + '/roots',
- { path: chosen.path, name: chosen.name });
- await platform.node.call('POST', '/api/reload');
- setNodeMsg(t('node.root_added'));
- await loadNodeInfo();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- <${Icon} name="folder-plus" /> ${t('node.add_root')}
- </button>
- </div>
- </div>
- `}
-
- ${/* Upload toggle via loopback when MNP not connected */
- nodeDetected && !connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
- <div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${nodeBusy}
- onClick=${async () => {
- setNodeBusy(true); setNodeMsg('');
- try {
- const newVal = !memberUpload;
- await platform.node.call('PUT',
- '/api/groups/' + groupId + '/member-upload',
- { allowed: newVal });
- if (onMemberUpload) onMemberUpload(newVal);
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- ${memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable')}
- </button>
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- </div>
- `}
-
- ${/* Delete/leave — node detach first (reversible), then hub delete
- (irreversible). */ html`
- <div class="settings-section">
- <h3 class="settings-heading">
- ${isOwner ? t('group.delete_group') : t('group.leave')}
- </h3>
- <div class="settings-row">
- <span class="settings-label">
- ${isOwner ? t('members.danger_delete_hint')
- : t('members.danger_leave_hint')}
- </span>
- ${isOwner
- ? html`
- <button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
- try {
- // Node detach first (reversible), then hub delete (irreversible)
- if (nodeDetected && nodeGroupName) {
- try {
- await platform.node.call('POST', '/api/groups/detach',
- { name: nodeGroupName });
- } catch (detachErr) {
- if (!confirm(t('settings_node.detach_failed_continue'))) return;
- }
- }
- await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
- navigate('/');
- window.location.reload();
- } catch (err) { setError(err.message); }
- }}>${t('group.delete_group')}</button>
- `
- : html`
- <button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
- try {
- await hubFetch('/v1/groups/' + groupId + '/leave',
- { method: 'POST', token });
- if (onLeft) onLeft(groupId);
- } catch (err) { setError(err.message); }
- }}>${t('group.leave')}</button>
- `}
- </div>
- </div>
- `}
-
- <div class="settings-section">
- <h3 class="settings-heading">
- ${t('group.tab_members')} (${members.length})
- </h3>
- <table class="admin-table">
- <thead>
- <tr>
- <th>${t('admin.col_username')}</th>
- <th>${t('members.group_role')}</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- ${members.map(m => html`
- <tr key=${m.user_id}>
- <td>${m.username}</td>
- <td>
- ${m.user_id === adminId
- ? html`<span class="badge badge-owner">${t('members.owner')}</span>`
- : html`<span class="badge">${t('members.member')}</span>`
- }
- </td>
- <td class="admin-actions">
- ${isAdmin && m.user_id !== adminId && html`
- <button class="admin-btn danger" disabled=${removing === m.user_id}
- onClick=${() => {
- if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
- removeMember(m);
- }}>
- ${removing === m.user_id ? '...' : t('members.remove')}
- </button>
- `}
- </td>
- </tr>
- `)}
- </tbody>
- </table>
- ${isAdmin && members.length > 1 && html`
- <p class="settings-hint">${t('members.remove_hint')}</p>
- `}
- </div>
- </div>
- `;
-}
-
-// ── Chat Panel ──────────────────────────────────────────────────────────
-
-/**
- * Message text with its links made clickable.
- *
- * Only http and https, and built as elements rather than markup: a message is
- * something another member wrote, so it must never become HTML. `javascript:`
- * and `data:` are not matched at all, and the anchors carry noopener so the new
- * tab cannot reach back into this one.
- */
-const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
-
-function linkify(text) {
- const out = [];
- let last = 0;
- for (const m of String(text).matchAll(URL_RE)) {
- if (m.index > last) out.push(text.slice(last, m.index));
- // Trailing punctuation is almost never part of the address.
- let url = m[0];
- let tail = '';
- while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); }
- out.push(html`<a href=${url} target="_blank" rel="noopener noreferrer"
- class="chat-link">${url}</a>`);
- if (tail) out.push(tail);
- last = m.index + m[0].length;
- }
- if (last < text.length) out.push(text.slice(last));
- return out;
-}
-
-function formatTime(ts) {
- const d = new Date(ts * 1000);
- const now = new Date();
- // getLocale() rather than the browser default: the user may have picked a
- // language here that differs from the one their OS reports.
- const time = d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
- if (d.toDateString() === now.toDateString()) return time;
- return d.toLocaleDateString(getLocale(), { month: 'short', day: 'numeric' }) + ' ' + time;
-}
-
-function _parsePayload(raw) {
- if (typeof raw === 'string' && raw.startsWith('{')) {
- try { return JSON.parse(raw); } catch { /* not JSON */ }
- }
- return null;
-}
-
-// How much history a group opens with, and how much each "older" click adds.
-const CHAT_PAGE = 100;
-const CHAT_OLDER_PAGE = 50;
-
-// Breathing room under the panel, and the floor below which shrinking it stops
-// helping — past that the page may scroll after all, which beats a chat two
-// lines tall.
-const CHAT_BOTTOM_GAP = 16;
-const CHAT_MIN_HEIGHT = 240;
-
-function _sameDay(a, b) {
- const da = new Date(a * 1000), db = new Date(b * 1000);
- return da.getFullYear() === db.getFullYear()
- && da.getMonth() === db.getMonth()
- && da.getDate() === db.getDate();
-}
-
-/** "Today" / "Yesterday" / a written date, in the reader's language. */
-function _dayLabel(ts) {
- const d = new Date(ts * 1000);
- const now = new Date();
- if (_sameDay(ts, now.getTime() / 1000)) return t('chat.today');
- const yesterday = new Date(now);
- yesterday.setDate(now.getDate() - 1);
- if (_sameDay(ts, yesterday.getTime() / 1000)) return t('chat.yesterday');
- return d.toLocaleDateString(getLocale(), {
- weekday: 'long', day: 'numeric', month: 'long',
- year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric',
- });
-}
-
-function ChatImage({ filename, entries, transportRef, gekRef }) {
- const [blobUrl, setBlobUrl] = useState(null);
- const [loading, setLoading] = useState(true);
- const loadedRef = useRef(false);
-
- useEffect(() => {
- if (loadedRef.current) return;
- let cancelled = false;
- const load = async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) { setLoading(true); return; }
- const entry = entries.find(e => e.name === filename);
- if (!entry) { setLoading(true); return; }
- try {
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks);
- if (cancelled) return;
- const ext = filename.split('.').pop().toLowerCase();
- const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif'
- : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg';
- const blob = new Blob(chunks, { type: mime });
- loadedRef.current = true;
- setBlobUrl(URL.createObjectURL(blob));
- } catch { /* ignore */ }
- if (!cancelled) setLoading(false);
- };
- load();
- return () => { cancelled = true; };
- }, [filename, entries.length]);
-
- useEffect(() => {
- return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); };
- }, [blobUrl]);
-
- if (loading) return html`<div class="chat-att-thumb"><span class="spinner"></span></div>`;
- if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`;
- return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`;
-}
-
-function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true, onActivity }) {
- const [messages, setMessages] = useState([]);
- const [hasMore, setHasMore] = useState(false);
- const [loadingOlder, setLoadingOlder] = useState(false);
- const [atBottom, setAtBottom] = useState(true);
- const [unreadFrom, setUnreadFrom] = useState(null);
- const [input, setInput] = useState('');
- const [sending, setSending] = useState(false);
- const [attaching, setAttaching] = useState(false);
- const listRef = useRef(null);
- const panelRef = useRef(null);
- const inputRef = useRef(null);
- const loadedRef = useRef(false);
- // Set just before older messages are prepended; read once, after the DOM has
- // them but before the browser paints.
- const anchorRef = useRef(null);
- const atBottomRef = useRef(true);
-
- useEffect(() => {
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
-
- if (!loadedRef.current) {
- loadedRef.current = true;
- // The newest page. This used to be fetchChatHistory(0, 200), which paged
- // forwards from the very first message ever sent, so a busy group opened
- // on its oldest screen and the recent conversation was unreachable.
- transport.fetchChatHistory({ limit: CHAT_PAGE })
- .then(({ messages: msgs, hasMore: more }) => {
- setMessages(msgs);
- setHasMore(more);
- })
- .catch(() => {});
- }
-
- transport.onChat = (msg) => {
- // A live message has no row id until it is re-read from the node, so it
- // gets a local one. Keys have to be stable and unique or prepending a
- // page makes Preact reuse the wrong bubbles. Computed once and reused
- // below: the unread marker points at a message by id, so generating a
- // second one there would point it at nothing.
- const id = msg.id
- || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
- setMessages(prev => [...prev, {
- id,
- sender_id: msg.sender_id,
- sender_name: msg.sender_name || '',
- payload: msg.payload,
- timestamp: msg.timestamp || Date.now() / 1000,
- thread_id: msg.thread_id,
- }]);
- // Somebody wrote while you were reading further up: mark where you were
- // rather than yanking the view down.
- if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id);
- };
-
- return () => { transport.onChat = null; };
- }, [transportRef.current?.connected]);
-
- const loadOlder = useCallback(async () => {
- const transport = transportRef.current;
- if (!transport || !transport.connected || loadingOlder || !messages.length) return;
- setLoadingOlder(true);
- const list = listRef.current;
- // Keeping the reading position means restoring the distance from the
- // *bottom*, not scrollTop: everything above the viewport just grew.
- anchorRef.current = list ? list.scrollHeight - list.scrollTop : null;
- try {
- const { messages: older, hasMore: more } =
- await transport.fetchChatHistory({ before: messages[0].id, limit: CHAT_OLDER_PAGE });
- setMessages(prev => [...older, ...prev]);
- setHasMore(more);
- } catch {
- anchorRef.current = null;
- } finally {
- setLoadingOlder(false);
- }
- }, [messages, loadingOlder]);
-
- useLayoutEffect(() => {
- const list = listRef.current;
- if (!list) return;
- if (anchorRef.current !== null) {
- list.scrollTop = list.scrollHeight - anchorRef.current;
- anchorRef.current = null;
- return;
- }
- // Only follow the conversation if the reader was already at the bottom.
- // Scrolling unconditionally fought every attempt to read back through it.
- //
- // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no
- // height, so aligning it to the bottom of the viewport leaves the list's
- // own padding below it and the bar stops just short of the end.
- if (atBottomRef.current) list.scrollTop = list.scrollHeight;
- }, [messages]);
-
- // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a
- // phone the group header — title, description, edit link, delete button, tabs
- // — is closer to 430px, so the panel ran past the fold and the composer ended
- // up off screen with the whole page scrolling to reach it.
- //
- // Measured instead, from the panel's own position in the document, so the
- // header can be any height. `visualViewport` rather than innerHeight where it
- // exists: on Android the on-screen keyboard shrinks the visual viewport
- // without changing innerHeight, and the composer would go back under it.
- useLayoutEffect(() => {
- const el = panelRef.current;
- if (!el) return;
- const fit = () => {
- const vh = window.visualViewport?.height || window.innerHeight;
- // Document-relative, so a page that happens to be scrolled does not skew
- // the result — the answer must be the same either way.
- const top = el.getBoundingClientRect().top + window.scrollY;
- el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`;
- // What sits *below* the panel is not knowable from up here — today it is
- // `.main`'s 24px bottom padding against this 16px gap, which left the
- // document 8px taller than the window and a scrollbar on the chat tab at
- // every window size. Rather than encode 24 somewhere and have the next
- // change to the page break it again, the leftover is measured and taken
- // off. Self-correcting: anything added under the panel is absorbed the
- // same way.
- const over = document.documentElement.scrollHeight - vh;
- if (over > 0) {
- el.style.height =
- `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`;
- }
- };
- fit();
- window.addEventListener('resize', fit);
- window.addEventListener('orientationchange', fit);
- window.visualViewport?.addEventListener('resize', fit);
- return () => {
- window.removeEventListener('resize', fit);
- window.removeEventListener('orientationchange', fit);
- window.visualViewport?.removeEventListener('resize', fit);
- };
- }, []);
-
- const onScroll = useCallback((e) => {
- const el = e.target;
- const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
- atBottomRef.current = bottom;
- setAtBottom(bottom);
- if (bottom) setUnreadFrom(null);
- }, []);
-
- const jumpToBottom = useCallback(() => {
- atBottomRef.current = true;
- setAtBottom(true);
- setUnreadFrom(null);
- const list = listRef.current;
- if (list) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' });
- }, []);
-
- const sendMessage = useCallback(async () => {
- const text = input.trim();
- if (!text) return;
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
-
- setSending(true);
- setInput('');
- try {
- await transport.sendChat(text, 0, null, username);
- setMessages(prev => [...prev, {
- id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- sender_id: username,
- sender_name: username,
- payload: text,
- timestamp: Date.now() / 1000,
- thread_id: null,
- }]);
- jumpToBottom();
- if (onActivity) onActivity();
- } catch {
- setInput(text);
- } finally {
- setSending(false);
- setTimeout(() => { if (inputRef.current) inputRef.current.focus(); });
- }
- }, [input, username, jumpToBottom]);
-
- const attachFile = useCallback(async (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- e.target.value = '';
- const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- setAttaching(true);
- try {
- // Two people sending IMG_1234.jpg both succeed; the node picks a free name
- // and the message has to point at the one it chose.
- const ack = await transport.uploadFile(file);
- const storedAs = (ack && ack.stored_as) || file.name;
- await new Promise(r => setTimeout(r, 2500));
- if (onRefreshIndex) await onRefreshIndex();
- const ext = file.name.split('.').pop().toLowerCase();
- const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image'
- : ['mp4','webm','mkv','mov','avi'].includes(ext) ? 'video' : 'file';
- const structured = JSON.stringify({
- text: '', attachment: { filename: storedAs, size: file.size, type: ftype },
- });
- await transport.sendChat(structured, 0, null, username);
- setMessages(prev => [...prev, {
- id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
- sender_id: username, sender_name: username,
- payload: structured, timestamp: Date.now() / 1000, thread_id: null,
- }]);
- jumpToBottom();
- } catch (err) {
- alert(err.message);
- } finally {
- setAttaching(false);
- }
- }, [username, onRefreshIndex, jumpToBottom]);
-
- const onKeyDown = useCallback((e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendMessage();
- }
- }, [sendMessage]);
-
- return html`
- <div class="chat-panel" ref=${panelRef}>
- <div class="chat-messages" ref=${listRef} onScroll=${onScroll}>
- ${hasMore && html`
- <div class="chat-older-row">
- <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}>
- ${loadingOlder
- ? html`<span class="spinner"></span>`
- : html`<${Icon} name="chevron" cls="chat-older-icon" />`}
- ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })}
- </button>
- </div>
- `}
- ${!hasMore && messages.length > 0 && html`
- <div class="chat-start">${t('chat.start_of_history')}</div>
- `}
- ${messages.length === 0 && html`
- <div class="chat-empty">${t('chat.empty')}</div>
- `}
- ${messages.map((m, i) => {
- const isOwn = m.sender_name === username || m.sender_id === username;
- const displayName = m.sender_name || '?';
- const prev = messages[i - 1];
- const showSender = !isOwn && (i === 0 ||
- (prev.sender_name || prev.sender_id) !== (m.sender_name || m.sender_id));
- // A conversation read over several days is unreadable without them.
- const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp)
- ? _dayLabel(m.timestamp) : null;
- const parsed = _parsePayload(m.payload);
- const att = parsed && parsed.attachment;
- return html`
- ${daySep && html`
- <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div>
- `}
- ${unreadFrom && unreadFrom === m.id && html`
- <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div>
- `}
- <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}
- ${showSender || daySep ? '' : 'chat-msg-tight'}">
- ${showSender && html`
- <div class="chat-sender">${displayName}</div>
- `}
- <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
- ${att ? html`
- <div class="chat-attachment" style="cursor:pointer" onClick=${() => {
- if (!onPreview) return;
- const entry = entries.find(e => e.name === att.filename);
- if (entry) onPreview(entry);
- }}>
- ${att.type === 'image'
- ? html`<${ChatImage} filename=${att.filename} entries=${entries}
- transportRef=${transportRef} gekRef=${gekRef} />`
- : att.type === 'video'
- ? html`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>`
- : html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>`
- }
- <div class="chat-att-size">${formatSize(att.size)}</div>
- </div>
- ` : html`
- <span class="chat-text">
- ${linkify(parsed && typeof parsed.text === 'string'
- ? parsed.text : m.payload)}
- </span>
- `}
- <span class="chat-time">${formatTime(m.timestamp)}</span>
- </div>
- </div>
- `;
- })}
- </div>
- ${!atBottom && messages.length > 0 && html`
- <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}>
- <${Icon} name="chevron" cls="chat-jump-icon" />
- ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')}
- </button>
- `}
- <div class="chat-input-row">
- ${mayUpload && html`
- <label class="chat-attach" title="${t('chat.attach')}">
- ${attaching ? html`<span class="spinner"></span>`
- : html`<${Icon} name="clip" />`}
- <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
- </label>
- `}
- <textarea class="chat-input" rows="1" ref=${inputRef}
- placeholder="${t('chat.placeholder')}"
- value=${input}
- onInput=${e => setInput(e.target.value)}
- onKeyDown=${onKeyDown}
- disabled=${sending} />
- <button class="chat-send" onClick=${sendMessage}
- disabled=${sending || !input.trim()}>
- ${t('chat.send')}
- </button>
- </div>
- </div>
- `;
-}
-
-// ── Video Player (MSE streaming) ────────────────────────────────────────
-
-function _mseSupported(codec) {
- if (!window.MediaSource) return false;
- const mime = `video/mp4; codecs="${codec}"`;
- return MediaSource.isTypeSupported(mime);
-}
-
-/** Seconds as h:mm:ss, or m:ss under an hour. */
-function formatClock(seconds) {
- const s = Math.max(0, Math.floor(seconds || 0));
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = String(s % 60).padStart(2, '0');
- return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
-}
-
-/**
- * Where *this account on this device* last left off in a given file.
- *
- * localStorage rather than the node: it needs no protocol, no storage anyone
- * else has to keep, and nothing new learns what you watch. The cost is that
- * the position does not follow you from the laptop to the phone.
- *
- * The account has to be in the key. Without it the position is per *device* —
- * so a second person signing in on the same machine was offered "resume where
- * you left off" in a film they had never opened, which is both wrong and a
- * small disclosure of what someone else watches. Found by signing in with a
- * fresh account and being offered a resume point.
- */
-function resumeKey(fileId) {
- const auth = loadAuth();
- return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null;
-}
-
-function readResumePosition(fileId) {
- try {
- const key = resumeKey(fileId);
- if (!key) return 0;
- const raw = localStorage.getItem(key);
- const at = raw ? parseFloat(raw) : 0;
- return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0;
- } catch {
- return 0; // private browsing, or storage disabled
- }
-}
-
-function writeResumePosition(fileId, at, duration) {
- try {
- const key = resumeKey(fileId);
- if (!key) return;
- if (!Number.isFinite(at) || at < RESUME_MIN_S
- || (duration && at > duration * RESUME_MAX_FRACTION)) {
- localStorage.removeItem(key);
- return;
- }
- localStorage.setItem(key, String(Math.floor(at)));
- } catch { /* nothing to be done, and nothing worth failing over */ }
-}
-
-/**
- * Drop the positions written before they were scoped to an account.
- *
- * Re-keying them is not possible — there is no record of whose they were, and
- * guessing would hand them to whoever signs in next, which is the bug. They go.
- */
-function purgeUnscopedResumePositions() {
- try {
- const stale = [];
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current.
- if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) {
- stale.push(key);
- }
- }
- stale.forEach((key) => localStorage.removeItem(key));
- } catch { /* storage disabled: nothing was written either */ }
-}
-
-purgeUnscopedResumePositions();
-
-function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
- const [dlBusy, setDlBusy] = useState(false);
- const [phase, setPhase] = useState('loading');
- const [error, setError] = useState('');
- const videoRef = useRef(null);
- const msRef = useRef(null);
- const sbRef = useRef(null);
- const blobUrlRef = useRef(null);
- const queueRef = useRef([]);
- const appendingRef = useRef(false);
- const endedRef = useRef(false);
- const durationRef = useRef(0);
- // Segments the node is allowed to have in flight but has not sent yet, and
- // when we last said anything to it at all.
- const outstandingRef = useRef(0);
- const lastPokeRef = useRef(0);
- // Diagnostics reported to the node: how many appends the browser refused for
- // want of room, and whether the element itself says it is starved.
- const quotaRef = useRef(0);
- const stalledRef = useRef(false);
- // Seeking. `awaitingInit` is true from the moment we ask the node to restart
- // somewhere else until its new `stream_init` arrives: the channel is ordered,
- // so everything in between belongs to the stream we just abandoned and would
- // otherwise be appended on top of the new one. `seekTarget` is where to put
- // the playhead once the buffer actually covers it.
- const awaitingInitRef = useRef(false);
- const seekTargetRef = useRef(null);
- const seekTimerRef = useRef(null);
- // The seek is built inside the effect, where the transport and `cancelled`
- // live; the render needs to reach it for "start from the beginning".
- const requestSeekRef = useRef(null);
- const [resumedFrom, setResumedFrom] = useState(0);
- const [castActive, setCastActive] = useState(false);
- const [castUrl, setCastUrl] = useState(null);
- const [castPickerOpen, setCastPickerOpen] = useState(false);
- const [castDevices, setCastDevices] = useState([]);
- const [castScanning, setCastScanning] = useState(false);
- const [castDeviceName, setCastDeviceName] = useState(null);
- const castActiveRef = useRef(false);
- const castCodecRef = useRef(null);
- const initSegmentRef = useRef(null);
- const castRestartPendingRef = useRef(false);
- const castDeviceRef = useRef(null);
- const castRestartGenRef = useRef(0);
- const landingPlayheadRef = useRef(false);
-
- /**
- * The buffered range the playhead is actually in, or null.
- *
- * Seeking makes the buffer discontinuous, and "the last range" stops meaning
- * "the one being watched" the moment there is more than one: measuring the
- * read-ahead against a range on the far side of a gap reports a full buffer
- * while the player starves.
- */
- const currentRange = useCallback(() => {
- const sb = sbRef.current;
- const v = videoRef.current;
- if (!sb || !v) return null;
- try {
- const t = v.currentTime;
- for (let i = 0; i < sb.buffered.length; i++) {
- // Half a second of slack: the playhead sits exactly on a boundary
- // often enough, and a strict test there reports nothing buffered.
- if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) {
- return [sb.buffered.start(i), sb.buffered.end(i)];
- }
- }
- } catch { /* the SourceBuffer went away under us */ }
- return null;
- }, []);
-
- /**
- * Drop what has already been watched.
- *
- * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes
- * and refuse the append that goes past. Keeping a minute behind the playhead
- * is enough for a small seek backwards and bounded for a three-hour film.
- */
- const evictBehind = useCallback(() => {
- const sb = sbRef.current;
- const v = videoRef.current;
- if (!sb || !v || sb.updating || !sb.buffered.length) return false;
- const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
- // The range being watched, not the first one: after a seek backwards the
- // first range is somewhere else entirely, and removing from its start to
- // just behind the playhead would take out everything in between —
- // including what is playing.
- const range = currentRange();
- const start = range ? range[0] : sb.buffered.start(0);
- if (keepFrom - start < 10) return false;
- try {
- sb.remove(start, keepFrom);
- return true;
- } catch {
- return false;
- }
- }, [currentRange]);
-
- /** Seconds of film held past the playhead. */
- const bufferedAhead = useCallback(() => {
- const v = videoRef.current;
- const range = currentRange();
- if (!v || !range) return 0;
- return Math.max(0, range[1] - v.currentTime);
- }, [currentRange]);
-
- const flushQueue = useCallback(() => {
- const sb = sbRef.current;
- if (!sb || appendingRef.current || sb.updating) return;
- if (queueRef.current.length === 0) {
- if (endedRef.current && msRef.current?.readyState === 'open') {
- try { msRef.current.endOfStream(); } catch {}
- }
- return;
- }
- appendingRef.current = true;
- const chunk = queueRef.current[0];
- try {
- sb.appendBuffer(chunk);
- queueRef.current.shift();
- } catch (e) {
- appendingRef.current = false;
- if (e.name === 'QuotaExceededError') {
- quotaRef.current += 1;
- // The segment stays at the head of the queue and is tried again once
- // there is room. Dropping it — which is what this used to do — leaves a
- // hole in the middle of the film and no error anywhere.
- if (!evictBehind()) {
- console.warn('[MSE] buffer full and nothing to evict yet');
- }
- return;
- }
- queueRef.current.shift();
- console.error('[MSE] appendBuffer error:', e);
- }
- }, [evictBehind]);
-
- /**
- * Decide whether the node may send more, and keep the pipeline moving.
- *
- * This is the only place credit is granted, and the only thing that can
- * restart a pipeline the buffer ceiling has stopped. That second job is why
- * it exists: an append refused for quota fires no `updateend`, so it grants
- * no credit, so the node sends nothing, so no segment arrives to call
- * `flushQueue` again. Every wakeup the append path had was downstream of the
- * append that just failed — the player deadlocked against itself and sat on
- * "buffering" for good, which is what a 500 MB film did at around 100 MB.
- *
- * So the clock drives this, not the data.
- */
- const pump = useCallback(() => {
- if (awaitingInitRef.current) return;
- const transport = transportRef.current;
- evictBehind();
- flushQueue();
- if (endedRef.current && queueRef.current.length === 0) return;
- if (!transport || !transport.connected) return;
-
- if (bufferedAhead() > BUFFER_AHEAD_S
- || queueRef.current.length > QUEUE_HIGH_WATER) {
- // Far enough ahead. Grant nothing, but do not go silent: two minutes of
- // silence is how the node decides nobody is watching, and pausing a film
- // for two minutes is an ordinary thing to do.
- const now = Date.now();
- if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) {
- lastPokeRef.current = now;
- transport.grantStreamCredit(0);
- }
- return;
- }
-
- // Top the window back up to what is allowed in flight, rather than paying
- // off everything owed at once. Called on every arriving segment as well as
- // on the clock, so credit trickles out as room appears instead of being
- // released in one gulp when the buffer finally drains.
- const room = STREAM_WINDOW - outstandingRef.current;
- if (room > 0) {
- outstandingRef.current += room;
- lastPokeRef.current = Date.now();
- transport.grantStreamCredit(room);
- }
- }, [evictBehind, flushQueue, bufferedAhead]);
-
- useEffect(() => {
- let cancelled = false;
- // Reset here, not in the teardown of the run before: switching video while
- // an append was in flight left `appendingRef` true, and flushQueue bails
- // out on it. The new SourceBuffer then never appended anything, so no
- // `updateend` ever cleared the flag, no credit went back to the node, and
- // the player sat on "buffering" for good. `endedRef` surviving is the same
- // shape of bug — the next stream would call endOfStream() the first time
- // its queue ran dry and truncate the film.
- appendingRef.current = false;
- endedRef.current = false;
- queueRef.current = [];
- outstandingRef.current = 0;
- lastPokeRef.current = Date.now();
- quotaRef.current = 0;
- stalledRef.current = false;
- // The same shape again, and the seek refs are worse than the others.
- // Switching film while a seek was in flight leaves `awaitingInit` true,
- // and only reinitAt() ever lowers it — which the next film does not go
- // through, because it builds a new SourceBuffer. Every segment of the new
- // film is then dropped as though it belonged to the one we left, for good.
- // A stale `seekTarget` is milder: the new film jumps to a position from
- // the old one the moment that much is buffered.
- awaitingInitRef.current = false;
- seekTargetRef.current = null;
- clearTimeout(seekTimerRef.current);
- const transport = transportRef.current;
- if (!transport || !transport.connected) {
- setError(t('video.err_transport'));
- setPhase('error');
- return;
- }
-
- const onStarved = () => { stalledRef.current = true; pump(); };
- const onFed = () => { stalledRef.current = false; };
-
- /** The buffered ranges, short enough for a log line. */
- const describeRanges = () => {
- const sb = sbRef.current;
- if (!sb) return '(no buffer)';
- try {
- let s = '';
- for (let i = 0; i < sb.buffered.length; i++) {
- s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
- }
- return s.trim() || '(empty)';
- } catch {
- return '?';
- }
- };
-
- /**
- * Ask the node to restart the film somewhere else.
- *
- * Debounced, because dragging the scrubber fires `seeking` continuously and
- * each request kills an ffmpeg and spawns another. Only the position the
- * finger stops on is worth acting on.
- */
- const requestSeek = (target) => {
- clearTimeout(seekTimerRef.current);
- seekTimerRef.current = setTimeout(() => {
- const t = transportRef.current;
- if (cancelled || !t || !t.connected) return;
- // Everything arriving from here until the new `stream_init` belongs to
- // the stream being abandoned. The channel is ordered, so this flag is
- // enough to tell them apart without a sequence number in the protocol.
- // Rare enough to report every time, and the node logs it at INFO. A
- // seek nobody asked for is the kind of thing only this line can show:
- // from the node's side it is indistinguishable from a viewer dragging
- // the scrubber.
- t.sendStreamDiag({
- event: 'seek', target: +target.toFixed(1),
- t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
- ready: videoRef.current ? videoRef.current.readyState : null,
- offset: sbRef.current ? sbRef.current.timestampOffset : null,
- ranges: describeRanges(),
- });
- awaitingInitRef.current = true;
- seekTargetRef.current = target;
- outstandingRef.current = STREAM_WINDOW;
- setPhase('loading');
- console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW);
- t.requestStream(entry.id, STREAM_WINDOW, target);
- }, SEEK_DEBOUNCE_MS);
- };
- requestSeekRef.current = requestSeek;
-
- /**
- * Move the playhead onto a seek once the data for it has arrived.
- *
- * Setting `currentTime` into a region that is not buffered yet leaves the
- * element waiting with nothing to show, and on a seek backwards it would
- * be overwritten by the playhead the browser restores. So the position is
- * remembered and applied on the first append that actually covers it.
- */
- const landPlayhead = () => {
- const target = seekTargetRef.current;
- const v = videoRef.current, sb = sbRef.current;
- if (target === null || !v || !sb) return;
- try {
- for (let i = 0; i < sb.buffered.length; i++) {
- const a = sb.buffered.start(i), b = sb.buffered.end(i);
- if (target >= a - 1 && target < b) {
- seekTargetRef.current = null;
- // ffmpeg lands on the keyframe at or before what we asked for, so
- // the range can begin slightly later than the target; never seek
- // behind what is actually there.
- if (Math.abs(v.currentTime - target) > 0.5) {
- landingPlayheadRef.current = true;
- v.currentTime = Math.max(target, a);
- }
- v.play().catch(() => {});
- return;
- }
- }
- } catch { /* the SourceBuffer went away */ }
- };
-
- /** Wait for whatever the SourceBuffer is doing to finish. */
- const settled = (sb) => new Promise((resolve) => {
- if (!sb.updating) return resolve();
- sb.addEventListener('updateend', resolve, { once: true });
- });
-
- /**
- * Put the SourceBuffer back to an empty state that starts at `start`.
- *
- * Everything buffered is dropped rather than kept alongside the new
- * material. A discontinuous buffer is legal and every piece of code that
- * reads `buffered` then has to reason about which range it means — the
- * eviction, the read-ahead, the seek test — for the sake of a few
- * megabytes of film the viewer has just navigated away from.
- *
- * `abort()` first: ffmpeg was killed mid-fragment, so the parser is
- * holding half of one, and appending the next stream's header on top of
- * that is a decode error.
- */
- const reinitAt = async (start) => {
- const sb = sbRef.current;
- if (!sb) return;
- try { sb.abort(); } catch { /* not in a state that needs it */ }
- await settled(sb);
- try {
- sb.remove(0, Infinity);
- await settled(sb);
- } catch { /* nothing buffered */ }
- // ffmpeg restarts its timestamps at zero however far in we asked it to
- // seek, so this is what puts the fragments back on the film's timeline.
- try { sb.timestampOffset = start; } catch { /* older browsers */ }
- const tr = transportRef.current;
- if (tr) {
- tr.sendStreamDiag({
- event: 'reinit', target: start, offset: sb.timestampOffset,
- t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
- ranges: describeRanges(),
- });
- }
- queueRef.current = [];
- appendingRef.current = false;
- endedRef.current = false;
- quotaRef.current = 0;
- awaitingInitRef.current = false;
- seekTargetRef.current = start;
- console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length);
- setPhase('streaming');
- pump();
- };
-
- const onSeeking = () => {
- if (landingPlayheadRef.current) {
- landingPlayheadRef.current = false;
- return;
- }
- const v = videoRef.current;
- if (!v || cancelled) return;
- const target = v.currentTime;
- // Inside what is buffered, the browser handles it and the node need not
- // hear about it at all — unless a cast is active, because the relay
- // cannot seek within its HTTP stream and must be restarted.
- if (!castActiveRef.current) {
- const sb = sbRef.current;
- if (sb) {
- try {
- for (let i = 0; i < sb.buffered.length; i++) {
- if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) {
- return;
- }
- }
- } catch { /* fall through and ask the node */ }
- }
- }
- requestSeek(target);
- };
-
- const startStream = async () => {
- transport.onStreamError = (msg) => {
- if (cancelled) return;
- // Say what the node said. Sitting on "buffering" with the reason
- // already delivered is the worst of both.
- setError(msg.detail || t('video.err_transport'));
- setPhase('error');
- };
-
- transport.onStreamInit = (msg) => {
- if (cancelled) return;
- if (msg.file_id && msg.file_id !== entry.id) return;
- const mime = `video/mp4; codecs="${msg.codec}"`;
- castCodecRef.current = msg.codec;
-
- if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
- setError(t('video.err_mse', { codec: msg.codec }));
- setPhase('error');
- return;
- }
-
- durationRef.current = msg.duration || 0;
-
- // A second init on a live SourceBuffer is a seek landing, not a new
- // film. Reuse what is there: rebuilding the MediaSource would reset the
- // element's src, blank the picture and throw away the duration the
- // scrubber is drawn from.
- if (sbRef.current && msRef.current
- && msRef.current.readyState === 'open') {
- console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current);
- initSegmentRef.current = null;
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.stop().catch(() => {});
- castRestartPendingRef.current = true;
- }
- reinitAt(msg.start || 0).catch(() => {
- setError(t('video.err_transport'));
- setPhase('error');
- });
- return;
- }
-
- // If we reach here during a seek (readyState was 'ended' after the
- // previous stream finished), the seek-landing path above could not run.
- // A fresh MediaSource is needed, but the seek state must still be reset
- // or awaitingInit stays true and every segment is dropped forever.
- awaitingInitRef.current = false;
- endedRef.current = false;
- appendingRef.current = false;
- queueRef.current = [];
- sbRef.current = null;
- initSegmentRef.current = null;
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.stop().catch(() => {});
- castRestartPendingRef.current = true;
- }
-
- const ms = new MediaSource();
- msRef.current = ms;
- if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
- const url = URL.createObjectURL(ms);
- blobUrlRef.current = url;
-
- ms.addEventListener('sourceopen', () => {
- if (cancelled) return;
- if (durationRef.current > 0) {
- ms.duration = durationRef.current;
- }
- const sb = ms.addSourceBuffer(mime);
- sbRef.current = sb;
- // 'segments', not 'sequence': the fragments must land where they
- // belong on the film's timeline rather than one after another, or a
- // stream that started at 40 minutes would be buffered at zero and
- // the scrubber would lie about everything.
- sb.mode = 'segments';
- try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ }
- if (msg.start) seekTargetRef.current = msg.start;
- transport.sendStreamDiag({
- event: 'first-init', target: msg.start || 0,
- offset: sb.timestampOffset, duration: durationRef.current,
- t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
- });
- sb.addEventListener('updateend', () => {
- // No credit is granted here, deliberately. Appending is not the
- // same question as having room, and tying the two meant `remove()`
- // — which fires this event too — paid the node for the player's own
- // evictions. What may be in flight is decided from the buffer, in
- // pump(), and nowhere else.
- appendingRef.current = false;
- landPlayhead();
- pump();
- });
- setPhase('streaming');
- flushQueue();
- });
-
- if (videoRef.current) {
- videoRef.current.src = url;
- videoRef.current.addEventListener('seeking', onSeeking);
- videoRef.current.addEventListener('timeupdate', pump);
- // The element's own verdict. "buffering" on screen is this, and it
- // is the one thing the node cannot infer from a stream it is feeding.
- videoRef.current.addEventListener('waiting', onStarved);
- videoRef.current.addEventListener('stalled', onStarved);
- videoRef.current.addEventListener('playing', onFed);
- videoRef.current.addEventListener('canplay', onFed);
- }
- };
-
- transport.onStreamData = async (msg) => {
- if (cancelled) return;
- // A segment arrived, so it is no longer in flight — whatever we go on
- // to do with it. This has to come before every early return below, and
- // it did not: skipping the count for segments we discard leaks a slot
- // out of the window each time, and the window never grows back.
- //
- // `reinitAt` is asynchronous — it waits for two `updateend` events —
- // and a seek's first segments arrive during that gap and are dropped
- // by the flag below. Lose all eight and the player believes a full
- // window is in flight, grants nothing ever again, and the node waits
- // for credit that cannot come. A race, which is why the same seek
- // worked twice and hung on the third.
- outstandingRef.current = Math.max(0, outstandingRef.current - 1);
- if (awaitingInitRef.current) {
- console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
- }
- // Between asking for a seek and its `stream_init`, everything on the
- // channel is the film we just left. Same file, so `file_id` cannot
- // tell them apart — ordering can.
- if (awaitingInitRef.current) return;
- // Late segments from the stream we just left. The DataChannel is
- // ordered, so they arrive before the new stream's first segment and
- // would otherwise be decrypted against the wrong file — which fails,
- // loudly, in the console, for something that is simply not ours.
- if (msg.file_id && msg.file_id !== entry.id) return;
- try {
- const plaintext = await window.MeshBayCrypto.decryptChunkBin(
- gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
- if (initSegmentRef.current === null) {
- initSegmentRef.current = plaintext;
- if (castRestartPendingRef.current && castActiveRef.current
- && platform.cast.available) {
- castRestartPendingRef.current = false;
- const gen = ++castRestartGenRef.current;
- const device = castDeviceRef.current;
- platform.cast.start({
- codec: castCodecRef.current,
- initSegment: plaintext,
- }).then(async (result) => {
- if (castRestartGenRef.current !== gen) return;
- if (!result) return;
- setCastUrl(result.url);
- const status = await platform.cast.status();
- if (status && status.chromecast && status.chromecast.connected) {
- await platform.cast.chromecastReload({ mediaUrl: result.url });
- } else if (device) {
- await platform.cast.chromecastConnect({
- deviceId: device.id, mediaUrl: result.url,
- });
- setCastDeviceName(device.name);
- } else {
- navigator.clipboard.writeText(result.url).catch(() => {});
- }
- }).catch((err) => {
- if (castRestartGenRef.current !== gen) return;
- console.error('[cast] restart failed:', err);
- platform.cast.stop().catch(() => {});
- setCastActive(false); castActiveRef.current = false;
- setCastUrl(null); setCastDeviceName(null);
- });
- }
- }
- if (castActiveRef.current && !castRestartPendingRef.current
- && platform.cast.available) {
- platform.cast.push(plaintext).catch(() => {});
- }
- queueRef.current.push(plaintext);
- // pump(), not flushQueue(): arriving data is the moment to top the
- // window back up, and that is what keeps the stream continuous.
- pump();
- } catch (e) {
- console.error('[MSE] decrypt error:', e);
- }
- };
-
- transport.onStreamEnd = (msg) => {
- if (cancelled) return;
- // The end of the previous film is not the end of this one.
- if (msg && msg.file_id && msg.file_id !== entry.id) return;
- // Nor is the end of the stream we abandoned by seeking: taking it
- // would call endOfStream() and truncate the film at the seek point.
- if (awaitingInitRef.current) return;
- endedRef.current = true;
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.finish().catch(() => {});
- }
- flushQueue();
- };
-
- // The opening window, and the count that tracks it. Asking for more here
- // than pump() maintains would leave the node holding credit this side
- // does not know about, which is the whole window's worth of overshoot on
- // the very first breath of the stream.
- outstandingRef.current = STREAM_WINDOW;
- const resumeAt = readResumePosition(entry.id);
- if (resumeAt) setResumedFrom(resumeAt);
- transport.requestStream(entry.id, STREAM_WINDOW, resumeAt);
- };
-
- // Closing the tab, or backgrounding it on a phone, never runs a React
- // cleanup — so the node hears nothing and keeps transcoding. `pagehide`
- // fires in both cases and is the one event mobile browsers honour on the
- // way out; `visibilitychange` covers switching apps. The node stops the
- // stream by itself when the connection drops, but that costs a round of
- // detection, and this message is a single datagram already in flight.
- const leave = (why) => {
- const t = transportRef.current;
- console.log('[MeshBay] stopStream:', why);
- if (t && t.connected) t.stopStream();
- };
- const onPageHide = () => leave('pagehide');
- // NOT wired to stopStream. Android fires visibilitychange when a video goes
- // fullscreen, so cutting the stream here killed the film the moment it was
- // watched properly. Logged only, until that is confirmed or ruled out.
- const onVisibility = () => {
- console.log('[MeshBay] visibilitychange:', document.visibilityState);
- };
- window.addEventListener('pagehide', onPageHide);
- document.addEventListener('visibilitychange', onVisibility);
-
- // `timeupdate` is silent while the film is paused, and the append path
- // cannot wake itself once the ceiling has refused a segment. This is the
- // clock that guarantees something is still driving the pipeline.
- const pumpTimer = setInterval(pump, 1000);
-
- // What the player sees, into the node's log. A hang on a phone shows the
- // node feeding a stream quite happily; the half that says otherwise is in
- // here, and there is no console to read it from.
- const diagTimer = setInterval(() => {
- const v = videoRef.current, sb = sbRef.current;
- const t = transportRef.current;
- if (!t || !v) return;
- // Cheap, and the only thing that makes "resume where I stopped" work
- // when the tab is closed rather than the player.
- if (!v.paused) {
- writeResumePosition(entry.id, v.currentTime, durationRef.current);
- }
- let ranges = '';
- try {
- for (let i = 0; sb && i < sb.buffered.length; i++) {
- ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
- }
- } catch { ranges = '?'; }
- t.sendStreamDiag({
- t: +v.currentTime.toFixed(1),
- ahead: +bufferedAhead().toFixed(1),
- ranges: ranges.trim(),
- ready: v.readyState, // 0 = nothing, 4 = enough to play through
- paused: v.paused,
- stalled: stalledRef.current,
- q: queueRef.current.length,
- inflight: outstandingRef.current,
- appending: appendingRef.current,
- updating: sb ? sb.updating : null,
- quota: quotaRef.current,
- ms: msRef.current ? msRef.current.readyState : null,
- err: v.error ? `${v.error.code}:${v.error.message}` : null,
- });
- }, 5000);
-
- startStream().catch(err => {
- if (!cancelled) { setError(err.message); setPhase('error'); }
- });
-
- return () => {
- cancelled = true;
- clearInterval(pumpTimer);
- clearInterval(diagTimer);
- clearTimeout(seekTimerRef.current);
- // Closing the player is the commonest way to stop watching, so this is
- // the write that matters most.
- if (videoRef.current) {
- writeResumePosition(entry.id, videoRef.current.currentTime,
- durationRef.current);
- }
- if (castActiveRef.current && platform.cast.available) {
- platform.cast.chromecastDisconnect().catch(() => {});
- platform.cast.stop().catch(() => {});
- castActiveRef.current = false;
- }
- window.removeEventListener('pagehide', onPageHide);
- document.removeEventListener('visibilitychange', onVisibility);
- if (videoRef.current) {
- videoRef.current.removeEventListener('seeking', onSeeking);
- videoRef.current.removeEventListener('timeupdate', pump);
- videoRef.current.removeEventListener('waiting', onStarved);
- videoRef.current.removeEventListener('stalled', onStarved);
- videoRef.current.removeEventListener('playing', onFed);
- videoRef.current.removeEventListener('canplay', onFed);
- }
- if (transport) {
- // Tell the node first: dropping the handlers only makes us deaf, and a
- // stream nobody is listening to still occupies a transcode slot.
- transport.stopStream();
- transport.onStreamInit = null;
- transport.onStreamData = null;
- transport.onStreamEnd = null;
- transport.onStreamError = null;
- }
- // The queue can hold several megabytes of decrypted video.
- queueRef.current = [];
- const ms = msRef.current;
- if (ms && ms.readyState === 'open') {
- try { ms.endOfStream(); } catch { /* already ended */ }
- }
- if (blobUrlRef.current) {
- URL.revokeObjectURL(blobUrlRef.current);
- blobUrlRef.current = null;
- }
- sbRef.current = null;
- msRef.current = null;
- };
- }, [entry, flushQueue, pump]);
-
- useEffect(() => {
- if (phase === 'streaming' && videoRef.current) {
- videoRef.current.play().catch(() => {});
- }
- }, [phase]);
-
- useEffect(() => {
- return () => {
- if (blobUrlRef.current) {
- URL.revokeObjectURL(blobUrlRef.current);
- blobUrlRef.current = null;
- }
- };
- }, []);
-
- useEffect(() => {
- const onKey = (e) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [onClose]);
-
- return html`
- <div class="video-overlay" onClick=${(e) => {
- if (e.target.classList.contains('video-overlay')) onClose();
- }}>
- <div class="video-top-bar">
- <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
- ${platform.capabilities.lanCast && html`
- <div class="cast-wrapper" style="position:relative">
- <button class="video-close ${castActive ? 'cast-active' : ''}"
- onClick=${async () => {
- if (castActive) {
- await platform.cast.chromecastDisconnect().catch(() => {});
- await platform.cast.stop();
- setCastActive(false); castActiveRef.current = false;
- setCastUrl(null);
- setCastDeviceName(null);
- castDeviceRef.current = null;
- } else if (castCodecRef.current && initSegmentRef.current) {
- if (castPickerOpen) {
- setCastPickerOpen(false);
- } else {
- setCastPickerOpen(true);
- setCastScanning(true);
- setCastDevices([]);
- platform.cast.discover().then((devices) => {
- setCastDevices(devices || []);
- setCastScanning(false);
- }).catch(() => setCastScanning(false));
- }
- }
- }}
- title="${castActive ? t('cast.stop') : t('cast.start')}">
- <${Icon} name="cast" /></button>
- ${castPickerOpen && html`
- <div class="cast-picker">
- ${castScanning && html`
- <div class="cast-picker-item cast-picker-scanning">
- <span class="spinner" style="width:14px;height:14px"></span>
- ${t('cast.scanning')}
- </div>
- `}
- ${castDevices.map(d => html`
- <button class="cast-picker-item" onClick=${() => {
- setCastPickerOpen(false);
- setCastDeviceName(d.name);
- setCastActive(true); castActiveRef.current = true;
- castRestartPendingRef.current = true;
- castDeviceRef.current = d;
- if (videoRef.current && requestSeekRef.current) {
- requestSeekRef.current(videoRef.current.currentTime);
- }
- }}>
- <${Icon} name="cast" /> ${d.name}
- </button>
- `)}
- ${!castScanning && castDevices.length === 0 && html`
- <div class="cast-picker-item cast-picker-empty">
- ${t('cast.no_devices')}
- </div>
- `}
- <div class="cast-picker-sep"></div>
- <button class="cast-picker-item" onClick=${async () => {
- setCastPickerOpen(false);
- setCastActive(true); castActiveRef.current = true;
- castRestartPendingRef.current = true;
- if (videoRef.current && requestSeekRef.current) {
- requestSeekRef.current(videoRef.current.currentTime);
- }
- }}>
- <${Icon} name="clip" /> ${t('cast.copy_url')}
- </button>
- </div>
- `}
- </div>
- `}
- ${castUrl && html`
- <span class="cast-status-label">
- ${castDeviceName
- ? castDeviceName
- : html`<input class="cast-url-input" readOnly value=${castUrl}
- onClick=${(e) => {
- e.target.select();
- navigator.clipboard.writeText(castUrl).catch(() => {});
- }}
- title="${t('cast.copy_url')}" />`
- }
- </span>
- `}
- ${onDownload && html`
- <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
- onClick=${() => {
- if (!dlBusy) {
- setDlBusy(true);
- onDownload();
- setTimeout(() => setDlBusy(false), 1500);
- }
- }}
- title="${t('group.download')}">
- ${dlBusy
- ? html`<span class="spinner"></span>`
- : html`<${Icon} name="download" />`}</button>
- `}
- <button class="video-close" onClick=${onClose} title="${t('video.close')}">
- <${Icon} name="close" /></button>
- </div>
-
- ${phase === 'loading' && html`
- <div class="video-loading">
- <div class="video-loading-label">
- <span class="spinner"></span>${' '}${t('video.buffering')}
- </div>
- </div>
- `}
-
- ${(phase === 'streaming' || phase === 'loading') && html`
- <div class="video-container">
- <video ref=${videoRef} controls autoplay />
- ${resumedFrom > 0 && html`
- <div class="video-resumed">
- ${t('video.resumed_at', { time: formatClock(resumedFrom) })}
- <button class="linklike" onClick=${() => {
- setResumedFrom(0);
- writeResumePosition(entry.id, 0, durationRef.current);
- if (requestSeekRef.current) requestSeekRef.current(0);
- }}>${t('video.from_start')}</button>
- </div>
- `}
- </div>
- `}
-
- ${phase === 'error' && html`
- <div class="video-error">${error}</div>
- `}
- </div>
- `;
-}
// ── Search Page (cross-group file search) ───────────────────────────────────
@@ -5740,7 +2233,7 @@ function NodePage({ token, username, userId, groups }) {
setStatus('connecting');
setError('');
try {
- if (!_bundleKey) _bundleKey = await _loadBundleKey();
+ if (!session.bundleKey) session.bundleKey = await _loadBundleKey();
const live = (await ensureFreshToken()) || token;
const nodeCandidates = (await Promise.all(
@@ -5777,7 +2270,7 @@ function NodePage({ token, username, userId, groups }) {
console.log('[NodePage] connecting via group', groupId.slice(0, 8),
'node', nodeId.slice(0, 8));
await transport.connect(nodeId, live, groupId,
- null, null, _bundleKey, username, userId);
+ null, null, session.bundleKey, username, userId);
console.log('[NodePage] connected, fetching status');
const result = await transport.fetchNodeStatus();
console.log('[NodePage] got status:', result.groups?.length, 'groups');
@@ -6273,7 +2766,7 @@ function App() {
// A renewal can happen inside hubFetch, well away from any render. This is
// how the component learns about it — including a failed one, which sets
// null and lands on the login page instead of failing every later call.
- _onAuthChange = (auth) => setUser(auth);
+ setAuthChangeListener((auth) => setUser(auth));
// On mount above all: a tab reopened tomorrow holds an hour-old access
// token and a refresh token good for a month, and used to greet its owner
@@ -6289,7 +2782,7 @@ function App() {
};
document.addEventListener('visibilitychange', onVisible);
return () => {
- _onAuthChange = null;
+ setAuthChangeListener(null);
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
@@ -6469,8 +2962,8 @@ function App() {
refreshToken = data.refreshToken;
// The only thing sign-in produces: the key that opens a node's bundle.
// Which identity we use is decided per node, when we get there.
- _bundleKey = data.bundleKey;
- await _storeBundleKey(_bundleKey);
+ session.bundleKey = data.bundleKey;
+ await _storeBundleKey(session.bundleKey);
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',