diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-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')
43 files changed, 4294 insertions, 3617 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 73f07d9..ad109b2 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -49,6 +49,11 @@ OP_MEMBER_UNPIN = "member_unpin" # setting decides who may write to the operator's disk, so a node that took it # from an unsigned message would let any member re-enable it for everyone. OP_MEMBER_UPLOAD = "member_upload" +# Which group "applications" (Chat, Files, and whatever registers later) are +# shown to members. Signed like the rest: it decides what a member sees, not +# anything about key material, but an unsigned toggle would let any member +# turn a disabled one back on. +OP_APPS_ENABLED = "apps_enabled" OP_ROOT_ADD = "root_add" OP_ROOT_REMOVE = "root_remove" OP_GROUP_ATTACH = "group_attach" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 06acb8b..53e5085 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -80,6 +80,8 @@ class MNP: MEMBER_UNPIN_ACK = "member_unpin_ack" MEMBER_UPLOAD = "member_upload" # operator → node: may members upload? MEMBER_UPLOAD_ACK = "member_upload_ack" + APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show + APPS_ENABLED_ACK = "apps_enabled_ack" # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 19d22d4..e11e718 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -28,7 +28,13 @@ router = APIRouter(tags=["webapp"]) # which is the failure this list exists to prevent, and it is silent. _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", "i18n.js", "downloads.js", "transfers.js", "zipstream.js", - "platform.js", "meshbay-m.png") + "platform.js", "meshbay-m.png", + # Split out of app.js by the group-page refactor — each imported by + # app.js or group-page.js, so a change to any of them is a change + # to what the browser must fetch. + "icon.js", "file-utils.js", "hub-client.js", "apps.js", + "chat-app.js", "files-app.js", "video-player.js", + "group-settings.js", "group-page.js") def _asset_version() -> str: diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 36df0cd..d18d9a4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -7,124 +7,23 @@ import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; import * as downloads from './downloads.js'; import * as platform from './platform.js'; +import { Icon } from './icon.js'; +import { FILE_ICONS, formatSize } from './file-utils.js'; +import { + HUB, navigate, session, getCachedGroupIndex, getAllCachedIndexes, + _storeBundleKey, _loadBundleKey, _clearKeyDB, + loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, + refreshAccessToken, +} from './hub-client.js'; +import { GroupPage } from './group-page.js'; // ── Constants ──────────────────────────────────────────────────────────────── -// Where the hub is. Empty in a browser — it served this page, so a relative -// path cannot be pointed at the wrong place. In the installed app the page -// comes from disk and has no origin of its own, so the base is configured. -// See platform.js. -const HUB = platform.hubBase(); -const AUTH_KEY = 'mb_auth'; -// Renew an access token with this much life left rather than waiting for it to -// fail. Generous against a one-hour token: a film is watched without the hub -// hearing a word, and coming back to a tab that has been asleep for an hour -// should not cost a round trip before the first click works. -const TOKEN_RENEW_MARGIN_S = 600; // How often to look. Cheap — it reads a timestamp out of the token and almost // always does nothing. const TOKEN_CHECK_MS = 60000; const THEME_KEY = 'mb_theme'; -const IDB_NAME = 'meshbay'; -const IDB_VERSION = 1; -const IDB_STORE = 'group_indexes'; - -// ── IndexedDB cache ───────────────────────────────────────────────────────── - -function openDB() { - return new Promise((resolve, reject) => { - const req = indexedDB.open(IDB_NAME, IDB_VERSION); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(IDB_STORE)) { - db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -async function cacheGroupIndex(groupId, groupName, entries) { - try { - const db = await openDB(); - const tx = db.transaction(IDB_STORE, 'readwrite'); - tx.objectStore(IDB_STORE).put({ - groupId, groupName, entries, cachedAt: Date.now(), - }); - await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); - db.close(); - } catch { /* best-effort */ } -} - -async function getCachedGroupIndex(groupId) { - try { - const db = await openDB(); - const tx = db.transaction(IDB_STORE, 'readonly'); - const req = tx.objectStore(IDB_STORE).get(groupId); - const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); - db.close(); - return result || null; - } catch { return null; } -} - -async function getAllCachedIndexes() { - try { - const db = await openDB(); - const tx = db.transaction(IDB_STORE, 'readonly'); - const req = tx.objectStore(IDB_STORE).getAll(); - const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); - db.close(); - return result || []; - } catch { return []; } -} - -// ── Auth persistence ───────────────────────────────────────────────────────── -// The key that opens a node's keypair bundle, derived once at sign-in. There is -// no global identity to keep: identity keys belong to a node and are fetched from -// it (transport.js), so nothing of that kind lives here. -let _bundleKey = null; -// A one-time pairing code the user just typed, consumed by the next connection -// attempt. Deliberately not persisted: it is single-use and short-lived. -let _pendingJoinCode = null; - -function _openKeyDB() { - return new Promise((resolve, reject) => { - const req = indexedDB.open('meshbay_keys', 1); - req.onupgradeneeded = () => req.result.createObjectStore('k'); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} -async function _storeBundleKey(key) { - try { - const db = await _openKeyDB(); - const tx = db.transaction('k', 'readwrite'); - tx.objectStore('k').put(key, 'bk'); - await new Promise(r => { tx.oncomplete = r; }); - db.close(); - } catch {} -} -async function _loadBundleKey() { - try { - const db = await _openKeyDB(); - const tx = db.transaction('k', 'readonly'); - const g = tx.objectStore('k').get('bk'); - const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); - db.close(); - return val || null; - } catch { return null; } -} -async function _clearKeyDB() { - try { - const db = await _openKeyDB(); - const tx = db.transaction('k', 'readwrite'); - tx.objectStore('k').clear(); - await new Promise(r => { tx.oncomplete = r; }); - db.close(); - } catch {} -} /** * Rough passphrase strength, in bits, and what it is up against. * @@ -166,112 +65,6 @@ async function _pkXFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } -function loadAuth() { - try { - return JSON.parse(localStorage.getItem(AUTH_KEY)); - } catch { - return null; - } -} - -function saveAuth(auth) { - if (auth) { - localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); - } else { - localStorage.removeItem(AUTH_KEY); - _bundleKey = null; - _clearKeyDB(); - } -} - -// ── Session ────────────────────────────────────────────────────────────────── -// -// The access token lasts an hour and the refresh token thirty days. Nothing was -// using the second: `hubFetch` reported a 401 as an error like any other, so an -// hour of watching a film — during which the hub hears nothing, because the -// video comes over WebRTC — ended with "token expired or invalid" and no way -// out but signing out and back in. Reopening the tab the next day did the same, -// with a perfectly good refresh token sitting in localStorage beside the stale -// access one. -// -// This lives outside the component because `hubFetch` is a plain function and -// has to be able to renew a token mid-request without every caller passing the -// machinery down to it. - -let _auth = loadAuth(); -let _onAuthChange = null; // set by App, so the UI follows a background renewal -let _refreshing = null; // in flight, shared: see refreshAccessToken - -function setAuth(auth) { - _auth = auth; - saveAuth(auth); - if (_onAuthChange) _onAuthChange(auth); -} - -/** Seconds until this JWT expires, or null if it says nothing useful. */ -function tokenLifeLeft(token) { - try { - const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); - if (!payload.exp) return null; - return payload.exp - Math.floor(Date.now() / 1000); - } catch { - return null; // not a JWT we can read; treat as unknown, never as expired - } -} - -/** - * Trade the refresh token for a new pair. - * - * The hub rotates: it revokes the token presented and returns a new one, and a - * revoked token presented again revokes the whole family. So the new one must - * be stored — the previous code kept only the access token and dropped its - * replacement, which burned the refresh token on first use and locked the - * account out of renewal on the second. That is why signing out and in was the - * only way back. - * - * Concurrent callers share one request. Two 401s racing would otherwise send - * the same refresh token twice, and the second would look exactly like theft. - */ -async function refreshAccessToken() { - if (!_auth || !_auth.refreshToken) return null; - if (_refreshing) return _refreshing; - _refreshing = (async () => { - try { - const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: _auth.refreshToken }), - }); - if (!r.ok) { - // Expired, revoked, or the family was torn down. Nothing to salvage: - // sign out cleanly rather than leave a session that fails every call. - setAuth(null); - return null; - } - const data = await r.json(); - setAuth({ - ..._auth, - token: data.access_token, - refreshToken: data.refresh_token || _auth.refreshToken, - }); - return data.access_token; - } catch { - return null; // offline: keep the session, the next call can try again - } finally { - _refreshing = null; - } - })(); - return _refreshing; -} - -/** Renew before it bites, rather than after. */ -async function ensureFreshToken() { - if (!_auth || !_auth.token) return null; - const left = tokenLifeLeft(_auth.token); - if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token; - return refreshAccessToken(); -} - // ── Theme ──────────────────────────────────────────────────────────────────── function getInitialTheme() { @@ -287,38 +80,6 @@ function resolveTheme(pref) { return pref; } -// ── Hub API ────────────────────────────────────────────────────────────────── - -async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { - const headers = {}; - if (body) headers['Content-Type'] = 'application/json'; - // Prefer the token the session currently holds. Callers read theirs from - // React state, which is a render behind a renewal that happened in the - // background — and sending the stale one would 401 for no reason. - const bearer = token && _auth && _auth.token ? _auth.token : token; - if (bearer) headers['Authorization'] = `Bearer ${bearer}`; - const opts = { method, headers }; - if (body) opts.body = JSON.stringify(body); - const r = await platform.apiFetch(HUB + path, opts); - if (r.status === 401 && bearer && !_retried) { - // The one case worth a second attempt: the access token aged out while - // nothing was talking to the hub. Renew once and replay. If the renewal - // fails it signs out, and the replay below is skipped. - const fresh = await refreshAccessToken(); - if (fresh) { - return hubFetch(path, { method, body, token: fresh, _retried: true }); - } - } - if (!r.ok) { - const err = await r.json().catch(() => ({ detail: r.statusText })); - const detail = Array.isArray(err.detail) - ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ') - : (err.detail || r.statusText); - throw new Error(String(detail)); - } - return r.json(); -} - // ── Router ─────────────────────────────────────────────────────────────────── function useRoute() { @@ -331,103 +92,16 @@ function useRoute() { return hash; } -function navigate(path) { - window.location.hash = path; -} - // ── Context ────────────────────────────────────────────────────────────────── const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } -// ── Icons ──────────────────────────────────────────────────────────────────── -// -// One stroked set, drawn in currentColor and sized in em, so an icon takes the -// weight and colour of the text beside it. The Administration entry was already -// an outline shield while the rest of the site was colour emoji — a different -// drawing on every operating system, and never the same line weight twice. -// -// The explorer keeps its emoji on purpose. There the icon says what kind of file -// this is, and the colour is doing real work; a wall of identical grey outlines -// would be a worse file list. - -const ICON_PATHS = { - menu: ['M4 7h16M4 12h16M4 17h16'], - bell: ['M18 9a6 6 0 1 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', - 'M10.3 20a2 2 0 0 0 3.4 0'], - shield: ['M12 3l7.5 3v5.2c0 4.6-3.1 8.6-7.5 10.3-4.4-1.7-7.5-5.7-7.5-10.3V6z'], - globe: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18', - 'M3.4 9.2h17.2M3.4 14.8h17.2', - 'M12 3c-2.6 2.4-4 5.6-4 9s1.4 6.6 4 9c2.6-2.4 4-5.6 4-9s-1.4-6.6-4-9'], - archive: ['M3 7.5h18v3H3z', 'M4.5 10.5V19a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-8.5', - 'M10 14h4'], - play: ['M8 5.5v13l11-6.5z'], - eye: ['M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12', - 'M12 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5'], - trash: ['M4 7h16', 'M10 11v6M14 11v6', - 'M6 7l1 12.5A1.5 1.5 0 0 0 8.5 21h7a1.5 1.5 0 0 0 1.5-1.5L18 7', - 'M9.5 7V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v2'], - user: ['M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8', - 'M4.5 20a7.5 7.5 0 0 1 15 0'], - gear: ['M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6', - 'M19.2 14.4a1.7 1.7 0 0 0 .3 1.9 2 2 0 1 1-2.8 2.8 1.7 1.7 0 0 0-2.9 1.2 2 2 0 0 1-4 0 1.7 1.7 0 0 0-2.9-1.2 2 2 0 1 1-2.8-2.8 1.7 1.7 0 0 0-1.2-2.9 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.2-2.9 2 2 0 1 1 2.8-2.8 1.7 1.7 0 0 0 2.9-1.2 2 2 0 0 1 4 0 1.7 1.7 0 0 0 2.9 1.2 2 2 0 1 1 2.8 2.8 1.7 1.7 0 0 0 1.2 2.9 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1z'], - sun: ['M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6', - 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4'], - moon: ['M20.8 13.4A8.6 8.6 0 1 1 10.6 3.2a6.9 6.9 0 0 0 10.2 10.2z'], - power: ['M12 3.2v8.4', 'M6.9 6.6a7.6 7.6 0 1 0 10.2 0'], - lock: ['M5.5 11h13a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1V12a1 1 0 0 1 1-1z', - 'M8 11V7.4a4 4 0 0 1 8 0V11'], - envelope: ['M4 5.5h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z', - 'M3.4 6.6L12 13.4l8.6-6.8'], - door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5', - 'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'], - download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'], - upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'], - transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5', - 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'], - search: ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5'], - dots: ['M12 5.6h.01', 'M12 12h.01', 'M12 18.4h.01'], - checkbox: ['M5.5 4h13a1.5 1.5 0 0 1 1.5 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-13A1.5 1.5 0 0 1 5.5 4z'], - home: ['M4 11.2L12 4.5l8 6.7', 'M6.2 9.8V19a1 1 0 0 0 1 1h9.6a1 1 0 0 0 1-1V9.8'], - 'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z', - 'M12 11.4v5', 'M9.5 13.9h5'], - plus: ['M12 5v14', 'M5 12h14'], - clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'], - pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3', - 'M14.5 6.5l3 3'], - check: ['M4.5 12.5l5 5 10-11'], - chevron: ['M6 9.5l6 6 6-6'], - close: ['M6 6l12 12M18 6L6 18'], - chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'], - folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'], - 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', - 'M10.3 20a2 2 0 0 0 3.4 0', - 'M4 4l16 16'], - server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', - 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', - 'M8 7.5h.01', 'M8 16.5h.01'], - cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21', - 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6', - 'M2 21h.01'], -}; - // The M of the wordmark is a picture; the rest is text. Resolved from this // module's own URL so the hub's fingerprinted path and the application's // app:// scheme both come out right without either being named here. const BRAND_M = new URL('./meshbay-m.png', import.meta.url).href; -function Icon({ name, cls = '' }) { - const paths = ICON_PATHS[name]; - if (!paths) return null; - return html` - <svg class="icon ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" - fill="none" stroke="currentColor" stroke-width="1.6" - stroke-linecap="round" stroke-linejoin="round"> - ${paths.map((d, i) => html`<path key=${i} d=${d} />`)} - </svg> - `; -} - // ── User Menu ──────────────────────────────────────────────────────────────── function UserMenu({ user, theme, onThemeChange, onLogout }) { @@ -1298,7 +972,7 @@ function CreateGroupWizard({ token, username, onCreated }) { const pairResult = await platform.node.call('POST', '/api/operator/pair'); if (pairResult && pairResult.code) { await platform.node.setPairingCode(pairResult.code); - _pendingJoinCode = pairResult.code; + session.pendingJoinCode = pairResult.code; } update('done'); @@ -1471,3187 +1145,6 @@ function CreateGroupWizard({ token, username, onCreated }) { </div>`; } -// ── Helpers ────────────────────────────────────────────────────────────────── - -const FILE_ICONS = { - video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', - document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', -}; - -function formatSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; -} - -function formatDate(ts) { - return new Date(ts * 1000).toLocaleDateString(undefined, { - year: 'numeric', month: 'short', day: 'numeric', - }); -} - -// ── Group Page ────────────────────────────────────────────────────────────── - -const PREVIEWABLE_TEXT = - /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; - -function canPreview(e) { - return ['image', 'video', 'document'].includes(e.type) - || PREVIEWABLE_TEXT.test(e.name); -} - -const CHUNK_SIZE = 1024 * 1024; -// Seconds of already-watched video kept in the SourceBuffer, and the queue depth -// past which we start making room before being forced to. -const BUFFER_BEHIND_S = 60; -// How far past the playhead we are willing to pull. The browser caps a video -// SourceBuffer at a few hundred megabytes and refuses the append that goes -// past, so "as fast as the network allows" is not a strategy for a film: the -// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a -// ten-megabit second fills the ceiling in the first minute. Buffering by time -// rather than by bytes keeps a two-hour film and a two-minute clip alike. -const BUFFER_AHEAD_S = 90; -// While we deliberately hold credit back, the node must still hear from us: its -// own stall timeout is two minutes, and a paused film is not a gone viewer. -const CREDIT_KEEPALIVE_MS = 20000; -// Segments allowed in flight while there is room to put them. This is a window, -// topped up as segments land, and not a debt released in one go: accumulating a -// credit per append and handing the lot over when the buffer finally had room -// sent 6 MB in a burst, overshot the target by a minute of film, and then said -// nothing for the next forty-six seconds. Measured in Chrome against real -// fragmented MP4. A stream that arrives in gulps has no margin for a network -// that hesitates, and looks like a hang while it is quiet. -const STREAM_WINDOW = 8; -// Dragging the scrubber fires `seeking` continuously, and every seek we act on -// kills an ffmpeg and spawns another. Only where the finger stops is worth a -// restart. -const SEEK_DEBOUNCE_MS = 350; -// A position is remembered per file, in this browser. Below the first threshold -// there is nothing to resume; above the second the film is finished and -// offering to resume thirty seconds before the credits is a nuisance. -const RESUME_MIN_S = 30; -const RESUME_MAX_FRACTION = 0.97; -const QUEUE_HIGH_WATER = 12; -const PIPELINE_WINDOW = 8; - -/** - * Open somewhere to write, honouring the user's download setting. - * - * Returns a target ({writable, name}), null for "no stream available — collect - * it and hand the browser a blob", or false for "the person dismissed the - * dialog", which is not an error and must not start a transfer. - */ -async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, - swSize = size) { - // On a desktop build this is the whole answer, and it comes first. - // - // The two browser paths below are both unavailable there — `showDirectoryPicker` - // does not exist, and Chromium refuses a service worker on a custom scheme — - // so without this the chain fell all the way through to its floor, which - // collects the file in the page and hands the browser a blob. A gigabyte of - // film meant a gigabyte of RAM, and a Save As dialog at the *end*. - if (platform.capabilities.nativeSave) { - try { - const native = await platform.nativeSave( - filename, { auto: downloads.getMode() === 'auto' }); - // Null means the person dismissed the dialog, which is not an error and - // must not start a transfer. - return native || false; - } catch (err) { - console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err)); - return false; - } - } - - try { - const target = await downloads.openTarget(filename); - if (target) return target; - } catch (err) { - console.warn('[MeshBay] download folder unusable:', err.message); - } - - // No granted folder. A service worker can still hand the browser a stream to - // write, which is how this works at all in Firefox: the alternative there is - // to collect gigabytes in a tab. It goes to the browser's own download - // folder, without a dialog, which is what "save automatically" meant. - if (downloads.getMode() === 'auto') { - const streamed = await downloads.openStreamedDownload(filename, swSize); - if (streamed) return streamed; - // Nothing to stream to: small enough for memory, and no dialog. - if (size < downloads.BLOB_LIMIT) return null; - } - - if (!window.showSaveFilePicker) return null; - try { - const handle = await window.showSaveFilePicker({ - suggestedName: filename, ...pickerOpts, - }); - return { writable: await handle.createWritable(), name: handle.name || filename }; - } catch (err) { - if (err.name === 'AbortError') return false; - throw err; - } -} - -/** The download of last resort, for browsers with no way to stream to disk. */ -function _saveBlob(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, - writable, signal) { - const results = writable ? null : new Array(totalChunks); - let nextSend = 0, nextRecv = 0; - const inflight = new Array(totalChunks); - - const fire = () => { - while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { - inflight[nextSend] = transport.fetchChunk(fileId, nextSend); - nextSend++; - } - }; - - fire(); - while (nextRecv < totalChunks) { - if (signal && signal.aborted) { - const err = new Error('Cancelled'); - err.name = 'AbortError'; - throw err; - } - const chunkMsg = await inflight[nextRecv]; - let plaintext; - if (gekKey && chunkMsg.ct) { - plaintext = await window.MeshBayCrypto.decryptChunkBin( - gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); - } else if (gekKey && chunkMsg.ct_b64) { - plaintext = await window.MeshBayCrypto.decryptChunk( - gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64); - } else { - plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64); - } - if (writable) { - await writable.write(plaintext); - } else { - results[nextRecv] = plaintext; - } - nextRecv++; - fire(); - if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks); - } - return results; -} - -function GroupPage({ groupId, group, token, username, userId, userPrefs, - onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) { - const [status, setStatus] = useState('idle'); - const [entries, setEntries] = useState([]); - - const [error, setError] = useState(''); - const [selecting, setSelecting] = useState(false); - const [selected, setSelected] = useState(() => new Set()); - const [editingDesc, setEditingDesc] = useState(false); - const [descDraft, setDescDraft] = useState(''); - const [savingDesc, setSavingDesc] = useState(false); - const [sortKey, setSortKey] = useState('name'); - const [sortAsc, setSortAsc] = useState(true); - const [filter, setFilter] = useState(''); - const [currentPath, setCurrentPath] = useState(''); - const [videoEntry, setVideoEntry] = useState(null); - const [previewEntry, setPreviewEntry] = useState(null); - const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat'; - const [tab, setTab] = useState(defaultTab); - useEffect(() => { setTab(defaultTab); }, [groupId]); - // A directory from the group just left rarely exists in the one just - // entered (e.g. "outputs" in one group, absent in another) — Files would - // otherwise show that stale path and list nothing. - useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); - const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted)); - - const _lastTouch = useRef(0); - const touchActivity = useCallback(() => { - const now = Date.now(); - if (now - _lastTouch.current < 60_000) return; - _lastTouch.current = now; - const ts = new Date().toISOString(); - if (onGroupUpdated) onGroupUpdated(groupId, { last_activity_at: ts }); - hubFetch(`/v1/groups/${groupId}/activity`, { method: 'POST', token }).catch(() => {}); - }, [groupId, token, onGroupUpdated]); - - const toggleGroupMute = useCallback(async () => { - const next = !groupMuted; - setGroupMuted(next); - try { - await hubFetch(`/v1/groups/${groupId}/mute`, { - method: 'POST', token, body: { muted: next }, - }); - if (onGroupUpdated) onGroupUpdated(groupId, { muted: next }); - } catch (err) { - setGroupMuted(!next); - } - }, [groupMuted, groupId, token, onGroupUpdated]); - - // Directories are not index entries, so a new empty one needs a nudge - // to appear in the breadcrumb listing. - const [nodeDirs, setNodeDirs] = useState([]); - // The group's roots and whether each is readable. A root whose drive is - // unplugged keeps its files listed — they are frozen, not deleted — so this is - // the only thing that lets the UI say which of the two it is. - const [nodeRoots, setNodeRoots] = useState([]); - - const [isNodeAdmin, setIsNodeAdmin] = useState(false); - // Whether ordinary members may upload here. The node decides and enforces it; - // this only says whether to offer the controls. Defaults to true so a node - // that predates the setting behaves as it always did. - const [memberUpload, setMemberUpload] = useState(true); - // Paired ≠ operator account. `is_node_admin` says the hub account owning this - // node is the one connecting; this says the node pinned *this browser's* key - // as an operator key. Only the second one lets you sign an invite, and only - // the second one should make the pairing form go away. - const [operatorPaired, setOperatorPaired] = useState(false); - const [needsCode, setNeedsCode] = useState(false); - // This browser holds a key the node does not know, for an account it does. - // Not the operator's problem: a device already paired here can admit it. - const [needsDevice, setNeedsDevice] = useState(false); - const [deviceCode, setDeviceCode] = useState(''); - const [codeInput, setCodeInput] = useState(''); - const [retryKey, setRetryKey] = useState(0); - const transportRef = useRef(null); - const gekRef = useRef(null); - // One refresh per mount: if a fresh token still says we are not a member, we - // really are not, and retrying forever would hide that. - const refreshedRef = useRef(false); - - const submitJoinCode = useCallback((e) => { - e.preventDefault(); - const code = codeInput.trim(); - if (!code) return; - _pendingJoinCode = code; - setCodeInput(''); - setNeedsCode(false); - setError(''); - setRetryKey(k => k + 1); - }, [codeInput]); - - // One place that takes an index from the node and puts it everywhere it has to - // go. Deleting a file used to refresh the table and leave the cache alone, so - // the search page went on offering a file that no longer existed until the - // group was reconnected. - const applyIndex = useCallback((indexMsg) => { - const fresh = indexMsg.entries || []; - setEntries(fresh); - if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); - if (indexMsg.roots) setNodeRoots(indexMsg.roots); - cacheGroupIndex(groupId, group ? group.name : groupId, fresh); - }, [groupId, group]); - - useEffect(() => { - let cancelled = false; - - // The cache is written here and read only by the search page. It used to - // seed this list too, which put a stale index on screen and then raced the - // live one: IndexedDB is async, so a fast node could be overwritten by the - // cache landing afterwards. Files shows what the node says, or says it - // cannot reach the node. - - const connect = async () => { - setStatus('discovering'); - setError(''); - gekRef.current = null; - if (!_bundleKey) _bundleKey = await _loadBundleKey(); - try { - const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); - if (cancelled) return; - if (!nodesData.nodes || nodesData.nodes.length === 0) { - setStatus('offline'); - if (onPresence) onPresence(groupId, 'offline'); - return; - } - - // No keys are carried in: the transport fetches this node's identity - // from the node, or creates one there on a first join. - const sessionKeys = null; - - setStatus('connecting'); - const nodeId = nodesData.nodes[0].node_id; - // Renewed here rather than taken from the prop. This effect no longer - // re-runs when the token rotates (see the dependency list below), so - // the captured one can be older than the session's — and it is used to - // sign the offer to the hub, where an expired one is a 401 and no - // connection at all. Renewals are shared, so if one is already in - // flight this waits for it instead of starting a second. - const live = (await ensureFreshToken()) || token; - // The same base the API calls use: signaling is a hub endpoint like - // any other, and two sources for one address is how they drift. - const transport = new window.MeshBayTransport(HUB, live); - transportRef.current = transport; - - const ack = await transport.connect( - nodeId, live, groupId, null, sessionKeys, _bundleKey, username, - userId, _pendingJoinCode); - _pendingJoinCode = null; - if (cancelled) return; - setIsNodeAdmin(!!ack.is_node_admin); - setMemberUpload(ack.member_upload !== false); - // Changed while we are connected, by an operator who may be someone - // else entirely. Without this the button stays until a reconnection, - // and a button that is still there is a button people press. - transport.onUploadPolicy = (allowed) => setMemberUpload(allowed); - setOperatorPaired(transport.memberRole === 'operator'); - - // A first join to this node generated an identity for it; leave it with - // the node so any other browser can become the same person here with the - // passphrase. It is this node's key and no other's. - if (transport.connected && transport.newNodeBundle) { - try { - await transport.storeKeypairBundle(transport.newNodeBundle); - transport.newNodeBundle = null; - } catch (e) { - console.warn('[MeshBay] could not leave our key with the node:', e.message); - } - } - - // Import GEK from transport (fetched from node during handshake) - if (transport.gekRaw && window.MeshBayCrypto) { - gekRef.current = await window.MeshBayCrypto.importGEK( - window.MeshBayCrypto.b64encode(transport.gekRaw)); - } - - setStatus('fetching'); - - transport.onIndexSync = (msg) => { - if (cancelled) return; - applyIndex(msg); - }; - - // We are in: an invitation to this group has served its purpose. - if (onJoined) onJoined(groupId); - - const indexMsg = await transport.fetchIndex(); - if (cancelled) return; - applyIndex(indexMsg); - setStatus('connected'); - touchActivity(); - // First-hand evidence, and the strongest available: this browser spoke - // to the node. It outranks whatever the hub said in the group list. - if (onPresence) onPresence(groupId, 'online'); - } catch (err) { - if (cancelled) return; - - // Our token predates being added to this group. Refresh once and retry - // rather than telling someone who was just invited that they are not a - // member — which is what the node honestly sees, and is useless to them. - if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) { - refreshedRef.current = true; - try { - if (await onRefreshAuth()) { - setRetryKey(k => k + 1); - return; - } - } catch { /* fall through to the message below */ } - } - - // The node has never seen this browser for this account: it needs a - // one-time code from the operator before it will hand over the group - // key. Not an error to shout about — a step in joining. - if (err.reason === 'code_required') setNeedsCode(true); - // A key this node has never pinned, for an account it knows. The way in - // is a device already trusted here, not an operator — which is the - // whole point of device linking: a second browser or a native client - // must not cost anyone a support request. - if (err.reason === 'unknown_device') setNeedsDevice(true); - setError(err.message); - setStatus('error'); - if (transportRef.current) { - try { transportRef.current.close(); } catch { /* already gone */ } - transportRef.current = null; - } - // A refusal means the node answered, so it is up; only a failure to - // reach it at all is evidence of absence. - if (onPresence) { - onPresence(groupId, err.reason ? 'online' : 'offline'); - } - } - }; - - if (token && window.MeshBayTransport) { - connect(); - } else if (!window.MeshBayTransport) { - setStatus('error'); - setError(t('group.err_transport')); - } - - return () => { - cancelled = true; - if (transportRef.current) { - // Handed over rather than closed: a download running when you leave the - // group keeps its connection, and the last transfer using it closes it. - transfers.releaseWhenIdle(transportRef.current); - transportRef.current = null; - } - }; - // applyIndex is deliberately not a dependency: its identity changes with the - // `group` object, which the hub poll re-creates, and re-running this effect - // means tearing down the WebRTC connection. groupId is here, so a real group - // change still re-captures it. - // - // Neither is the token itself, only whether there is one. It used to be a - // dependency and that was harmless while a token never changed during a - // session — it only expired. Now that the session renews itself, the string - // rotates, and this effect tore the WebRTC connection down and rebuilt it - // every time. Worst on arrival: a stored token past its life is renewed the - // instant the page mounts, which is exactly when the group page is - // negotiating ICE, so the connection was abandoned mid-handshake and the - // node sat in `connecting` for ever. The live token is read inside - // `connect()` instead. Signing out unmounts this page; signing in mounts - // it; nothing in between should disturb a working connection. - }, [groupId, Boolean(token), retryKey]); - - const downloadFile = useCallback(async (entry) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - const gek = gekRef.current; - - // Both of these have to happen inside the click: a browser grants a file - // picker, and re-grants a folder, only from a user gesture. - const target = await _openDownloadTarget(entry.name, entry.size); - if (target === false) return; // the picker was dismissed - - const openRef = { url: null }; - transfers.start({ - kind: 'download', name: (target && target.name) || entry.name, - total: entry.size, transport, - open: target - ? (target.open || null) - : () => { if (openRef.url) window.open(openRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - let done = 0; - const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; - - if (target) { - try { - await pipelinedDownload(transport, gek, entry.id, totalChunks, - onChunk, target.writable, signal); - await target.writable.close(); - } catch (err) { - await target.writable.abort().catch(() => {}); - throw err; - } - } else { - const chunks = await pipelinedDownload( - transport, gek, entry.id, totalChunks, onChunk, null, signal); - const blob = new Blob(chunks); - _saveBlob(blob, entry.name); - openRef.url = URL.createObjectURL(blob); - } - }, - }); - }, []); - - const uploadFile = useCallback((e) => { - const files = [...(e.target.files || [])]; - e.target.value = ''; - const transport = transportRef.current; - if (!files.length || !transport || !transport.connected) return; - setError(''); - - for (const file of files) { - transfers.start({ - kind: 'upload', name: file.name, total: file.size, transport, - run: async ({ signal, onProgress }) => { - await transport.uploadFile(file, { - // Bytes the node acknowledged, not bytes read locally. - onProgress: (sent) => onProgress(sent, file.size), - signal, - }); - // The node re-indexes on a filesystem event, so there is nothing to - // wait on but the clock. Refreshing here means the file appears in - // the list without anyone reloading. - await new Promise(r => setTimeout(r, 2500)); - if (transport.connected) applyIndex(await transport.fetchIndex()); - }, - }); - } - }, [applyIndex]); - - const makeDirectory = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - const name = prompt(t('group.mkdir_prompt')); - if (!name || !name.trim()) return; - try { - await transport.createDirectory(currentPath, name.trim()); - const indexMsg = await transport.fetchIndex(); - if (indexMsg.entries) setEntries(indexMsg.entries); - if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); - if (indexMsg.roots) setNodeRoots(indexMsg.roots); - } catch (err) { - setError(err.message); - } - }, [currentPath]); - - const saveDescription = useCallback(async (e) => { - e.preventDefault(); - setSavingDesc(true); - try { - const r = await hubFetch(`/v1/groups/${groupId}`, { - method: 'PATCH', token, body: { description: descDraft }, - }); - if (onGroupUpdated) onGroupUpdated(groupId, { description: r.description }); - setEditingDesc(false); - } catch (err) { - setError(err.message); - } finally { - setSavingDesc(false); - } - }, [groupId, token, descDraft, onGroupUpdated]); - - /** - * Download a directory as a zip, written straight to disk. - * - * An archive of a group directory is routinely tens of gigabytes, so it is - * never held anywhere: each file is fetched chunk by chunk, decrypted, and - * handed to the zip writer, which hands it to the file the browser opened. - * Peak memory is one chunk plus one small record per file. - * - * Without the File System Access API there is nowhere to stream to, and the - * only alternative is to build the whole thing in memory — so that path is - * offered but says what it costs first. - */ - const downloadDirectory = useCallback(async (dir) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - - const files = entriesUnder(entries, dir); - if (!files.length) { - setError(t('group.zip_empty')); - return; - } - const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); - const suggested = (dir.split('/').pop() || 'files') + '.zip'; - - // totalBytes decides how this is delivered, but it is not the archive's - // size — headers and the central directory come on top — so it is not - // announced as a Content-Length that the download would then miss. - const target = await _openDownloadTarget(suggested, totalBytes, { - types: [{ description: 'ZIP archive', - accept: { 'application/zip': ['.zip'] } }], - }, 0); - if (target === false) return; - if (!target && !confirm(t('group.zip_no_stream', { - size: formatSize(totalBytes), name: suggested, - }))) { - return; - } - const gek = gekRef.current; - const zipOpenRef = { url: null }; - - transfers.start({ - kind: 'download', name: (target && target.name) || suggested, - total: totalBytes, transport, - open: target - ? (target.open || null) - : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const writable = target ? target.writable : null; - const parts = writable ? null : []; - let written = 0; - try { - const zip = new ZipStream(async (bytes) => { - if (writable) await writable.write(bytes); - else parts.push(bytes.slice()); - }); - - for (const { entry, name } of files) { - await zip.begin(name, entry.size, - new Date((entry.added_at || 0) * 1000)); - // A zero-byte file has no chunk to ask for; the header and an empty - // descriptor are the whole entry. - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - if (totalChunks > 0) await pipelinedDownload( - transport, gek, entry.id, totalChunks, - (bytes) => { written += bytes; onProgress(written, totalBytes); }, - // pipelinedDownload writes in order, which the archive needs. - { write: (plaintext) => zip.write(plaintext) }, signal); - await zip.end(); - } - await zip.finish(); - if (writable) await writable.close(); - else { - const blob = new Blob(parts, { type: 'application/zip' }); - _saveBlob(blob, suggested); - zipOpenRef.url = URL.createObjectURL(blob); - } - } catch (err) { - if (writable) await writable.abort().catch(() => {}); - throw err; - } - }, - }); - }, [entries]); - - const deleteDirectory = useCallback(async (dir) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - await transport.deleteDirectory(dir, signFn); - applyIndex(await transport.fetchIndex()); - } catch (err) { - setError(err.message); - } - }, [applyIndex]); - - const deleteFile = useCallback(async (entry) => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - // Signs an explicit transcript built by transport.js, not opaque bytes from - // the node — see MeshBayCrypto.adminTranscript and finding H5. - // Signed with the identity this node pinned for us — the only one it - // will accept, and the only one we hold here. - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - await transport.deleteFile(entry.id, signFn); - applyIndex(await transport.fetchIndex()); - } catch (err) { - setError(err.message); - } - }, [applyIndex]); - - const refreshIndex = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - applyIndex(await transport.fetchIndex()); - } catch {} - }, [applyIndex]); - - const toggleSort = useCallback((key) => { - setSortAsc(prev => sortKey === key ? !prev : true); - setSortKey(key); - }, [sortKey]); - - const dirs = new Set(); - const filteredEntries = entries.filter(e => { - const ePath = e.path || ''; - if (ePath === currentPath) { - return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); - } - if (!currentPath && ePath) { - dirs.add(ePath.split('/')[0]); - } else if (currentPath && ePath.startsWith(currentPath + '/')) { - const rest = ePath.slice(currentPath.length + 1); - dirs.add(rest.split('/')[0]); - } - return false; - }); - - const sorted = [...filteredEntries].sort((a, b) => { - let cmp = 0; - if (sortKey === 'name') cmp = a.name.localeCompare(b.name); - else if (sortKey === 'size') cmp = a.size - b.size; - else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); - else if (sortKey === 'date') cmp = a.added_at - b.added_at; - return sortAsc ? cmp : -cmp; - }); - - // The node's own listing, so an empty folder is visible, plus anything implied - // by a file path in case the two ever disagree. - for (const d of nodeDirs) { - if (!currentPath && !d.includes('/')) dirs.add(d); - else if (currentPath && d.startsWith(currentPath + '/')) { - const rest = d.slice(currentPath.length + 1); - if (!rest.includes('/')) dirs.add(rest); - } - } - const subdirs = [...dirs].sort(); - - // At the top of a group the folders on screen ARE the roots, so their state - // belongs there. Deeper in, everything shown lives inside one readable root - // and there is nothing to flag. - const rootState = new Map(nodeRoots.map(r => [r.name, r])); - const unavailableHere = currentPath - ? [] - : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); - // A member cannot create a folder at the top of a group: that level is the - // set of roots, which is the operator's configuration and not a directory on - // anyone's disk. The node refuses it, so offering it would only produce an - // error nobody can act on. - const canCreateDir = Boolean(currentPath) && isNodeAdmin; - - const baseLabel = { - idle: t('status.idle'), - discovering: t('status.discovering'), - connecting: t('status.connecting'), - fetching: t('status.fetching'), - connected: t('status.files', { n: entries.length }), - offline: t('status.offline'), - error: t('status.error'), - }[status] || status; - const statusLabel = baseLabel; - - const breadcrumbs = currentPath ? currentPath.split('/') : []; - - // Selection is keyed globally — file ids, and 'dir:' plus a full path — so - // walking into another folder keeps what was already ticked. - const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name); - const selectedFiles = entries.filter(e => selected.has(e.id)); - const selectedDirs = [...selected] - .filter(k => typeof k === 'string' && k.startsWith('dir:')) - .map(k => k.slice(4)); - const toggle = (key) => setSelected(prev => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); else next.add(key); - return next; - }); - - const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0 - ? selectedFiles[0] : null; - const deletableFiles = selectedFiles.filter( - e => isNodeAdmin || (userId && e.uploader_id === userId)); - - // Asked in two places — the Files toolbar and the chat composer — so it is - // answered once. The operator is never locked out of their own node. - const mayUpload = memberUpload || isNodeAdmin; - const run = (fn) => { - setSelecting(false); - setSelected(new Set()); - Promise.resolve().then(fn).catch(err => { - if (err && err.name !== 'AbortError') setError(err.message); - }); - }; - - // Icon only, with the name in the tooltip: these sit in a toolbar that is - // already narrow, and every one of them is a verb the icon carries on its - // own. `title` gives the hover text and `aria-label` the accessible name — - // an icon button with neither is unusable with a screen reader. - // - // Every action is rendered as soon as Select is on, and the ones that do not - // apply are disabled rather than absent. Buttons appearing and vanishing as - // the selection changed made the bar jump about and gave no clue that an - // action existed at all before something was ticked. - const action = (icon, label, onClick, opts = {}) => html` - <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}" - title=${label} aria-label=${label} - disabled=${!!opts.disabled} onClick=${onClick}> - <${Icon} name=${icon} /> - </button> - `; - - const canPlay = !!(onlyFile && onlyFile.type === 'video'); - const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); - const deletableCount = deletableFiles.length - + (operatorPaired ? selectedDirs.length : 0); - // The operator can always delete; anyone else only ever sees the button if - // something here is theirs to remove. Hiding it from an uploader would take - // away a right the protocol grants them (draft-v5 §5.1), not just a control. - const mayEverDelete = isNodeAdmin - || (userId && entries.some(e => e.uploader_id === userId)); - - const actionItems = html` - ${action('play', t('group.play'), - () => run(() => setVideoEntry(onlyFile)), { disabled: !canPlay })} - ${action('eye', t('group.view'), - () => run(() => setPreviewEntry(onlyFile)), { disabled: !canView })} - ${action('download', - selectedFiles.length - ? t('group.download_n', { n: selectedFiles.length }) - : t('group.download'), - () => run(async () => { - // Awaited one at a time, and each returns as soon as its transfer is - // registered — so the transfers still run together. Firing them without - // awaiting meant every file asked the browser for a save dialog at - // once, and a browser allows one: the rest were rejected and only the - // first file ever downloaded. - for (const e of selectedFiles) await downloadFile(e); - }), { disabled: selectedFiles.length === 0 })} - ${action('archive', - selectedDirs.length - ? t('group.download_zip_n', { n: selectedDirs.length }) - : t('group.download_zip_n', { n: 0 }), - () => run(async () => { - for (const d of selectedDirs) await downloadDirectory(d); - }), { disabled: selectedDirs.length === 0 })} - ${mayEverDelete && action('trash', - deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), - () => { - const names = [...deletableFiles.map(e => e.name), - ...(operatorPaired ? selectedDirs : [])]; - if (!confirm(t('group.delete_n_confirm', { n: names.length, - names: names.join(', ') }))) return; - run(() => { - for (const e of deletableFiles) deleteFile(e); - if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); - }); - }, - { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} - `; - - return html` - <div> - <div class="group-header"> - <div> - <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}"> - ${group ? group.name : t('group.default_name')} - </h2> - ${editingDesc - ? html` - <form class="group-desc-edit" onSubmit=${saveDescription}> - <textarea rows="2" maxlength="512" autofocus - placeholder="${t('group.desc_placeholder')}" - value=${descDraft} - onInput=${e => setDescDraft(e.target.value)}></textarea> - <div> - <button class="admin-btn" type="submit" disabled=${savingDesc}> - ${savingDesc ? '...' : t('group.desc_save')} - </button> - <button class="btn-secondary" type="button" - onClick=${() => setEditingDesc(false)}>${t('group.desc_cancel')}</button> - </div> - </form> - ` - : html` - ${group && group.description && html` - <p class="group-desc">${group.description}</p> - `} - ${group && group.is_admin && html` - <button class="link-btn" title=${t('group.desc_edit')} - onClick=${() => { setDescDraft(group.description || ''); - setEditingDesc(true); }}> - <${Icon} name="pencil" />${' '} - ${group.description ? t('group.desc_edit') : t('group.desc_add')} - </button> - `} - `} - </div> - ${group && html` - <button class="group-mute-btn" onClick=${toggleGroupMute} - title=${groupMuted ? t('group.unmute') : t('group.mute')}> - <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} /> - </button> - `} - </div> - ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '} - <button class="admin-btn" style="margin-left:8px;font-size:0.9em" - onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button> - </div>`} - ${needsDevice && html` - <div class="invite-form" style="margin-bottom:12px"> - <h4>${t('device.add_title')}</h4> - <p class="settings-hint">${t('device.add_hint')}</p> - ${!deviceCode && html` - <button class="admin-btn" onClick=${async () => { - try { - const transport = transportRef.current; - const out = await transport.requestDeviceAdd(userId); - setDeviceCode(out.code); - } catch (err) { setError(err.message); } - }}>${t('device.add_btn')}</button> - `} - ${deviceCode && html` - <p class="settings-hint">${t('device.add_show')}</p> - <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px"> - ${deviceCode} - </p> - `} - </div> - `} - ${needsCode && html` - <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}> - <h4>${t('group.join_code_title')}</h4> - <p class="settings-hint">${t('group.join_code_hint')}</p> - <div style="display:flex;gap:8px"> - <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" - value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required /> - <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button> - </div> - </form> - `} - ${/* Not gated on the connection any more. Leaving a group, deleting it - and seeing who is in it are hub-side, and moving them into this tab - would otherwise have made them unreachable exactly when a node is - down — which is when someone is most likely to want them. Files and - chat still need the node and say so. */ group && html` - <div class="group-tabs"> - <button class="group-tab ${tab === 'chat' ? 'active' : ''}" - onClick=${() => setTab('chat')} title=${t('group.tab_chat')}> - <${Icon} name="chat" cls="tab-icon" /></button> - <button class="group-tab ${tab === 'files' ? 'active' : ''}" - onClick=${() => setTab('files')} title=${t('group.tab_files')}> - <${Icon} name="folder" cls="tab-icon" /></button> - <button class="group-tab ${tab === 'settings' ? 'active' : ''}" - onClick=${() => setTab('settings')} title=${t('group.tab_settings')}> - <${Icon} name="gear" cls="tab-icon" /></button> - </div> - - ${tab === 'files' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html` - <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> - `} - ${tab === 'files' && status === 'offline' && html` - <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> - `} - - ${tab === 'files' && status === 'connected' && html` - <div class="file-toolbar"> - <div class="toolbar-group"> - ${mayUpload && html` - <label class="tb-btn primary"> - <${Icon} name="upload" /> ${t('group.upload')} - <input type="file" multiple style="display:none" - onChange=${uploadFile} /> - </label> - `} - ${canCreateDir && html` - <button class="tb-btn" onClick=${makeDirectory}> - <${Icon} name="folder-plus" /> ${t('group.mkdir')} - </button> - `} - </div> - - <div class="breadcrumbs"> - <a class="crumb" onClick=${() => setCurrentPath('')}> - <${Icon} name="home" /> - </a> - ${breadcrumbs.map((seg, i) => { - const path = breadcrumbs.slice(0, i + 1).join('/'); - return html` - <span class="crumb-sep">/</span> - <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a> - `; - })} - </div> - - <div class="toolbar-group right"> - <div class="tb-search"> - <${Icon} name="search" /> - <input type="text" placeholder="${t('group.filter')}" - value=${filter} onInput=${e => setFilter(e.target.value)} /> - </div> - <button class="tb-btn ${selecting ? 'active' : ''}" - onClick=${() => { - setSelecting(v => !v); - setSelected(new Set()); - }}> - <${Icon} name=${selecting ? 'check' : 'checkbox'} /> - ${selecting ? t('group.select_done') : t('group.select')} - </button> - ${selecting && html`<div class="tb-actions">${actionItems}</div>`} - </div> - </div> - <table class="file-table"> - <thead> - <tr> - ${selecting && html`<th class="sel-cell"></th>`} - <th></th> - <th class="sortable" onClick=${() => toggleSort('name')}> - ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} - </th> - <th class="sortable" onClick=${() => toggleSort('size')}> - ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} - </th> - <th class="sortable th-type" onClick=${() => toggleSort('type')}> - ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} - </th> - <th class="sortable th-date" onClick=${() => toggleSort('date')}> - ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''} - </th> - </tr> - </thead> - <tbody> - ${subdirs.map(d => { - const full = currentPath ? currentPath + '/' + d : d; - const inside = entriesUnder(entries, full); - const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); - return html` - <tr class="file-row dir-row" key=${full} onClick=${() => - selecting ? toggle(dirKey(d)) : setCurrentPath(full)}> - ${selecting && html` - <td class="sel-cell"> - <input type="checkbox" checked=${selected.has(dirKey(d))} - onClick=${(ev) => ev.stopPropagation()} - onChange=${() => toggle(dirKey(d))} /> - </td> - `} - <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td> - <td>${d}${unavailableHere.includes(d) ? html` - <span class="root-offline"> ${t('group.root_unavailable')}</span> - ` : ''}</td> - <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> - <td class="td-type"></td> - <td class="td-date"></td> - </tr> - `; })} - ${sorted.map(e => html` - <tr class="file-row" key=${e.id} - onClick=${() => selecting && toggle(e.id)}> - ${selecting && html` - <td class="sel-cell"> - <input type="checkbox" checked=${selected.has(e.id)} - onClick=${(ev) => ev.stopPropagation()} - onChange=${() => toggle(e.id)} /> - </td> - `} - <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td> - <td class="file-name"> - ${!selecting && canPreview(e) - ? html`<a class="file-link" onClick=${() => { - if (e.type === 'video') setVideoEntry(e); - else setPreviewEntry(e); - }}>${e.name}</a>` - : e.name - } - </td> - <td class="file-size">${formatSize(e.size)}</td> - <td class="file-type td-type">${e.type}</td> - <td class="file-date td-date">${formatDate(e.added_at)}</td> - </tr> - `)} - ${sorted.length === 0 && subdirs.length === 0 && html` - <tr><td colspan=${selecting ? 6 : 5} class="file-empty"> - ${filter ? t('group.empty_filter') : t('group.empty_dir')} - </td></tr> - `} - </tbody> - </table> - `} - - ${tab === 'chat' && status === 'connected' && html` - <${ChatPanel} transportRef=${transportRef} username=${username} - entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} - mayUpload=${mayUpload} onActivity=${touchActivity} - onPreview=${(entry) => { - if (entry.type === 'video') setVideoEntry(entry); - else setPreviewEntry(entry); - }} /> - `} - ${tab === 'chat' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html` - <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> - `} - ${tab === 'chat' && status === 'offline' && html` - <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> - `} - - ${tab === 'settings' && html` - <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token} - transportRef=${transportRef} gekRef=${gekRef} - isNodeAdmin=${isNodeAdmin} userId=${userId} - operatorPaired=${operatorPaired} connected=${status === 'connected'} - memberUpload=${memberUpload} - onMemberUpload=${(allowed) => setMemberUpload(allowed)} - onLeft=${onLeft} - onPaired=${() => setOperatorPaired(true)} /> - `} - `} - ${status === 'offline' && !group && html` - <p class="page-message"> - ${t('group.offline_title')} - ${' '}${t('group.offline_hint')} - </p> - `} - ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html` - <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> - `} - ${previewEntry && html` - <${FilePreview} - entry=${previewEntry} - transportRef=${transportRef} - gekRef=${gekRef} - onClose=${() => setPreviewEntry(null)} - onDownload=${() => downloadFile(previewEntry)} /> - `} - ${videoEntry && html` - <${VideoPlayer} - entry=${videoEntry} - transportRef=${transportRef} - gekRef=${gekRef} - onClose=${() => setVideoEntry(null)} - onDownload=${() => downloadFile(videoEntry)} /> - `} - </div> - `; -} - -// ── File Preview (text, images) ───────────────────────────────────────── - -const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; -const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; - -function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { - const [phase, setPhase] = useState('loading'); - const [progress, setProgress] = useState(0); - const [content, setContent] = useState(null); - const [error, setError] = useState(''); - const [downloading, setDownloading] = useState(false); - const blobUrlRef = useRef(null); - - useEffect(() => { - let cancelled = false; - const load = async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) { - setError(t('video.err_transport')); - setPhase('error'); - return; - } - try { - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - let downloaded = 0; - const chunks = await pipelinedDownload( - transport, gekRef.current, entry.id, totalChunks, - (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, - ); - if (cancelled) return; - - if (/\.pdf$/i.test(entry.name)) { - // Decrypted here and shown from a blob: URL — the bytes never leave - // the page, and the browser's own viewer renders them. - const blob = new Blob(chunks, { type: 'application/pdf' }); - blobUrlRef.current = URL.createObjectURL(blob); - setContent({ type: 'pdf' }); - } else if (entry.name.match(IMAGE_EXTS)) { - const ext = entry.name.split('.').pop().toLowerCase(); - const mime = ext === 'svg' ? 'image/svg+xml' - : ext === 'png' ? 'image/png' - : ext === 'gif' ? 'image/gif' - : ext === 'webp' ? 'image/webp' - : 'image/jpeg'; - const blob = new Blob(chunks, { type: mime }); - blobUrlRef.current = URL.createObjectURL(blob); - setContent({ type: 'image' }); - } else { - const decoder = new TextDecoder('utf-8', { fatal: false }); - const text = chunks.map(c => decoder.decode(c, { stream: true })).join(''); - setContent({ type: 'text', text: text.slice(0, 500000) }); - } - setPhase('ready'); - } catch (err) { - if (!cancelled) { setError(err.message); setPhase('error'); } - } - }; - load(); - return () => { cancelled = true; }; - }, [entry]); - - useEffect(() => { - const onKey = (e) => { if (e.key === 'Escape') onClose(); }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose]); - - useEffect(() => { - return () => { - if (blobUrlRef.current) { - URL.revokeObjectURL(blobUrlRef.current); - blobUrlRef.current = null; - } - }; - }, []); - - return html` - <div class="video-overlay" onClick=${(e) => { - if (e.target.classList.contains('video-overlay')) onClose(); - }}> - <div class="video-top-bar"> - <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> - ${onDownload && html` - <button class="video-close ${downloading ? 'dl-active' : ''}" - onClick=${() => { - if (!downloading) { - setDownloading(true); - onDownload(); - setTimeout(() => setDownloading(false), 1500); - } - }} - title="${t('group.download')}" disabled=${downloading}> - ${downloading - ? html`<span class="spinner"></span>` - : html`<${Icon} name="download" />`}</button> - `} - <button class="video-close" onClick=${onClose} title="${t('video.close')}"> - <${Icon} name="close" /></button> - </div> - ${phase === 'loading' && html` - <div class="video-loading"> - <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div> - <div class="video-progress-bar"> - <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div> - </div> - </div> - `} - ${phase === 'ready' && content?.type === 'pdf' && html` - <object data=${blobUrlRef.current} type="application/pdf" - class="preview-pdf" aria-label=${entry.name}> - <p class="page-message">${t('preview.pdf_fallback')}</p> - </object> - `} - ${phase === 'ready' && content?.type === 'image' && html` - <div class="preview-image-wrap"> - <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} /> - </div> - `} - ${phase === 'ready' && content?.type === 'text' && html` - <div class="preview-text-wrap"> - <pre class="preview-text">${content.text}</pre> - </div> - `} - ${phase === 'error' && html` - <div class="video-error">${error}</div> - `} - </div> - `; -} - -function _b64ToU8(b64) { - const bin = atob(b64); - const arr = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); - return arr; -} - -// ── Members Panel ──────────────────────────────────────────────────────── - -/** - * Everything about the group that is not its files or its chat. - * - * Was "Members", which was a list with three unrelated forms stacked on top of - * it and the group's own controls somewhere else entirely — leaving or deleting - * a group lived in the header, beside its title. One tab now, in sections, with - * the roster last: it is the part that grows without limit, and burying the - * controls under two hundred names is how a tab stops being usable. - */ -function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, - isNodeAdmin, userId, operatorPaired, connected, - memberUpload, onMemberUpload, - onPaired, onLeft }) { - const [members, setMembers] = useState([]); - const [adminId, setAdminId] = useState(''); - const [loading, setLoading] = useState(true); - const [inviteUser, setInviteUser] = useState(''); - const [inviting, setInviting] = useState(false); - const [error, setError] = useState(''); - - // Node loopback state (Electron-only) - const [nodeDetected, setNodeDetected] = useState(false); - const [nodeRoots, setNodeRoots] = useState([]); - const [nodeGroupName, setNodeGroupName] = useState(''); - const [nodeBusy, setNodeBusy] = useState(false); - const [nodeMsg, setNodeMsg] = useState(''); - - const loadNodeInfo = useCallback(async () => { - if (!platform.node.available) return; - try { - const detect = await platform.node.detect(); - if (!detect.detected) { setNodeDetected(false); return; } - setNodeDetected(true); - const data = await platform.node.call('GET', '/api/groups'); - const groups = data.groups || []; - const ng = groups.find(g => g.id === groupId); - if (ng) { - setNodeRoots(ng.roots || []); - setNodeGroupName(ng.name || ''); - } - } catch { setNodeDetected(false); } - }, [groupId]); - - useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); - const [inviteCode, setInviteCode] = useState(null); - const [pairCode, setPairCode] = useState(''); - const [pairStatus, setPairStatus] = useState(''); - const [pairing, setPairing] = useState(false); - // Your own devices on this node. Not a members feature — it is beside them - // because this is where a live connection to the node exists. - const [devices, setDevices] = useState([]); - const [approveCode, setApproveCode] = useState(''); - const [deviceMsg, setDeviceMsg] = useState(''); - - // Pairing lives here rather than in Settings because this is where a live - // connection to the node exists — and it is offered only when the node itself - // says this account is its operator (is_node_admin comes from the authenticated - // handshake_ack, not from the hub). - const loadDevices = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - try { - const out = await transport.listDevices(); - setDevices(out.devices); - } catch { /* a node that has none says so by listing none */ } - }, [transportRef]); - - useEffect(() => { loadDevices(); }, [loadDevices]); - - const approveDevice = useCallback(async (e) => { - e.preventDefault(); - const code = approveCode.trim(); - if (!code) return; - setDeviceMsg(''); - try { - await transportRef.current.approveDevice(userId, code); - setApproveCode(''); - setDeviceMsg(t('device.approved')); - await loadDevices(); - } catch (err) { setDeviceMsg(err.message); } - }, [approveCode, userId, transportRef, loadDevices]); - - const revokeDevice = useCallback(async (device) => { - if (!confirm(t('device.revoke_confirm'))) return; - setDeviceMsg(''); - try { - await transportRef.current.revokeDevice( - userId, device.pk_ed25519, device.pk_x25519 || ''); - await loadDevices(); - } catch (err) { setDeviceMsg(err.message); } - }, [userId, transportRef, loadDevices]); - - const doPair = useCallback(async (e) => { - e.preventDefault(); - const code = pairCode.trim(); - if (!code) return; - setPairing(true); - setPairStatus(''); - try { - const transport = transportRef && transportRef.current; - if (!transport || !transport.connected) throw new Error('Not connected to the node'); - await transport.pairOperator(userId, code); - setPairCode(''); - setPairStatus('paired'); - // The node has pinned this key as an operator key; the form has nothing - // left to do. It used to stay put through a refresh, because what governed - // it was the account, which pairing does not change. - if (onPaired) onPaired(); - } catch (err) { - setPairStatus(err.message); - } finally { - setPairing(false); - } - }, [pairCode, transportRef, userId]); - - const [uploadBusy, setUploadBusy] = useState(false); - const [uploadMsg, setUploadMsg] = useState(''); - - /** - * Close or open uploading for everyone who is not the operator. - * - * Signed, like removing a member: the node refuses an unsigned instruction, - * so this is a request to the node rather than a decision taken here. The - * button does not move until the node has said it did it. - */ - const setUploads = useCallback(async (allowed) => { - const transport = transportRef && transportRef.current; - setUploadMsg(''); - setUploadBusy(true); - try { - if (!transport || !transport.connected) { - throw new Error('Not connected to the node'); - } - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - await transport.setMemberUpload(allowed, signFn); - if (onMemberUpload) onMemberUpload(allowed); - } catch (err) { - setUploadMsg(err.message); - } finally { - setUploadBusy(false); - } - }, [transportRef, onMemberUpload]); - - const [removing, setRemoving] = useState(''); - - /** - * Take someone out of this group: both halves, in the order that fails safe. - * - * The node first, because that is the half that stops the group key being - * wrapped for them; if the hub removal then fails, they are a member on paper - * with no key. The other order would leave them able to reach a node that - * still serves them. - */ - const removeMember = useCallback(async (member) => { - const transport = transportRef && transportRef.current; - setError(''); - setRemoving(member.user_id); - try { - if (platform.node.available) { - try { - await platform.node.call('POST', - `/api/members/${member.user_id}/revoke?group_id=${groupId}`); - } catch { /* best effort — node may not host this group */ } - try { - await platform.node.call('POST', - `/api/members/${member.user_id}/unpin`); - } catch { /* best effort */ } - } else if (transport && transport.connected && operatorPaired) { - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - await transport.revokeMember(member.user_id, signFn); - } - await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { - method: 'DELETE', token, - }); - loadMembers(); - } catch (err) { - setError(err.message); - } finally { - setRemoving(''); - } - }, [groupId, token, transportRef, operatorPaired]); - - const loadMembers = useCallback(() => { - setLoading(true); - hubFetch(`/v1/groups/${groupId}/members`, { token }) - .then(data => { - setMembers(data.members || []); - setAdminId(data.admin_id || ''); - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [groupId, token]); - - useEffect(() => { loadMembers(); }, [loadMembers]); - - const isAdmin = group && group.is_admin; - - const doInvite = useCallback(async (e) => { - e.preventDefault(); - if (!inviteUser.trim()) return; - setInviting(true); - setError(''); - setInviteCode(null); - try { - const transport = transportRef && transportRef.current; - const username = inviteUser.trim(); - if (!transport || !transport.connected) { - throw new Error('Not connected to the node — it must be online to invite'); - } - - // The hub is asked for the account id, and nothing else. It is no longer - // asked for the invitee's public key: the node wraps the group key itself, - // for a key the invitee proves possession of when they connect (H3). A hub - // that answered with the wrong account here would produce an invite whose - // code it never learns — the code goes to a human, out of band. - const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); - - // Signed with the identity this node pinned for us — the only one it - // will accept, and the only one we hold here. - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - const result = await transport.createInvite( - account.user_id, groupId, username, signFn); - - // Membership on the hub is what lets them reach the node at all; the code - // is what gets them the key. - await hubFetch(`/v1/groups/${groupId}/members/${username}`, { - method: 'POST', token, body: {}, - }); - - setInviteCode({ username, code: result.code, expires: result.expires_at }); - setInviteUser(''); - loadMembers(); - } catch (err) { - setError(err.message); - } finally { - setInviting(false); - } - }, [groupId, token, inviteUser, loadMembers, transportRef]); - - if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; - - const isOwner = Boolean(isAdmin); - - return html` - <div class="members-panel"> - ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} - - ${/* Inviting needs the node: it is the node that wraps the group key and - issues the code, not the hub. Public groups admit anyone — no invite. - The form stays in the DOM so a brief reconnect does not destroy the - input the user is typing into — controls are disabled instead. */ - isAdmin && group?.join_policy !== 'open' && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('members.invite_title')}</h3> - ${!connected ? html` - <p class="settings-hint">${t('group.offline_title')}</p> - ` : !operatorPaired ? html` - <p class="settings-hint"> - ${isNodeAdmin ? t('members.invite_needs_pairing') - : t('members.invite_ask_operator')} - </p> - ` : ''} - <form onSubmit=${doInvite}> - ${inviteCode && html` - <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> - <p class="code-display">${inviteCode.code}</p> - <p>${t('members.invite_code_hint')}</p> - </div> - `} - <div class="form-row"> - <input type="text" placeholder="${t('members.username_placeholder')}" - value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} - disabled=${!connected || !operatorPaired} required /> - <button class="admin-btn" type="submit" - disabled=${inviting || !connected || !operatorPaired}> - ${inviting ? '...' : t('members.invite_btn')} - </button> - </div> - </form> - </div> - `} - - ${isNodeAdmin && !operatorPaired && connected && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('members.pair_title')}</h3> - <p class="settings-hint">${t('members.pair_hint')}</p> - ${pairStatus && html` - <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}> - ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} - </p> - `} - <form class="form-row" onSubmit=${doPair}> - <input type="text" placeholder="XXXX-XXXX" class="code-input" - value=${pairCode} onInput=${e => setPairCode(e.target.value)} required /> - <button class="admin-btn" type="submit" disabled=${pairing}> - ${pairing ? '...' : t('members.pair_btn')} - </button> - </form> - </div> - `} - - ${/* Operator only, and only with a live connection: the node is what - holds and enforces this, so there is nothing to show or change - without one. */ isNodeAdmin && connected && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('members.uploads_title')}</h3> - <div class="settings-row"> - <span class="settings-label"> - ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} - </span> - <button class="admin-btn" disabled=${uploadBusy} - onClick=${() => setUploads(!memberUpload)}> - ${uploadBusy ? '...' - : (memberUpload ? t('members.uploads_disable') - : t('members.uploads_enable'))} - </button> - </div> - <p class="settings-hint">${t('members.uploads_hint')}</p> - ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`} - </div> - `} - - ${connected && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('device.mine_title')}</h3> - <p class="settings-hint">${t('device.mine_hint')}</p> - ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`} - ${devices.length === 0 - ? html`<p class="settings-hint">${t('device.mine_empty')}</p>` - : html` - <ul class="device-list"> - ${devices.map(d => html` - <li class="device-row" key=${d.pk_ed25519}> - <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span> - <span class="device-meta"> - ${d.is_this_one && html` - <span class="badge">${t('device.this_one')}</span>${' '} - `} - ${d.pinned_via}${d.label ? ' · ' + d.label : ''} - </span> - ${!d.is_this_one && devices.length > 1 && html` - <button class="admin-btn" onClick=${() => revokeDevice(d)}> - ${t('device.revoke')} - </button> - `} - </li> - `)} - </ul> - `} - <form onSubmit=${approveDevice} class="settings-subform"> - <p class="settings-hint">${t('device.approve_hint')}</p> - <div class="form-row"> - <input type="text" placeholder="XXXX-XXXX" class="code-input" - value=${approveCode} onInput=${e => setApproveCode(e.target.value)} /> - <button class="admin-btn" type="submit">${t('device.approve_btn')}</button> - </div> - </form> - </div> - `} - - ${/* Roots management (Electron-only, when node is local) */ - nodeDetected && nodeRoots.length > 0 && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('settings_node.roots')}</h3> - ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`} - <div class="node-roots"> - ${nodeRoots.map(r => html` - <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" - key=${r.name}> - <div class="node-root-info"> - <span class="node-root-name"> - <${Icon} name="folder" /> - ${r.name} - </span> - ${r.upload && html` - <span class="node-root-badge">${t('node.upload_root')}</span>`} - ${!r.available && html` - <span class="node-root-badge node-root-badge-warn"> - ${t('node.unavailable')}</span>`} - </div> - ${nodeRoots.length > 1 && !r.upload && html` - <button class="btn btn-small btn-danger" - disabled=${nodeBusy} - onClick=${async () => { - if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return; - setNodeBusy(true); setNodeMsg(''); - try { - await platform.node.call('DELETE', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name)); - await platform.node.call('POST', '/api/reload'); - setNodeMsg(t('node.root_removed')); - await loadNodeInfo(); - } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } - finally { setNodeBusy(false); } - }}> - ${t('node.remove_root')}</button>`} - </div> - `)} - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${nodeBusy} - onClick=${async () => { - const chosen = await platform.rootPicker.choose(); - if (!chosen) return; - setNodeBusy(true); setNodeMsg(''); - try { - await platform.node.call('POST', - '/api/groups/' + groupId + '/roots', - { path: chosen.path, name: chosen.name }); - await platform.node.call('POST', '/api/reload'); - setNodeMsg(t('node.root_added')); - await loadNodeInfo(); - } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } - finally { setNodeBusy(false); } - }}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - </div> - </div> - `} - - ${/* Upload toggle via loopback when MNP not connected */ - nodeDetected && !connected && html` - <div class="settings-section"> - <h3 class="settings-heading">${t('members.uploads_title')}</h3> - <div class="settings-row"> - <span class="settings-label"> - ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} - </span> - <button class="admin-btn" disabled=${nodeBusy} - onClick=${async () => { - setNodeBusy(true); setNodeMsg(''); - try { - const newVal = !memberUpload; - await platform.node.call('PUT', - '/api/groups/' + groupId + '/member-upload', - { allowed: newVal }); - if (onMemberUpload) onMemberUpload(newVal); - } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } - finally { setNodeBusy(false); } - }}> - ${memberUpload ? t('members.uploads_disable') - : t('members.uploads_enable')} - </button> - </div> - <p class="settings-hint">${t('members.uploads_hint')}</p> - </div> - `} - - ${/* Delete/leave — node detach first (reversible), then hub delete - (irreversible). */ html` - <div class="settings-section"> - <h3 class="settings-heading"> - ${isOwner ? t('group.delete_group') : t('group.leave')} - </h3> - <div class="settings-row"> - <span class="settings-label"> - ${isOwner ? t('members.danger_delete_hint') - : t('members.danger_leave_hint')} - </span> - ${isOwner - ? html` - <button class="admin-btn danger" onClick=${async () => { - if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; - try { - // Node detach first (reversible), then hub delete (irreversible) - if (nodeDetected && nodeGroupName) { - try { - await platform.node.call('POST', '/api/groups/detach', - { name: nodeGroupName }); - } catch (detachErr) { - if (!confirm(t('settings_node.detach_failed_continue'))) return; - } - } - await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); - navigate('/'); - window.location.reload(); - } catch (err) { setError(err.message); } - }}>${t('group.delete_group')}</button> - ` - : html` - <button class="admin-btn danger" onClick=${async () => { - if (!confirm(t('group.leave_confirm', { name: group.name }))) return; - try { - await hubFetch('/v1/groups/' + groupId + '/leave', - { method: 'POST', token }); - if (onLeft) onLeft(groupId); - } catch (err) { setError(err.message); } - }}>${t('group.leave')}</button> - `} - </div> - </div> - `} - - <div class="settings-section"> - <h3 class="settings-heading"> - ${t('group.tab_members')} (${members.length}) - </h3> - <table class="admin-table"> - <thead> - <tr> - <th>${t('admin.col_username')}</th> - <th>${t('members.group_role')}</th> - <th></th> - </tr> - </thead> - <tbody> - ${members.map(m => html` - <tr key=${m.user_id}> - <td>${m.username}</td> - <td> - ${m.user_id === adminId - ? html`<span class="badge badge-owner">${t('members.owner')}</span>` - : html`<span class="badge">${t('members.member')}</span>` - } - </td> - <td class="admin-actions"> - ${isAdmin && m.user_id !== adminId && html` - <button class="admin-btn danger" disabled=${removing === m.user_id} - onClick=${() => { - if (!confirm(t('members.remove_confirm', { user: m.username }))) return; - removeMember(m); - }}> - ${removing === m.user_id ? '...' : t('members.remove')} - </button> - `} - </td> - </tr> - `)} - </tbody> - </table> - ${isAdmin && members.length > 1 && html` - <p class="settings-hint">${t('members.remove_hint')}</p> - `} - </div> - </div> - `; -} - -// ── Chat Panel ────────────────────────────────────────────────────────── - -/** - * Message text with its links made clickable. - * - * Only http and https, and built as elements rather than markup: a message is - * something another member wrote, so it must never become HTML. `javascript:` - * and `data:` are not matched at all, and the anchors carry noopener so the new - * tab cannot reach back into this one. - */ -const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; - -function linkify(text) { - const out = []; - let last = 0; - for (const m of String(text).matchAll(URL_RE)) { - if (m.index > last) out.push(text.slice(last, m.index)); - // Trailing punctuation is almost never part of the address. - let url = m[0]; - let tail = ''; - while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } - out.push(html`<a href=${url} target="_blank" rel="noopener noreferrer" - class="chat-link">${url}</a>`); - if (tail) out.push(tail); - last = m.index + m[0].length; - } - if (last < text.length) out.push(text.slice(last)); - return out; -} - -function formatTime(ts) { - const d = new Date(ts * 1000); - const now = new Date(); - // getLocale() rather than the browser default: the user may have picked a - // language here that differs from the one their OS reports. - const time = d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); - if (d.toDateString() === now.toDateString()) return time; - return d.toLocaleDateString(getLocale(), { month: 'short', day: 'numeric' }) + ' ' + time; -} - -function _parsePayload(raw) { - if (typeof raw === 'string' && raw.startsWith('{')) { - try { return JSON.parse(raw); } catch { /* not JSON */ } - } - return null; -} - -// How much history a group opens with, and how much each "older" click adds. -const CHAT_PAGE = 100; -const CHAT_OLDER_PAGE = 50; - -// Breathing room under the panel, and the floor below which shrinking it stops -// helping — past that the page may scroll after all, which beats a chat two -// lines tall. -const CHAT_BOTTOM_GAP = 16; -const CHAT_MIN_HEIGHT = 240; - -function _sameDay(a, b) { - const da = new Date(a * 1000), db = new Date(b * 1000); - return da.getFullYear() === db.getFullYear() - && da.getMonth() === db.getMonth() - && da.getDate() === db.getDate(); -} - -/** "Today" / "Yesterday" / a written date, in the reader's language. */ -function _dayLabel(ts) { - const d = new Date(ts * 1000); - const now = new Date(); - if (_sameDay(ts, now.getTime() / 1000)) return t('chat.today'); - const yesterday = new Date(now); - yesterday.setDate(now.getDate() - 1); - if (_sameDay(ts, yesterday.getTime() / 1000)) return t('chat.yesterday'); - return d.toLocaleDateString(getLocale(), { - weekday: 'long', day: 'numeric', month: 'long', - year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', - }); -} - -function ChatImage({ filename, entries, transportRef, gekRef }) { - const [blobUrl, setBlobUrl] = useState(null); - const [loading, setLoading] = useState(true); - const loadedRef = useRef(false); - - useEffect(() => { - if (loadedRef.current) return; - let cancelled = false; - const load = async () => { - const transport = transportRef.current; - if (!transport || !transport.connected) { setLoading(true); return; } - const entry = entries.find(e => e.name === filename); - if (!entry) { setLoading(true); return; } - try { - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks); - if (cancelled) return; - const ext = filename.split('.').pop().toLowerCase(); - const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' - : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg'; - const blob = new Blob(chunks, { type: mime }); - loadedRef.current = true; - setBlobUrl(URL.createObjectURL(blob)); - } catch { /* ignore */ } - if (!cancelled) setLoading(false); - }; - load(); - return () => { cancelled = true; }; - }, [filename, entries.length]); - - useEffect(() => { - return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); }; - }, [blobUrl]); - - if (loading) return html`<div class="chat-att-thumb"><span class="spinner"></span></div>`; - if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`; - return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; -} - -function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, mayUpload = true, onActivity }) { - const [messages, setMessages] = useState([]); - const [hasMore, setHasMore] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [atBottom, setAtBottom] = useState(true); - const [unreadFrom, setUnreadFrom] = useState(null); - const [input, setInput] = useState(''); - const [sending, setSending] = useState(false); - const [attaching, setAttaching] = useState(false); - const listRef = useRef(null); - const panelRef = useRef(null); - const inputRef = useRef(null); - const loadedRef = useRef(false); - // Set just before older messages are prepended; read once, after the DOM has - // them but before the browser paints. - const anchorRef = useRef(null); - const atBottomRef = useRef(true); - - useEffect(() => { - const transport = transportRef.current; - if (!transport || !transport.connected) return; - - if (!loadedRef.current) { - loadedRef.current = true; - // The newest page. This used to be fetchChatHistory(0, 200), which paged - // forwards from the very first message ever sent, so a busy group opened - // on its oldest screen and the recent conversation was unreachable. - transport.fetchChatHistory({ limit: CHAT_PAGE }) - .then(({ messages: msgs, hasMore: more }) => { - setMessages(msgs); - setHasMore(more); - }) - .catch(() => {}); - } - - transport.onChat = (msg) => { - // A live message has no row id until it is re-read from the node, so it - // gets a local one. Keys have to be stable and unique or prepending a - // page makes Preact reuse the wrong bubbles. Computed once and reused - // below: the unread marker points at a message by id, so generating a - // second one there would point it at nothing. - const id = msg.id - || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; - setMessages(prev => [...prev, { - id, - sender_id: msg.sender_id, - sender_name: msg.sender_name || '', - payload: msg.payload, - timestamp: msg.timestamp || Date.now() / 1000, - thread_id: msg.thread_id, - }]); - // Somebody wrote while you were reading further up: mark where you were - // rather than yanking the view down. - if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); - }; - - return () => { transport.onChat = null; }; - }, [transportRef.current?.connected]); - - const loadOlder = useCallback(async () => { - const transport = transportRef.current; - if (!transport || !transport.connected || loadingOlder || !messages.length) return; - setLoadingOlder(true); - const list = listRef.current; - // Keeping the reading position means restoring the distance from the - // *bottom*, not scrollTop: everything above the viewport just grew. - anchorRef.current = list ? list.scrollHeight - list.scrollTop : null; - try { - const { messages: older, hasMore: more } = - await transport.fetchChatHistory({ before: messages[0].id, limit: CHAT_OLDER_PAGE }); - setMessages(prev => [...older, ...prev]); - setHasMore(more); - } catch { - anchorRef.current = null; - } finally { - setLoadingOlder(false); - } - }, [messages, loadingOlder]); - - useLayoutEffect(() => { - const list = listRef.current; - if (!list) return; - if (anchorRef.current !== null) { - list.scrollTop = list.scrollHeight - anchorRef.current; - anchorRef.current = null; - return; - } - // Only follow the conversation if the reader was already at the bottom. - // Scrolling unconditionally fought every attempt to read back through it. - // - // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no - // height, so aligning it to the bottom of the viewport leaves the list's - // own padding below it and the bar stops just short of the end. - if (atBottomRef.current) list.scrollTop = list.scrollHeight; - }, [messages]); - - // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a - // phone the group header — title, description, edit link, delete button, tabs - // — is closer to 430px, so the panel ran past the fold and the composer ended - // up off screen with the whole page scrolling to reach it. - // - // Measured instead, from the panel's own position in the document, so the - // header can be any height. `visualViewport` rather than innerHeight where it - // exists: on Android the on-screen keyboard shrinks the visual viewport - // without changing innerHeight, and the composer would go back under it. - useLayoutEffect(() => { - const el = panelRef.current; - if (!el) return; - const fit = () => { - const vh = window.visualViewport?.height || window.innerHeight; - // Document-relative, so a page that happens to be scrolled does not skew - // the result — the answer must be the same either way. - const top = el.getBoundingClientRect().top + window.scrollY; - el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`; - // What sits *below* the panel is not knowable from up here — today it is - // `.main`'s 24px bottom padding against this 16px gap, which left the - // document 8px taller than the window and a scrollbar on the chat tab at - // every window size. Rather than encode 24 somewhere and have the next - // change to the page break it again, the leftover is measured and taken - // off. Self-correcting: anything added under the panel is absorbed the - // same way. - const over = document.documentElement.scrollHeight - vh; - if (over > 0) { - el.style.height = - `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`; - } - }; - fit(); - window.addEventListener('resize', fit); - window.addEventListener('orientationchange', fit); - window.visualViewport?.addEventListener('resize', fit); - return () => { - window.removeEventListener('resize', fit); - window.removeEventListener('orientationchange', fit); - window.visualViewport?.removeEventListener('resize', fit); - }; - }, []); - - const onScroll = useCallback((e) => { - const el = e.target; - const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; - atBottomRef.current = bottom; - setAtBottom(bottom); - if (bottom) setUnreadFrom(null); - }, []); - - const jumpToBottom = useCallback(() => { - atBottomRef.current = true; - setAtBottom(true); - setUnreadFrom(null); - const list = listRef.current; - if (list) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' }); - }, []); - - const sendMessage = useCallback(async () => { - const text = input.trim(); - if (!text) return; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - - setSending(true); - setInput(''); - try { - await transport.sendChat(text, 0, null, username); - setMessages(prev => [...prev, { - id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, - sender_name: username, - payload: text, - timestamp: Date.now() / 1000, - thread_id: null, - }]); - jumpToBottom(); - if (onActivity) onActivity(); - } catch { - setInput(text); - } finally { - setSending(false); - setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }); - } - }, [input, username, jumpToBottom]); - - const attachFile = useCallback(async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - e.target.value = ''; - const transport = transportRef.current; - if (!transport || !transport.connected) return; - setAttaching(true); - try { - // Two people sending IMG_1234.jpg both succeed; the node picks a free name - // and the message has to point at the one it chose. - const ack = await transport.uploadFile(file); - const storedAs = (ack && ack.stored_as) || file.name; - await new Promise(r => setTimeout(r, 2500)); - if (onRefreshIndex) await onRefreshIndex(); - const ext = file.name.split('.').pop().toLowerCase(); - const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image' - : ['mp4','webm','mkv','mov','avi'].includes(ext) ? 'video' : 'file'; - const structured = JSON.stringify({ - text: '', attachment: { filename: storedAs, size: file.size, type: ftype }, - }); - await transport.sendChat(structured, 0, null, username); - setMessages(prev => [...prev, { - id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, sender_name: username, - payload: structured, timestamp: Date.now() / 1000, thread_id: null, - }]); - jumpToBottom(); - } catch (err) { - alert(err.message); - } finally { - setAttaching(false); - } - }, [username, onRefreshIndex, jumpToBottom]); - - const onKeyDown = useCallback((e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - }, [sendMessage]); - - return html` - <div class="chat-panel" ref=${panelRef}> - <div class="chat-messages" ref=${listRef} onScroll=${onScroll}> - ${hasMore && html` - <div class="chat-older-row"> - <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}> - ${loadingOlder - ? html`<span class="spinner"></span>` - : html`<${Icon} name="chevron" cls="chat-older-icon" />`} - ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })} - </button> - </div> - `} - ${!hasMore && messages.length > 0 && html` - <div class="chat-start">${t('chat.start_of_history')}</div> - `} - ${messages.length === 0 && html` - <div class="chat-empty">${t('chat.empty')}</div> - `} - ${messages.map((m, i) => { - const isOwn = m.sender_name === username || m.sender_id === username; - const displayName = m.sender_name || '?'; - const prev = messages[i - 1]; - const showSender = !isOwn && (i === 0 || - (prev.sender_name || prev.sender_id) !== (m.sender_name || m.sender_id)); - // A conversation read over several days is unreadable without them. - const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) - ? _dayLabel(m.timestamp) : null; - const parsed = _parsePayload(m.payload); - const att = parsed && parsed.attachment; - return html` - ${daySep && html` - <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div> - `} - ${unreadFrom && unreadFrom === m.id && html` - <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div> - `} - <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''} - ${showSender || daySep ? '' : 'chat-msg-tight'}"> - ${showSender && html` - <div class="chat-sender">${displayName}</div> - `} - <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}"> - ${att ? html` - <div class="chat-attachment" style="cursor:pointer" onClick=${() => { - if (!onPreview) return; - const entry = entries.find(e => e.name === att.filename); - if (entry) onPreview(entry); - }}> - ${att.type === 'image' - ? html`<${ChatImage} filename=${att.filename} entries=${entries} - transportRef=${transportRef} gekRef=${gekRef} />` - : att.type === 'video' - ? html`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>` - : html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>` - } - <div class="chat-att-size">${formatSize(att.size)}</div> - </div> - ` : html` - <span class="chat-text"> - ${linkify(parsed && typeof parsed.text === 'string' - ? parsed.text : m.payload)} - </span> - `} - <span class="chat-time">${formatTime(m.timestamp)}</span> - </div> - </div> - `; - })} - </div> - ${!atBottom && messages.length > 0 && html` - <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}> - <${Icon} name="chevron" cls="chat-jump-icon" /> - ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')} - </button> - `} - <div class="chat-input-row"> - ${mayUpload && html` - <label class="chat-attach" title="${t('chat.attach')}"> - ${attaching ? html`<span class="spinner"></span>` - : html`<${Icon} name="clip" />`} - <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} /> - </label> - `} - <textarea class="chat-input" rows="1" ref=${inputRef} - placeholder="${t('chat.placeholder')}" - value=${input} - onInput=${e => setInput(e.target.value)} - onKeyDown=${onKeyDown} - disabled=${sending} /> - <button class="chat-send" onClick=${sendMessage} - disabled=${sending || !input.trim()}> - ${t('chat.send')} - </button> - </div> - </div> - `; -} - -// ── Video Player (MSE streaming) ──────────────────────────────────────── - -function _mseSupported(codec) { - if (!window.MediaSource) return false; - const mime = `video/mp4; codecs="${codec}"`; - return MediaSource.isTypeSupported(mime); -} - -/** Seconds as h:mm:ss, or m:ss under an hour. */ -function formatClock(seconds) { - const s = Math.max(0, Math.floor(seconds || 0)); - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = String(s % 60).padStart(2, '0'); - return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`; -} - -/** - * Where *this account on this device* last left off in a given file. - * - * localStorage rather than the node: it needs no protocol, no storage anyone - * else has to keep, and nothing new learns what you watch. The cost is that - * the position does not follow you from the laptop to the phone. - * - * The account has to be in the key. Without it the position is per *device* — - * so a second person signing in on the same machine was offered "resume where - * you left off" in a film they had never opened, which is both wrong and a - * small disclosure of what someone else watches. Found by signing in with a - * fresh account and being offered a resume point. - */ -function resumeKey(fileId) { - const auth = loadAuth(); - return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null; -} - -function readResumePosition(fileId) { - try { - const key = resumeKey(fileId); - if (!key) return 0; - const raw = localStorage.getItem(key); - const at = raw ? parseFloat(raw) : 0; - return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0; - } catch { - return 0; // private browsing, or storage disabled - } -} - -function writeResumePosition(fileId, at, duration) { - try { - const key = resumeKey(fileId); - if (!key) return; - if (!Number.isFinite(at) || at < RESUME_MIN_S - || (duration && at > duration * RESUME_MAX_FRACTION)) { - localStorage.removeItem(key); - return; - } - localStorage.setItem(key, String(Math.floor(at))); - } catch { /* nothing to be done, and nothing worth failing over */ } -} - -/** - * Drop the positions written before they were scoped to an account. - * - * Re-keying them is not possible — there is no record of whose they were, and - * guessing would hand them to whoever signs in next, which is the bug. They go. - */ -function purgeUnscopedResumePositions() { - try { - const stale = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current. - if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) { - stale.push(key); - } - } - stale.forEach((key) => localStorage.removeItem(key)); - } catch { /* storage disabled: nothing was written either */ } -} - -purgeUnscopedResumePositions(); - -function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { - const [dlBusy, setDlBusy] = useState(false); - const [phase, setPhase] = useState('loading'); - const [error, setError] = useState(''); - const videoRef = useRef(null); - const msRef = useRef(null); - const sbRef = useRef(null); - const blobUrlRef = useRef(null); - const queueRef = useRef([]); - const appendingRef = useRef(false); - const endedRef = useRef(false); - const durationRef = useRef(0); - // Segments the node is allowed to have in flight but has not sent yet, and - // when we last said anything to it at all. - const outstandingRef = useRef(0); - const lastPokeRef = useRef(0); - // Diagnostics reported to the node: how many appends the browser refused for - // want of room, and whether the element itself says it is starved. - const quotaRef = useRef(0); - const stalledRef = useRef(false); - // Seeking. `awaitingInit` is true from the moment we ask the node to restart - // somewhere else until its new `stream_init` arrives: the channel is ordered, - // so everything in between belongs to the stream we just abandoned and would - // otherwise be appended on top of the new one. `seekTarget` is where to put - // the playhead once the buffer actually covers it. - const awaitingInitRef = useRef(false); - const seekTargetRef = useRef(null); - const seekTimerRef = useRef(null); - // The seek is built inside the effect, where the transport and `cancelled` - // live; the render needs to reach it for "start from the beginning". - const requestSeekRef = useRef(null); - const [resumedFrom, setResumedFrom] = useState(0); - const [castActive, setCastActive] = useState(false); - const [castUrl, setCastUrl] = useState(null); - const [castPickerOpen, setCastPickerOpen] = useState(false); - const [castDevices, setCastDevices] = useState([]); - const [castScanning, setCastScanning] = useState(false); - const [castDeviceName, setCastDeviceName] = useState(null); - const castActiveRef = useRef(false); - const castCodecRef = useRef(null); - const initSegmentRef = useRef(null); - const castRestartPendingRef = useRef(false); - const castDeviceRef = useRef(null); - const castRestartGenRef = useRef(0); - const landingPlayheadRef = useRef(false); - - /** - * The buffered range the playhead is actually in, or null. - * - * Seeking makes the buffer discontinuous, and "the last range" stops meaning - * "the one being watched" the moment there is more than one: measuring the - * read-ahead against a range on the far side of a gap reports a full buffer - * while the player starves. - */ - const currentRange = useCallback(() => { - const sb = sbRef.current; - const v = videoRef.current; - if (!sb || !v) return null; - try { - const t = v.currentTime; - for (let i = 0; i < sb.buffered.length; i++) { - // Half a second of slack: the playhead sits exactly on a boundary - // often enough, and a strict test there reports nothing buffered. - if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) { - return [sb.buffered.start(i), sb.buffered.end(i)]; - } - } - } catch { /* the SourceBuffer went away under us */ } - return null; - }, []); - - /** - * Drop what has already been watched. - * - * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes - * and refuse the append that goes past. Keeping a minute behind the playhead - * is enough for a small seek backwards and bounded for a three-hour film. - */ - const evictBehind = useCallback(() => { - const sb = sbRef.current; - const v = videoRef.current; - if (!sb || !v || sb.updating || !sb.buffered.length) return false; - const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S); - // The range being watched, not the first one: after a seek backwards the - // first range is somewhere else entirely, and removing from its start to - // just behind the playhead would take out everything in between — - // including what is playing. - const range = currentRange(); - const start = range ? range[0] : sb.buffered.start(0); - if (keepFrom - start < 10) return false; - try { - sb.remove(start, keepFrom); - return true; - } catch { - return false; - } - }, [currentRange]); - - /** Seconds of film held past the playhead. */ - const bufferedAhead = useCallback(() => { - const v = videoRef.current; - const range = currentRange(); - if (!v || !range) return 0; - return Math.max(0, range[1] - v.currentTime); - }, [currentRange]); - - const flushQueue = useCallback(() => { - const sb = sbRef.current; - if (!sb || appendingRef.current || sb.updating) return; - if (queueRef.current.length === 0) { - if (endedRef.current && msRef.current?.readyState === 'open') { - try { msRef.current.endOfStream(); } catch {} - } - return; - } - appendingRef.current = true; - const chunk = queueRef.current[0]; - try { - sb.appendBuffer(chunk); - queueRef.current.shift(); - } catch (e) { - appendingRef.current = false; - if (e.name === 'QuotaExceededError') { - quotaRef.current += 1; - // The segment stays at the head of the queue and is tried again once - // there is room. Dropping it — which is what this used to do — leaves a - // hole in the middle of the film and no error anywhere. - if (!evictBehind()) { - console.warn('[MSE] buffer full and nothing to evict yet'); - } - return; - } - queueRef.current.shift(); - console.error('[MSE] appendBuffer error:', e); - } - }, [evictBehind]); - - /** - * Decide whether the node may send more, and keep the pipeline moving. - * - * This is the only place credit is granted, and the only thing that can - * restart a pipeline the buffer ceiling has stopped. That second job is why - * it exists: an append refused for quota fires no `updateend`, so it grants - * no credit, so the node sends nothing, so no segment arrives to call - * `flushQueue` again. Every wakeup the append path had was downstream of the - * append that just failed — the player deadlocked against itself and sat on - * "buffering" for good, which is what a 500 MB film did at around 100 MB. - * - * So the clock drives this, not the data. - */ - const pump = useCallback(() => { - if (awaitingInitRef.current) return; - const transport = transportRef.current; - evictBehind(); - flushQueue(); - if (endedRef.current && queueRef.current.length === 0) return; - if (!transport || !transport.connected) return; - - if (bufferedAhead() > BUFFER_AHEAD_S - || queueRef.current.length > QUEUE_HIGH_WATER) { - // Far enough ahead. Grant nothing, but do not go silent: two minutes of - // silence is how the node decides nobody is watching, and pausing a film - // for two minutes is an ordinary thing to do. - const now = Date.now(); - if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) { - lastPokeRef.current = now; - transport.grantStreamCredit(0); - } - return; - } - - // Top the window back up to what is allowed in flight, rather than paying - // off everything owed at once. Called on every arriving segment as well as - // on the clock, so credit trickles out as room appears instead of being - // released in one gulp when the buffer finally drains. - const room = STREAM_WINDOW - outstandingRef.current; - if (room > 0) { - outstandingRef.current += room; - lastPokeRef.current = Date.now(); - transport.grantStreamCredit(room); - } - }, [evictBehind, flushQueue, bufferedAhead]); - - useEffect(() => { - let cancelled = false; - // Reset here, not in the teardown of the run before: switching video while - // an append was in flight left `appendingRef` true, and flushQueue bails - // out on it. The new SourceBuffer then never appended anything, so no - // `updateend` ever cleared the flag, no credit went back to the node, and - // the player sat on "buffering" for good. `endedRef` surviving is the same - // shape of bug — the next stream would call endOfStream() the first time - // its queue ran dry and truncate the film. - appendingRef.current = false; - endedRef.current = false; - queueRef.current = []; - outstandingRef.current = 0; - lastPokeRef.current = Date.now(); - quotaRef.current = 0; - stalledRef.current = false; - // The same shape again, and the seek refs are worse than the others. - // Switching film while a seek was in flight leaves `awaitingInit` true, - // and only reinitAt() ever lowers it — which the next film does not go - // through, because it builds a new SourceBuffer. Every segment of the new - // film is then dropped as though it belonged to the one we left, for good. - // A stale `seekTarget` is milder: the new film jumps to a position from - // the old one the moment that much is buffered. - awaitingInitRef.current = false; - seekTargetRef.current = null; - clearTimeout(seekTimerRef.current); - const transport = transportRef.current; - if (!transport || !transport.connected) { - setError(t('video.err_transport')); - setPhase('error'); - return; - } - - const onStarved = () => { stalledRef.current = true; pump(); }; - const onFed = () => { stalledRef.current = false; }; - - /** The buffered ranges, short enough for a log line. */ - const describeRanges = () => { - const sb = sbRef.current; - if (!sb) return '(no buffer)'; - try { - let s = ''; - for (let i = 0; i < sb.buffered.length; i++) { - s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `; - } - return s.trim() || '(empty)'; - } catch { - return '?'; - } - }; - - /** - * Ask the node to restart the film somewhere else. - * - * Debounced, because dragging the scrubber fires `seeking` continuously and - * each request kills an ffmpeg and spawns another. Only the position the - * finger stops on is worth acting on. - */ - const requestSeek = (target) => { - clearTimeout(seekTimerRef.current); - seekTimerRef.current = setTimeout(() => { - const t = transportRef.current; - if (cancelled || !t || !t.connected) return; - // Everything arriving from here until the new `stream_init` belongs to - // the stream being abandoned. The channel is ordered, so this flag is - // enough to tell them apart without a sequence number in the protocol. - // Rare enough to report every time, and the node logs it at INFO. A - // seek nobody asked for is the kind of thing only this line can show: - // from the node's side it is indistinguishable from a viewer dragging - // the scrubber. - t.sendStreamDiag({ - event: 'seek', target: +target.toFixed(1), - t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, - ready: videoRef.current ? videoRef.current.readyState : null, - offset: sbRef.current ? sbRef.current.timestampOffset : null, - ranges: describeRanges(), - }); - awaitingInitRef.current = true; - seekTargetRef.current = target; - outstandingRef.current = STREAM_WINDOW; - setPhase('loading'); - console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW); - t.requestStream(entry.id, STREAM_WINDOW, target); - }, SEEK_DEBOUNCE_MS); - }; - requestSeekRef.current = requestSeek; - - /** - * Move the playhead onto a seek once the data for it has arrived. - * - * Setting `currentTime` into a region that is not buffered yet leaves the - * element waiting with nothing to show, and on a seek backwards it would - * be overwritten by the playhead the browser restores. So the position is - * remembered and applied on the first append that actually covers it. - */ - const landPlayhead = () => { - const target = seekTargetRef.current; - const v = videoRef.current, sb = sbRef.current; - if (target === null || !v || !sb) return; - try { - for (let i = 0; i < sb.buffered.length; i++) { - const a = sb.buffered.start(i), b = sb.buffered.end(i); - if (target >= a - 1 && target < b) { - seekTargetRef.current = null; - // ffmpeg lands on the keyframe at or before what we asked for, so - // the range can begin slightly later than the target; never seek - // behind what is actually there. - if (Math.abs(v.currentTime - target) > 0.5) { - landingPlayheadRef.current = true; - v.currentTime = Math.max(target, a); - } - v.play().catch(() => {}); - return; - } - } - } catch { /* the SourceBuffer went away */ } - }; - - /** Wait for whatever the SourceBuffer is doing to finish. */ - const settled = (sb) => new Promise((resolve) => { - if (!sb.updating) return resolve(); - sb.addEventListener('updateend', resolve, { once: true }); - }); - - /** - * Put the SourceBuffer back to an empty state that starts at `start`. - * - * Everything buffered is dropped rather than kept alongside the new - * material. A discontinuous buffer is legal and every piece of code that - * reads `buffered` then has to reason about which range it means — the - * eviction, the read-ahead, the seek test — for the sake of a few - * megabytes of film the viewer has just navigated away from. - * - * `abort()` first: ffmpeg was killed mid-fragment, so the parser is - * holding half of one, and appending the next stream's header on top of - * that is a decode error. - */ - const reinitAt = async (start) => { - const sb = sbRef.current; - if (!sb) return; - try { sb.abort(); } catch { /* not in a state that needs it */ } - await settled(sb); - try { - sb.remove(0, Infinity); - await settled(sb); - } catch { /* nothing buffered */ } - // ffmpeg restarts its timestamps at zero however far in we asked it to - // seek, so this is what puts the fragments back on the film's timeline. - try { sb.timestampOffset = start; } catch { /* older browsers */ } - const tr = transportRef.current; - if (tr) { - tr.sendStreamDiag({ - event: 'reinit', target: start, offset: sb.timestampOffset, - t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, - ranges: describeRanges(), - }); - } - queueRef.current = []; - appendingRef.current = false; - endedRef.current = false; - quotaRef.current = 0; - awaitingInitRef.current = false; - seekTargetRef.current = start; - console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length); - setPhase('streaming'); - pump(); - }; - - const onSeeking = () => { - if (landingPlayheadRef.current) { - landingPlayheadRef.current = false; - return; - } - const v = videoRef.current; - if (!v || cancelled) return; - const target = v.currentTime; - // Inside what is buffered, the browser handles it and the node need not - // hear about it at all — unless a cast is active, because the relay - // cannot seek within its HTTP stream and must be restarted. - if (!castActiveRef.current) { - const sb = sbRef.current; - if (sb) { - try { - for (let i = 0; i < sb.buffered.length; i++) { - if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) { - return; - } - } - } catch { /* fall through and ask the node */ } - } - } - requestSeek(target); - }; - - const startStream = async () => { - transport.onStreamError = (msg) => { - if (cancelled) return; - // Say what the node said. Sitting on "buffering" with the reason - // already delivered is the worst of both. - setError(msg.detail || t('video.err_transport')); - setPhase('error'); - }; - - transport.onStreamInit = (msg) => { - if (cancelled) return; - if (msg.file_id && msg.file_id !== entry.id) return; - const mime = `video/mp4; codecs="${msg.codec}"`; - castCodecRef.current = msg.codec; - - if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { - setError(t('video.err_mse', { codec: msg.codec })); - setPhase('error'); - return; - } - - durationRef.current = msg.duration || 0; - - // A second init on a live SourceBuffer is a seek landing, not a new - // film. Reuse what is there: rebuilding the MediaSource would reset the - // element's src, blank the picture and throw away the duration the - // scrubber is drawn from. - if (sbRef.current && msRef.current - && msRef.current.readyState === 'open') { - console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current); - initSegmentRef.current = null; - if (castActiveRef.current && platform.cast.available) { - platform.cast.stop().catch(() => {}); - castRestartPendingRef.current = true; - } - reinitAt(msg.start || 0).catch(() => { - setError(t('video.err_transport')); - setPhase('error'); - }); - return; - } - - // If we reach here during a seek (readyState was 'ended' after the - // previous stream finished), the seek-landing path above could not run. - // A fresh MediaSource is needed, but the seek state must still be reset - // or awaitingInit stays true and every segment is dropped forever. - awaitingInitRef.current = false; - endedRef.current = false; - appendingRef.current = false; - queueRef.current = []; - sbRef.current = null; - initSegmentRef.current = null; - if (castActiveRef.current && platform.cast.available) { - platform.cast.stop().catch(() => {}); - castRestartPendingRef.current = true; - } - - const ms = new MediaSource(); - msRef.current = ms; - if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); - const url = URL.createObjectURL(ms); - blobUrlRef.current = url; - - ms.addEventListener('sourceopen', () => { - if (cancelled) return; - if (durationRef.current > 0) { - ms.duration = durationRef.current; - } - const sb = ms.addSourceBuffer(mime); - sbRef.current = sb; - // 'segments', not 'sequence': the fragments must land where they - // belong on the film's timeline rather than one after another, or a - // stream that started at 40 minutes would be buffered at zero and - // the scrubber would lie about everything. - sb.mode = 'segments'; - try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ } - if (msg.start) seekTargetRef.current = msg.start; - transport.sendStreamDiag({ - event: 'first-init', target: msg.start || 0, - offset: sb.timestampOffset, duration: durationRef.current, - t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, - }); - sb.addEventListener('updateend', () => { - // No credit is granted here, deliberately. Appending is not the - // same question as having room, and tying the two meant `remove()` - // — which fires this event too — paid the node for the player's own - // evictions. What may be in flight is decided from the buffer, in - // pump(), and nowhere else. - appendingRef.current = false; - landPlayhead(); - pump(); - }); - setPhase('streaming'); - flushQueue(); - }); - - if (videoRef.current) { - videoRef.current.src = url; - videoRef.current.addEventListener('seeking', onSeeking); - videoRef.current.addEventListener('timeupdate', pump); - // The element's own verdict. "buffering" on screen is this, and it - // is the one thing the node cannot infer from a stream it is feeding. - videoRef.current.addEventListener('waiting', onStarved); - videoRef.current.addEventListener('stalled', onStarved); - videoRef.current.addEventListener('playing', onFed); - videoRef.current.addEventListener('canplay', onFed); - } - }; - - transport.onStreamData = async (msg) => { - if (cancelled) return; - // A segment arrived, so it is no longer in flight — whatever we go on - // to do with it. This has to come before every early return below, and - // it did not: skipping the count for segments we discard leaks a slot - // out of the window each time, and the window never grows back. - // - // `reinitAt` is asynchronous — it waits for two `updateend` events — - // and a seek's first segments arrive during that gap and are dropped - // by the flag below. Lose all eight and the player believes a full - // window is in flight, grants nothing ever again, and the node waits - // for credit that cannot come. A race, which is why the same seek - // worked twice and hung on the third. - outstandingRef.current = Math.max(0, outstandingRef.current - 1); - if (awaitingInitRef.current) { - console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current); - } - // Between asking for a seek and its `stream_init`, everything on the - // channel is the film we just left. Same file, so `file_id` cannot - // tell them apart — ordering can. - if (awaitingInitRef.current) return; - // Late segments from the stream we just left. The DataChannel is - // ordered, so they arrive before the new stream's first segment and - // would otherwise be decrypted against the wrong file — which fails, - // loudly, in the console, for something that is simply not ours. - if (msg.file_id && msg.file_id !== entry.id) return; - try { - const plaintext = await window.MeshBayCrypto.decryptChunkBin( - gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); - if (initSegmentRef.current === null) { - initSegmentRef.current = plaintext; - if (castRestartPendingRef.current && castActiveRef.current - && platform.cast.available) { - castRestartPendingRef.current = false; - const gen = ++castRestartGenRef.current; - const device = castDeviceRef.current; - platform.cast.start({ - codec: castCodecRef.current, - initSegment: plaintext, - }).then(async (result) => { - if (castRestartGenRef.current !== gen) return; - if (!result) return; - setCastUrl(result.url); - const status = await platform.cast.status(); - if (status && status.chromecast && status.chromecast.connected) { - await platform.cast.chromecastReload({ mediaUrl: result.url }); - } else if (device) { - await platform.cast.chromecastConnect({ - deviceId: device.id, mediaUrl: result.url, - }); - setCastDeviceName(device.name); - } else { - navigator.clipboard.writeText(result.url).catch(() => {}); - } - }).catch((err) => { - if (castRestartGenRef.current !== gen) return; - console.error('[cast] restart failed:', err); - platform.cast.stop().catch(() => {}); - setCastActive(false); castActiveRef.current = false; - setCastUrl(null); setCastDeviceName(null); - }); - } - } - if (castActiveRef.current && !castRestartPendingRef.current - && platform.cast.available) { - platform.cast.push(plaintext).catch(() => {}); - } - queueRef.current.push(plaintext); - // pump(), not flushQueue(): arriving data is the moment to top the - // window back up, and that is what keeps the stream continuous. - pump(); - } catch (e) { - console.error('[MSE] decrypt error:', e); - } - }; - - transport.onStreamEnd = (msg) => { - if (cancelled) return; - // The end of the previous film is not the end of this one. - if (msg && msg.file_id && msg.file_id !== entry.id) return; - // Nor is the end of the stream we abandoned by seeking: taking it - // would call endOfStream() and truncate the film at the seek point. - if (awaitingInitRef.current) return; - endedRef.current = true; - if (castActiveRef.current && platform.cast.available) { - platform.cast.finish().catch(() => {}); - } - flushQueue(); - }; - - // The opening window, and the count that tracks it. Asking for more here - // than pump() maintains would leave the node holding credit this side - // does not know about, which is the whole window's worth of overshoot on - // the very first breath of the stream. - outstandingRef.current = STREAM_WINDOW; - const resumeAt = readResumePosition(entry.id); - if (resumeAt) setResumedFrom(resumeAt); - transport.requestStream(entry.id, STREAM_WINDOW, resumeAt); - }; - - // Closing the tab, or backgrounding it on a phone, never runs a React - // cleanup — so the node hears nothing and keeps transcoding. `pagehide` - // fires in both cases and is the one event mobile browsers honour on the - // way out; `visibilitychange` covers switching apps. The node stops the - // stream by itself when the connection drops, but that costs a round of - // detection, and this message is a single datagram already in flight. - const leave = (why) => { - const t = transportRef.current; - console.log('[MeshBay] stopStream:', why); - if (t && t.connected) t.stopStream(); - }; - const onPageHide = () => leave('pagehide'); - // NOT wired to stopStream. Android fires visibilitychange when a video goes - // fullscreen, so cutting the stream here killed the film the moment it was - // watched properly. Logged only, until that is confirmed or ruled out. - const onVisibility = () => { - console.log('[MeshBay] visibilitychange:', document.visibilityState); - }; - window.addEventListener('pagehide', onPageHide); - document.addEventListener('visibilitychange', onVisibility); - - // `timeupdate` is silent while the film is paused, and the append path - // cannot wake itself once the ceiling has refused a segment. This is the - // clock that guarantees something is still driving the pipeline. - const pumpTimer = setInterval(pump, 1000); - - // What the player sees, into the node's log. A hang on a phone shows the - // node feeding a stream quite happily; the half that says otherwise is in - // here, and there is no console to read it from. - const diagTimer = setInterval(() => { - const v = videoRef.current, sb = sbRef.current; - const t = transportRef.current; - if (!t || !v) return; - // Cheap, and the only thing that makes "resume where I stopped" work - // when the tab is closed rather than the player. - if (!v.paused) { - writeResumePosition(entry.id, v.currentTime, durationRef.current); - } - let ranges = ''; - try { - for (let i = 0; sb && i < sb.buffered.length; i++) { - ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `; - } - } catch { ranges = '?'; } - t.sendStreamDiag({ - t: +v.currentTime.toFixed(1), - ahead: +bufferedAhead().toFixed(1), - ranges: ranges.trim(), - ready: v.readyState, // 0 = nothing, 4 = enough to play through - paused: v.paused, - stalled: stalledRef.current, - q: queueRef.current.length, - inflight: outstandingRef.current, - appending: appendingRef.current, - updating: sb ? sb.updating : null, - quota: quotaRef.current, - ms: msRef.current ? msRef.current.readyState : null, - err: v.error ? `${v.error.code}:${v.error.message}` : null, - }); - }, 5000); - - startStream().catch(err => { - if (!cancelled) { setError(err.message); setPhase('error'); } - }); - - return () => { - cancelled = true; - clearInterval(pumpTimer); - clearInterval(diagTimer); - clearTimeout(seekTimerRef.current); - // Closing the player is the commonest way to stop watching, so this is - // the write that matters most. - if (videoRef.current) { - writeResumePosition(entry.id, videoRef.current.currentTime, - durationRef.current); - } - if (castActiveRef.current && platform.cast.available) { - platform.cast.chromecastDisconnect().catch(() => {}); - platform.cast.stop().catch(() => {}); - castActiveRef.current = false; - } - window.removeEventListener('pagehide', onPageHide); - document.removeEventListener('visibilitychange', onVisibility); - if (videoRef.current) { - videoRef.current.removeEventListener('seeking', onSeeking); - videoRef.current.removeEventListener('timeupdate', pump); - videoRef.current.removeEventListener('waiting', onStarved); - videoRef.current.removeEventListener('stalled', onStarved); - videoRef.current.removeEventListener('playing', onFed); - videoRef.current.removeEventListener('canplay', onFed); - } - if (transport) { - // Tell the node first: dropping the handlers only makes us deaf, and a - // stream nobody is listening to still occupies a transcode slot. - transport.stopStream(); - transport.onStreamInit = null; - transport.onStreamData = null; - transport.onStreamEnd = null; - transport.onStreamError = null; - } - // The queue can hold several megabytes of decrypted video. - queueRef.current = []; - const ms = msRef.current; - if (ms && ms.readyState === 'open') { - try { ms.endOfStream(); } catch { /* already ended */ } - } - if (blobUrlRef.current) { - URL.revokeObjectURL(blobUrlRef.current); - blobUrlRef.current = null; - } - sbRef.current = null; - msRef.current = null; - }; - }, [entry, flushQueue, pump]); - - useEffect(() => { - if (phase === 'streaming' && videoRef.current) { - videoRef.current.play().catch(() => {}); - } - }, [phase]); - - useEffect(() => { - return () => { - if (blobUrlRef.current) { - URL.revokeObjectURL(blobUrlRef.current); - blobUrlRef.current = null; - } - }; - }, []); - - useEffect(() => { - const onKey = (e) => { if (e.key === 'Escape') onClose(); }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [onClose]); - - return html` - <div class="video-overlay" onClick=${(e) => { - if (e.target.classList.contains('video-overlay')) onClose(); - }}> - <div class="video-top-bar"> - <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> - ${platform.capabilities.lanCast && html` - <div class="cast-wrapper" style="position:relative"> - <button class="video-close ${castActive ? 'cast-active' : ''}" - onClick=${async () => { - if (castActive) { - await platform.cast.chromecastDisconnect().catch(() => {}); - await platform.cast.stop(); - setCastActive(false); castActiveRef.current = false; - setCastUrl(null); - setCastDeviceName(null); - castDeviceRef.current = null; - } else if (castCodecRef.current && initSegmentRef.current) { - if (castPickerOpen) { - setCastPickerOpen(false); - } else { - setCastPickerOpen(true); - setCastScanning(true); - setCastDevices([]); - platform.cast.discover().then((devices) => { - setCastDevices(devices || []); - setCastScanning(false); - }).catch(() => setCastScanning(false)); - } - } - }} - title="${castActive ? t('cast.stop') : t('cast.start')}"> - <${Icon} name="cast" /></button> - ${castPickerOpen && html` - <div class="cast-picker"> - ${castScanning && html` - <div class="cast-picker-item cast-picker-scanning"> - <span class="spinner" style="width:14px;height:14px"></span> - ${t('cast.scanning')} - </div> - `} - ${castDevices.map(d => html` - <button class="cast-picker-item" onClick=${() => { - setCastPickerOpen(false); - setCastDeviceName(d.name); - setCastActive(true); castActiveRef.current = true; - castRestartPendingRef.current = true; - castDeviceRef.current = d; - if (videoRef.current && requestSeekRef.current) { - requestSeekRef.current(videoRef.current.currentTime); - } - }}> - <${Icon} name="cast" /> ${d.name} - </button> - `)} - ${!castScanning && castDevices.length === 0 && html` - <div class="cast-picker-item cast-picker-empty"> - ${t('cast.no_devices')} - </div> - `} - <div class="cast-picker-sep"></div> - <button class="cast-picker-item" onClick=${async () => { - setCastPickerOpen(false); - setCastActive(true); castActiveRef.current = true; - castRestartPendingRef.current = true; - if (videoRef.current && requestSeekRef.current) { - requestSeekRef.current(videoRef.current.currentTime); - } - }}> - <${Icon} name="clip" /> ${t('cast.copy_url')} - </button> - </div> - `} - </div> - `} - ${castUrl && html` - <span class="cast-status-label"> - ${castDeviceName - ? castDeviceName - : html`<input class="cast-url-input" readOnly value=${castUrl} - onClick=${(e) => { - e.target.select(); - navigator.clipboard.writeText(castUrl).catch(() => {}); - }} - title="${t('cast.copy_url')}" />` - } - </span> - `} - ${onDownload && html` - <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy} - onClick=${() => { - if (!dlBusy) { - setDlBusy(true); - onDownload(); - setTimeout(() => setDlBusy(false), 1500); - } - }} - title="${t('group.download')}"> - ${dlBusy - ? html`<span class="spinner"></span>` - : html`<${Icon} name="download" />`}</button> - `} - <button class="video-close" onClick=${onClose} title="${t('video.close')}"> - <${Icon} name="close" /></button> - </div> - - ${phase === 'loading' && html` - <div class="video-loading"> - <div class="video-loading-label"> - <span class="spinner"></span>${' '}${t('video.buffering')} - </div> - </div> - `} - - ${(phase === 'streaming' || phase === 'loading') && html` - <div class="video-container"> - <video ref=${videoRef} controls autoplay /> - ${resumedFrom > 0 && html` - <div class="video-resumed"> - ${t('video.resumed_at', { time: formatClock(resumedFrom) })} - <button class="linklike" onClick=${() => { - setResumedFrom(0); - writeResumePosition(entry.id, 0, durationRef.current); - if (requestSeekRef.current) requestSeekRef.current(0); - }}>${t('video.from_start')}</button> - </div> - `} - </div> - `} - - ${phase === 'error' && html` - <div class="video-error">${error}</div> - `} - </div> - `; -} // ── Search Page (cross-group file search) ─────────────────────────────────── @@ -5740,7 +2233,7 @@ function NodePage({ token, username, userId, groups }) { setStatus('connecting'); setError(''); try { - if (!_bundleKey) _bundleKey = await _loadBundleKey(); + if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); const live = (await ensureFreshToken()) || token; const nodeCandidates = (await Promise.all( @@ -5777,7 +2270,7 @@ function NodePage({ token, username, userId, groups }) { console.log('[NodePage] connecting via group', groupId.slice(0, 8), 'node', nodeId.slice(0, 8)); await transport.connect(nodeId, live, groupId, - null, null, _bundleKey, username, userId); + null, null, session.bundleKey, username, userId); console.log('[NodePage] connected, fetching status'); const result = await transport.fetchNodeStatus(); console.log('[NodePage] got status:', result.groups?.length, 'groups'); @@ -6273,7 +2766,7 @@ function App() { // A renewal can happen inside hubFetch, well away from any render. This is // how the component learns about it — including a failed one, which sets // null and lands on the login page instead of failing every later call. - _onAuthChange = (auth) => setUser(auth); + setAuthChangeListener((auth) => setUser(auth)); // On mount above all: a tab reopened tomorrow holds an hour-old access // token and a refresh token good for a month, and used to greet its owner @@ -6289,7 +2782,7 @@ function App() { }; document.addEventListener('visibilitychange', onVisible); return () => { - _onAuthChange = null; + setAuthChangeListener(null); clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); }; @@ -6469,8 +2962,8 @@ function App() { refreshToken = data.refreshToken; // The only thing sign-in produces: the key that opens a node's bundle. // Which identity we use is decided per node, when we get there. - _bundleKey = data.bundleKey; - await _storeBundleKey(_bundleKey); + session.bundleKey = data.bundleKey; + await _storeBundleKey(session.bundleKey); } else { const data = await hubFetch('/v1/users/login', { method: 'POST', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js new file mode 100644 index 0000000..0db713c --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js @@ -0,0 +1,28 @@ +import { ChatPanel } from './chat-app.js'; +import { FilesPanel } from './files-app.js'; + +/** + * Every group "application", in tab order. + * + * Adding one (Videos, Music, Photos — none of them need an MNP change, see + * the node's indexer classifying video/audio/image already) means a new file + * exporting a component and one entry here. Nothing in group-page.js changes: + * every registered component receives the same shared context (see its + * `commonProps`) and renders itself into the active tab. + * + * `key` doubles as the identifier the node's `apps_enabled` setting uses, so + * it must match `ALLOWED_APPS` in the node's webrtc_server.py. + */ +const APPS = [ + { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel }, + { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel }, +]; + +/** The registry filtered to what this group has enabled, in registry order. */ +function visibleApps(enabledKeys) { + const enabled = new Set( + enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key)); + return APPS.filter(a => enabled.has(a.key)); +} + +export { APPS, visibleApps }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js new file mode 100644 index 0000000..0babf51 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -0,0 +1,449 @@ +import { + html, useState, useEffect, useLayoutEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t, getLocale } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; + +/** + * Message text with its links made clickable. + * + * Only http and https, and built as elements rather than markup: a message is + * something another member wrote, so it must never become HTML. `javascript:` + * and `data:` are not matched at all, and the anchors carry noopener so the new + * tab cannot reach back into this one. + */ +const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; + +function linkify(text) { + const out = []; + let last = 0; + for (const m of String(text).matchAll(URL_RE)) { + if (m.index > last) out.push(text.slice(last, m.index)); + // Trailing punctuation is almost never part of the address. + let url = m[0]; + let tail = ''; + while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } + out.push(html`<a href=${url} target="_blank" rel="noopener noreferrer" + class="chat-link">${url}</a>`); + if (tail) out.push(tail); + last = m.index + m[0].length; + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + +function formatTime(ts) { + const d = new Date(ts * 1000); + const now = new Date(); + // getLocale() rather than the browser default: the user may have picked a + // language here that differs from the one their OS reports. + const time = d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' }); + if (d.toDateString() === now.toDateString()) return time; + return d.toLocaleDateString(getLocale(), { month: 'short', day: 'numeric' }) + ' ' + time; +} + +function _parsePayload(raw) { + if (typeof raw === 'string' && raw.startsWith('{')) { + try { return JSON.parse(raw); } catch { /* not JSON */ } + } + return null; +} + +// How much history a group opens with, and how much each "older" click adds. +const CHAT_PAGE = 100; +const CHAT_OLDER_PAGE = 50; + +// Breathing room under the panel, and the floor below which shrinking it stops +// helping — past that the page may scroll after all, which beats a chat two +// lines tall. +const CHAT_BOTTOM_GAP = 16; +const CHAT_MIN_HEIGHT = 240; + +function _sameDay(a, b) { + const da = new Date(a * 1000), db = new Date(b * 1000); + return da.getFullYear() === db.getFullYear() + && da.getMonth() === db.getMonth() + && da.getDate() === db.getDate(); +} + +/** "Today" / "Yesterday" / a written date, in the reader's language. */ +function _dayLabel(ts) { + const d = new Date(ts * 1000); + const now = new Date(); + if (_sameDay(ts, now.getTime() / 1000)) return t('chat.today'); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (_sameDay(ts, yesterday.getTime() / 1000)) return t('chat.yesterday'); + return d.toLocaleDateString(getLocale(), { + weekday: 'long', day: 'numeric', month: 'long', + year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', + }); +} + +function ChatImage({ filename, entries, transportRef, gekRef }) { + const [blobUrl, setBlobUrl] = useState(null); + const [loading, setLoading] = useState(true); + const loadedRef = useRef(false); + + useEffect(() => { + if (loadedRef.current) return; + let cancelled = false; + const load = async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { setLoading(true); return; } + const entry = entries.find(e => e.name === filename); + if (!entry) { setLoading(true); return; } + try { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks); + if (cancelled) return; + const ext = filename.split('.').pop().toLowerCase(); + const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg'; + const blob = new Blob(chunks, { type: mime }); + loadedRef.current = true; + setBlobUrl(URL.createObjectURL(blob)); + } catch { /* ignore */ } + if (!cancelled) setLoading(false); + }; + load(); + return () => { cancelled = true; }; + }, [filename, entries.length]); + + useEffect(() => { + return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); }; + }, [blobUrl]); + + if (loading) return html`<div class="chat-att-thumb"><span class="spinner"></span></div>`; + if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`; + return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`; +} + +function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, + onPreview, mayUpload = true, onActivity }) { + const [messages, setMessages] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [atBottom, setAtBottom] = useState(true); + const [unreadFrom, setUnreadFrom] = useState(null); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [attaching, setAttaching] = useState(false); + const listRef = useRef(null); + const panelRef = useRef(null); + const inputRef = useRef(null); + const loadedRef = useRef(false); + // Set just before older messages are prepended; read once, after the DOM has + // them but before the browser paints. + const anchorRef = useRef(null); + const atBottomRef = useRef(true); + + useEffect(() => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + if (!loadedRef.current) { + loadedRef.current = true; + // The newest page. This used to be fetchChatHistory(0, 200), which paged + // forwards from the very first message ever sent, so a busy group opened + // on its oldest screen and the recent conversation was unreachable. + transport.fetchChatHistory({ limit: CHAT_PAGE }) + .then(({ messages: msgs, hasMore: more }) => { + setMessages(msgs); + setHasMore(more); + }) + .catch(() => {}); + } + + transport.onChat = (msg) => { + // A live message has no row id until it is re-read from the node, so it + // gets a local one. Keys have to be stable and unique or prepending a + // page makes Preact reuse the wrong bubbles. Computed once and reused + // below: the unread marker points at a message by id, so generating a + // second one there would point it at nothing. + const id = msg.id + || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; + setMessages(prev => [...prev, { + id, + sender_id: msg.sender_id, + sender_name: msg.sender_name || '', + payload: msg.payload, + timestamp: msg.timestamp || Date.now() / 1000, + thread_id: msg.thread_id, + }]); + // Somebody wrote while you were reading further up: mark where you were + // rather than yanking the view down. + if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); + }; + + return () => { transport.onChat = null; }; + }, [transportRef.current?.connected]); + + const loadOlder = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected || loadingOlder || !messages.length) return; + setLoadingOlder(true); + const list = listRef.current; + // Keeping the reading position means restoring the distance from the + // *bottom*, not scrollTop: everything above the viewport just grew. + anchorRef.current = list ? list.scrollHeight - list.scrollTop : null; + try { + const { messages: older, hasMore: more } = + await transport.fetchChatHistory({ before: messages[0].id, limit: CHAT_OLDER_PAGE }); + setMessages(prev => [...older, ...prev]); + setHasMore(more); + } catch { + anchorRef.current = null; + } finally { + setLoadingOlder(false); + } + }, [messages, loadingOlder]); + + useLayoutEffect(() => { + const list = listRef.current; + if (!list) return; + if (anchorRef.current !== null) { + list.scrollTop = list.scrollHeight - anchorRef.current; + anchorRef.current = null; + return; + } + // Only follow the conversation if the reader was already at the bottom. + // Scrolling unconditionally fought every attempt to read back through it. + // + // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no + // height, so aligning it to the bottom of the viewport leaves the list's + // own padding below it and the bar stops just short of the end. + if (atBottomRef.current) list.scrollTop = list.scrollHeight; + }, [messages]); + + // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a + // phone the group header — title, description, edit link, delete button, tabs + // — is closer to 430px, so the panel ran past the fold and the composer ended + // up off screen with the whole page scrolling to reach it. + // + // Measured instead, from the panel's own position in the document, so the + // header can be any height. `visualViewport` rather than innerHeight where it + // exists: on Android the on-screen keyboard shrinks the visual viewport + // without changing innerHeight, and the composer would go back under it. + useLayoutEffect(() => { + const el = panelRef.current; + if (!el) return; + const fit = () => { + const vh = window.visualViewport?.height || window.innerHeight; + // Document-relative, so a page that happens to be scrolled does not skew + // the result — the answer must be the same either way. + const top = el.getBoundingClientRect().top + window.scrollY; + el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`; + // What sits *below* the panel is not knowable from up here — today it is + // `.main`'s 24px bottom padding against this 16px gap, which left the + // document 8px taller than the window and a scrollbar on the chat tab at + // every window size. Rather than encode 24 somewhere and have the next + // change to the page break it again, the leftover is measured and taken + // off. Self-correcting: anything added under the panel is absorbed the + // same way. + const over = document.documentElement.scrollHeight - vh; + if (over > 0) { + el.style.height = + `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`; + } + }; + fit(); + window.addEventListener('resize', fit); + window.addEventListener('orientationchange', fit); + window.visualViewport?.addEventListener('resize', fit); + return () => { + window.removeEventListener('resize', fit); + window.removeEventListener('orientationchange', fit); + window.visualViewport?.removeEventListener('resize', fit); + }; + }, []); + + const onScroll = useCallback((e) => { + const el = e.target; + const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + atBottomRef.current = bottom; + setAtBottom(bottom); + if (bottom) setUnreadFrom(null); + }, []); + + const jumpToBottom = useCallback(() => { + atBottomRef.current = true; + setAtBottom(true); + setUnreadFrom(null); + const list = listRef.current; + if (list) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' }); + }, []); + + const sendMessage = useCallback(async () => { + const text = input.trim(); + if (!text) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + setSending(true); + setInput(''); + try { + await transport.sendChat(text, 0, null, username); + setMessages(prev => [...prev, { + id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, + sender_id: username, + sender_name: username, + payload: text, + timestamp: Date.now() / 1000, + thread_id: null, + }]); + jumpToBottom(); + if (onActivity) onActivity(); + } catch { + setInput(text); + } finally { + setSending(false); + setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }); + } + }, [input, username, jumpToBottom]); + + const attachFile = useCallback(async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setAttaching(true); + try { + // Two people sending IMG_1234.jpg both succeed; the node picks a free name + // and the message has to point at the one it chose. + const ack = await transport.uploadFile(file); + const storedAs = (ack && ack.stored_as) || file.name; + await new Promise(r => setTimeout(r, 2500)); + if (onRefreshIndex) await onRefreshIndex(); + const ext = file.name.split('.').pop().toLowerCase(); + const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image' + : ['mp4','webm','mkv','mov','avi'].includes(ext) ? 'video' : 'file'; + const structured = JSON.stringify({ + text: '', attachment: { filename: storedAs, size: file.size, type: ftype }, + }); + await transport.sendChat(structured, 0, null, username); + setMessages(prev => [...prev, { + id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, + sender_id: username, sender_name: username, + payload: structured, timestamp: Date.now() / 1000, thread_id: null, + }]); + jumpToBottom(); + } catch (err) { + alert(err.message); + } finally { + setAttaching(false); + } + }, [username, onRefreshIndex, jumpToBottom]); + + const onKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }, [sendMessage]); + + return html` + <div class="chat-panel" ref=${panelRef}> + <div class="chat-messages" ref=${listRef} onScroll=${onScroll}> + ${hasMore && html` + <div class="chat-older-row"> + <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}> + ${loadingOlder + ? html`<span class="spinner"></span>` + : html`<${Icon} name="chevron" cls="chat-older-icon" />`} + ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })} + </button> + </div> + `} + ${!hasMore && messages.length > 0 && html` + <div class="chat-start">${t('chat.start_of_history')}</div> + `} + ${messages.length === 0 && html` + <div class="chat-empty">${t('chat.empty')}</div> + `} + ${messages.map((m, i) => { + const isOwn = m.sender_name === username || m.sender_id === username; + const displayName = m.sender_name || '?'; + const prev = messages[i - 1]; + const showSender = !isOwn && (i === 0 || + (prev.sender_name || prev.sender_id) !== (m.sender_name || m.sender_id)); + // A conversation read over several days is unreadable without them. + const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) + ? _dayLabel(m.timestamp) : null; + const parsed = _parsePayload(m.payload); + const att = parsed && parsed.attachment; + return html` + ${daySep && html` + <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div> + `} + ${unreadFrom && unreadFrom === m.id && html` + <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div> + `} + <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''} + ${showSender || daySep ? '' : 'chat-msg-tight'}"> + ${showSender && html` + <div class="chat-sender">${displayName}</div> + `} + <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}"> + ${att ? html` + <div class="chat-attachment" style="cursor:pointer" onClick=${() => { + if (!onPreview) return; + const entry = entries.find(e => e.name === att.filename); + if (entry) onPreview(entry); + }}> + ${att.type === 'image' + ? html`<${ChatImage} filename=${att.filename} entries=${entries} + transportRef=${transportRef} gekRef=${gekRef} />` + : att.type === 'video' + ? html`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>` + : html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>` + } + <div class="chat-att-size">${formatSize(att.size)}</div> + </div> + ` : html` + <span class="chat-text"> + ${linkify(parsed && typeof parsed.text === 'string' + ? parsed.text : m.payload)} + </span> + `} + <span class="chat-time">${formatTime(m.timestamp)}</span> + </div> + </div> + `; + })} + </div> + ${!atBottom && messages.length > 0 && html` + <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}> + <${Icon} name="chevron" cls="chat-jump-icon" /> + ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')} + </button> + `} + <div class="chat-input-row"> + ${mayUpload && html` + <label class="chat-attach" title="${t('chat.attach')}"> + ${attaching ? html`<span class="spinner"></span>` + : html`<${Icon} name="clip" />`} + <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} /> + </label> + `} + <textarea class="chat-input" rows="1" ref=${inputRef} + placeholder="${t('chat.placeholder')}" + value=${input} + onInput=${e => setInput(e.target.value)} + onKeyDown=${onKeyDown} + disabled=${sending} /> + <button class="chat-send" onClick=${sendMessage} + disabled=${sending || !input.trim()}> + ${t('chat.send')} + </button> + </div> + </div> + `; +} + +// ── Video Player (MSE streaming) ──────────────────────────────────────── + + +export { ChatPanel }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js new file mode 100644 index 0000000..4541290 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -0,0 +1,207 @@ +import * as downloads from './downloads.js'; +import * as platform from './platform.js'; + +const FILE_ICONS = { + video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', + document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', +}; + +function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; +} + +function formatDate(ts) { + return new Date(ts * 1000).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }); +} + +const PREVIEWABLE_TEXT = + /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; + +function canPreview(e) { + return ['image', 'video', 'document'].includes(e.type) + || PREVIEWABLE_TEXT.test(e.name); +} + +const CHUNK_SIZE = 1024 * 1024; +// Segments allowed in flight while there is room to put them. This is a window, +// topped up as segments land, and not a debt released in one go: accumulating a +// credit per append and handing the lot over when the buffer finally had room +// sent 6 MB in a burst, overshot the target by a minute of film, and then said +// nothing for the next forty-six seconds. Measured in Chrome against real +// fragmented MP4. A stream that arrives in gulps has no margin for a network +// that hesitates, and looks like a hang while it is quiet. +const PIPELINE_WINDOW = 8; + +/** + * Open somewhere to write, honouring the user's download setting. + * + * Returns a target ({writable, name}), null for "no stream available — collect + * it and hand the browser a blob", or false for "the person dismissed the + * dialog", which is not an error and must not start a transfer. + */ +async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, + swSize = size) { + // On a desktop build this is the whole answer, and it comes first. + // + // The two browser paths below are both unavailable there — `showDirectoryPicker` + // does not exist, and Chromium refuses a service worker on a custom scheme — + // so without this the chain fell all the way through to its floor, which + // collects the file in the page and hands the browser a blob. A gigabyte of + // film meant a gigabyte of RAM, and a Save As dialog at the *end*. + if (platform.capabilities.nativeSave) { + try { + const native = await platform.nativeSave( + filename, { auto: downloads.getMode() === 'auto' }); + // Null means the person dismissed the dialog, which is not an error and + // must not start a transfer. + return native || false; + } catch (err) { + console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err)); + return false; + } + } + + try { + const target = await downloads.openTarget(filename); + if (target) return target; + } catch (err) { + console.warn('[MeshBay] download folder unusable:', err.message); + } + + // No granted folder. A service worker can still hand the browser a stream to + // write, which is how this works at all in Firefox: the alternative there is + // to collect gigabytes in a tab. It goes to the browser's own download + // folder, without a dialog, which is what "save automatically" meant. + if (downloads.getMode() === 'auto') { + const streamed = await downloads.openStreamedDownload(filename, swSize); + if (streamed) return streamed; + // Nothing to stream to: small enough for memory, and no dialog. + if (size < downloads.BLOB_LIMIT) return null; + } + + if (!window.showSaveFilePicker) return null; + try { + const handle = await window.showSaveFilePicker({ + suggestedName: filename, ...pickerOpts, + }); + return { writable: await handle.createWritable(), name: handle.name || filename }; + } catch (err) { + if (err.name === 'AbortError') return false; + throw err; + } +} + +/** The download of last resort, for browsers with no way to stream to disk. */ +function _saveBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function _b64ToU8(b64) { + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; +} + +async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, + writable, signal) { + const results = writable ? null : new Array(totalChunks); + let nextSend = 0, nextRecv = 0; + const inflight = new Array(totalChunks); + + const fire = () => { + while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { + inflight[nextSend] = transport.fetchChunk(fileId, nextSend); + nextSend++; + } + }; + + fire(); + while (nextRecv < totalChunks) { + if (signal && signal.aborted) { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + throw err; + } + const chunkMsg = await inflight[nextRecv]; + let plaintext; + if (gekKey && chunkMsg.ct) { + plaintext = await window.MeshBayCrypto.decryptChunkBin( + gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); + } else if (gekKey && chunkMsg.ct_b64) { + plaintext = await window.MeshBayCrypto.decryptChunk( + gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64); + } else { + plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64); + } + if (writable) { + await writable.write(plaintext); + } else { + results[nextRecv] = plaintext; + } + nextRecv++; + fire(); + if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks); + } + return results; +} + +/** + * Download one file through the transfers widget: picks a target, streams + * and decrypts it, and falls back to a blob when there is nowhere to stream + * to. Shared by the Files table/toolbar and the video/preview modals' own + * download button — both just want "get this entry to disk". + */ +async function downloadEntry(transfers, transport, gek, entry) { + const target = await _openDownloadTarget(entry.name, entry.size); + if (target === false) return; // the picker was dismissed + + const openRef = { url: null }; + transfers.start({ + kind: 'download', name: (target && target.name) || entry.name, + total: entry.size, transport, + open: target + ? (target.open || null) + : () => { if (openRef.url) window.open(openRef.url, '_blank'); }, + run: async ({ signal, onProgress }) => { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + let done = 0; + const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; + + if (target) { + try { + await pipelinedDownload(transport, gek, entry.id, totalChunks, + onChunk, target.writable, signal); + await target.writable.close(); + } catch (err) { + await target.writable.abort().catch(() => {}); + throw err; + } + } else { + const chunks = await pipelinedDownload( + transport, gek, entry.id, totalChunks, onChunk, null, signal); + const blob = new Blob(chunks); + _saveBlob(blob, entry.name); + openRef.url = URL.createObjectURL(blob); + } + }, + }); +} + +export { + FILE_ICONS, + formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, + _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry, +}; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js new file mode 100644 index 0000000..561d8a7 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -0,0 +1,620 @@ +import { + html, useState, useEffect, useRef, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; +import { transfers } from './transfers.js'; +import { + FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, + _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, +} from './file-utils.js'; + +// ── Files ──────────────────────────────────────────────────────────────────── +// +// The group's file browser: toolbar (upload, mkdir, filter, select), the +// table itself, and every file/directory operation. `entries`/`nodeDirs`/ +// `nodeRoots` are owned by the group shell (group-page.js) — Chat needs the +// same index for its image attachments — and handed down here read-only +// alongside `applyIndex`/the raw setters to write back through, the same +// shape ChatPanel already takes for `onRefreshIndex`. +// +// `onPreview` opens a file in the shell's video/preview modal rather than +// this component owning that state itself, again because more than one tab +// (Chat's attachments) can trigger it. + +function FilesPanel({ + groupId, transportRef, gekRef, status, + entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, + isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, +}) { + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState(() => new Set()); + const [sortKey, setSortKey] = useState('name'); + const [sortAsc, setSortAsc] = useState(true); + const [filter, setFilter] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + + // A directory from the group just left rarely exists in the one just + // entered (e.g. "outputs" in one group, absent in another) — Files would + // otherwise show that stale path and list nothing. + useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); + + const downloadFile = useCallback(async (entry) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + // Both of these have to happen inside the click: a browser grants a file + // picker, and re-grants a folder, only from a user gesture — so this stays + // a click handler all the way down into downloadEntry's own picker call. + await downloadEntry(transfers, transport, gekRef.current, entry); + }, []); + + const uploadFile = useCallback((e) => { + const files = [...(e.target.files || [])]; + e.target.value = ''; + const transport = transportRef.current; + if (!files.length || !transport || !transport.connected) return; + setError(''); + + for (const file of files) { + transfers.start({ + kind: 'upload', name: file.name, total: file.size, transport, + run: async ({ signal, onProgress }) => { + await transport.uploadFile(file, { + // Bytes the node acknowledged, not bytes read locally. + onProgress: (sent) => onProgress(sent, file.size), + signal, + }); + // The node re-indexes on a filesystem event, so there is nothing to + // wait on but the clock. Refreshing here means the file appears in + // the list without anyone reloading. + await new Promise(r => setTimeout(r, 2500)); + if (transport.connected) applyIndex(await transport.fetchIndex()); + }, + }); + } + }, [applyIndex]); + + const makeDirectory = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + const name = prompt(t('group.mkdir_prompt')); + if (!name || !name.trim()) return; + try { + await transport.createDirectory(currentPath, name.trim()); + const indexMsg = await transport.fetchIndex(); + if (indexMsg.entries) setEntries(indexMsg.entries); + if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); + if (indexMsg.roots) setNodeRoots(indexMsg.roots); + } catch (err) { + setError(err.message); + } + }, [currentPath]); + + /** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + */ + const downloadDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. + const target = await _openDownloadTarget(suggested, totalBytes, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }, 0); + if (target === false) return; + if (!target && !confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + const gek = gekRef.current; + const zipOpenRef = { url: null }; + + transfers.start({ + kind: 'download', name: (target && target.name) || suggested, + total: totalBytes, transport, + open: target + ? (target.open || null) + : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, + run: async ({ signal, onProgress }) => { + const writable = target ? target.writable : null; + const parts = writable ? null : []; + let written = 0; + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gek, entry.id, totalChunks, + (bytes) => { written += bytes; onProgress(written, totalBytes); }, + // pipelinedDownload writes in order, which the archive needs. + { write: (plaintext) => zip.write(plaintext) }, signal); + await zip.end(); + } + await zip.finish(); + if (writable) await writable.close(); + else { + const blob = new Blob(parts, { type: 'application/zip' }); + _saveBlob(blob, suggested); + zipOpenRef.url = URL.createObjectURL(blob); + } + } catch (err) { + if (writable) await writable.abort().catch(() => {}); + throw err; + } + }, + }); + }, [entries]); + + const deleteDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteDirectory(dir, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + + const deleteFile = useCallback(async (entry) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + // Signs an explicit transcript built by transport.js, not opaque bytes from + // the node — see MeshBayCrypto.adminTranscript and finding H5. + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteFile(entry.id, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + + const toggleSort = useCallback((key) => { + setSortAsc(prev => sortKey === key ? !prev : true); + setSortKey(key); + }, [sortKey]); + + const dirs = new Set(); + const filteredEntries = entries.filter(e => { + const ePath = e.path || ''; + if (ePath === currentPath) { + return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); + } + if (!currentPath && ePath) { + dirs.add(ePath.split('/')[0]); + } else if (currentPath && ePath.startsWith(currentPath + '/')) { + const rest = ePath.slice(currentPath.length + 1); + dirs.add(rest.split('/')[0]); + } + return false; + }); + + const sorted = [...filteredEntries].sort((a, b) => { + let cmp = 0; + if (sortKey === 'name') cmp = a.name.localeCompare(b.name); + else if (sortKey === 'size') cmp = a.size - b.size; + else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); + else if (sortKey === 'date') cmp = a.added_at - b.added_at; + return sortAsc ? cmp : -cmp; + }); + + // The node's own listing, so an empty folder is visible, plus anything implied + // by a file path in case the two ever disagree. + for (const d of nodeDirs) { + if (!currentPath && !d.includes('/')) dirs.add(d); + else if (currentPath && d.startsWith(currentPath + '/')) { + const rest = d.slice(currentPath.length + 1); + if (!rest.includes('/')) dirs.add(rest); + } + } + const subdirs = [...dirs].sort(); + + // At the top of a group the folders on screen ARE the roots, so their state + // belongs there. Deeper in, everything shown lives inside one readable root + // and there is nothing to flag. + const rootState = new Map(nodeRoots.map(r => [r.name, r])); + const unavailableHere = currentPath + ? [] + : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); + // A member cannot create a folder at the top of a group: that level is the + // set of roots, which is the operator's configuration and not a directory on + // anyone's disk. The node refuses it, so offering it would only produce an + // error nobody can act on. + const canCreateDir = Boolean(currentPath) && isNodeAdmin; + + const breadcrumbs = currentPath ? currentPath.split('/') : []; + + // Selection is keyed globally — file ids, and 'dir:' plus a full path — so + // walking into another folder keeps what was already ticked. + const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name); + const selectedFiles = entries.filter(e => selected.has(e.id)); + const selectedDirs = [...selected] + .filter(k => typeof k === 'string' && k.startsWith('dir:')) + .map(k => k.slice(4)); + const toggle = (key) => setSelected(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + + const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0 + ? selectedFiles[0] : null; + const deletableFiles = selectedFiles.filter( + e => isNodeAdmin || (userId && e.uploader_id === userId)); + + const run = (fn) => { + setSelecting(false); + setSelected(new Set()); + Promise.resolve().then(fn).catch(err => { + if (err && err.name !== 'AbortError') setError(err.message); + }); + }; + + // Icon only, with the name in the tooltip: these sit in a toolbar that is + // already narrow, and every one of them is a verb the icon carries on its + // own. `title` gives the hover text and `aria-label` the accessible name — + // an icon button with neither is unusable with a screen reader. + // + // Every action is rendered as soon as Select is on, and the ones that do not + // apply are disabled rather than absent. Buttons appearing and vanishing as + // the selection changed made the bar jump about and gave no clue that an + // action existed at all before something was ticked. + const action = (icon, label, onClick, opts = {}) => html` + <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}" + title=${label} aria-label=${label} + disabled=${!!opts.disabled} onClick=${onClick}> + <${Icon} name=${icon} /> + </button> + `; + + const canPlay = !!(onlyFile && onlyFile.type === 'video'); + const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); + const deletableCount = deletableFiles.length + + (operatorPaired ? selectedDirs.length : 0); + // The operator can always delete; anyone else only ever sees the button if + // something here is theirs to remove. Hiding it from an uploader would take + // away a right the protocol grants them (draft-v5 §5.1), not just a control. + const mayEverDelete = isNodeAdmin + || (userId && entries.some(e => e.uploader_id === userId)); + + const actionItems = html` + ${action('play', t('group.play'), + () => run(() => onPreview(onlyFile)), { disabled: !canPlay })} + ${action('eye', t('group.view'), + () => run(() => onPreview(onlyFile)), { disabled: !canView })} + ${action('download', + selectedFiles.length + ? t('group.download_n', { n: selectedFiles.length }) + : t('group.download'), + () => run(async () => { + // Awaited one at a time, and each returns as soon as its transfer is + // registered — so the transfers still run together. Firing them without + // awaiting meant every file asked the browser for a save dialog at + // once, and a browser allows one: the rest were rejected and only the + // first file ever downloaded. + for (const e of selectedFiles) await downloadFile(e); + }), { disabled: selectedFiles.length === 0 })} + ${action('archive', + selectedDirs.length + ? t('group.download_zip_n', { n: selectedDirs.length }) + : t('group.download_zip_n', { n: 0 }), + () => run(async () => { + for (const d of selectedDirs) await downloadDirectory(d); + }), { disabled: selectedDirs.length === 0 })} + ${mayEverDelete && action('trash', + deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), + () => { + const names = [...deletableFiles.map(e => e.name), + ...(operatorPaired ? selectedDirs : [])]; + if (!confirm(t('group.delete_n_confirm', { n: names.length, + names: names.join(', ') }))) return; + run(() => { + for (const e of deletableFiles) deleteFile(e); + if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); + }); + }, + { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} + `; + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> + `} + ${status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} + + ${status === 'connected' && html` + <div class="file-toolbar"> + <div class="toolbar-group"> + ${mayUpload && html` + <label class="tb-btn primary"> + <${Icon} name="upload" /> ${t('group.upload')} + <input type="file" multiple style="display:none" + onChange=${uploadFile} /> + </label> + `} + ${canCreateDir && html` + <button class="tb-btn" onClick=${makeDirectory}> + <${Icon} name="folder-plus" /> ${t('group.mkdir')} + </button> + `} + </div> + + <div class="breadcrumbs"> + <a class="crumb" onClick=${() => setCurrentPath('')}> + <${Icon} name="home" /> + </a> + ${breadcrumbs.map((seg, i) => { + const path = breadcrumbs.slice(0, i + 1).join('/'); + return html` + <span class="crumb-sep">/</span> + <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a> + `; + })} + </div> + + <div class="toolbar-group right"> + <div class="tb-search"> + <${Icon} name="search" /> + <input type="text" placeholder="${t('group.filter')}" + value=${filter} onInput=${e => setFilter(e.target.value)} /> + </div> + <button class="tb-btn ${selecting ? 'active' : ''}" + onClick=${() => { + setSelecting(v => !v); + setSelected(new Set()); + }}> + <${Icon} name=${selecting ? 'check' : 'checkbox'} /> + ${selecting ? t('group.select_done') : t('group.select')} + </button> + ${selecting && html`<div class="tb-actions">${actionItems}</div>`} + </div> + </div> + <table class="file-table"> + <thead> + <tr> + ${selecting && html`<th class="sel-cell"></th>`} + <th></th> + <th class="sortable" onClick=${() => toggleSort('name')}> + ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable" onClick=${() => toggleSort('size')}> + ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable th-type" onClick=${() => toggleSort('type')}> + ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable th-date" onClick=${() => toggleSort('date')}> + ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''} + </th> + </tr> + </thead> + <tbody> + ${subdirs.map(d => { + const full = currentPath ? currentPath + '/' + d : d; + const inside = entriesUnder(entries, full); + const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); + return html` + <tr class="file-row dir-row" key=${full} onClick=${() => + selecting ? toggle(dirKey(d)) : setCurrentPath(full)}> + ${selecting && html` + <td class="sel-cell"> + <input type="checkbox" checked=${selected.has(dirKey(d))} + onClick=${(ev) => ev.stopPropagation()} + onChange=${() => toggle(dirKey(d))} /> + </td> + `} + <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td> + <td>${d}${unavailableHere.includes(d) ? html` + <span class="root-offline"> ${t('group.root_unavailable')}</span> + ` : ''}</td> + <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> + <td class="td-type"></td> + <td class="td-date"></td> + </tr> + `; })} + ${sorted.map(e => html` + <tr class="file-row" key=${e.id} + onClick=${() => selecting && toggle(e.id)}> + ${selecting && html` + <td class="sel-cell"> + <input type="checkbox" checked=${selected.has(e.id)} + onClick=${(ev) => ev.stopPropagation()} + onChange=${() => toggle(e.id)} /> + </td> + `} + <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td> + <td class="file-name"> + ${!selecting && canPreview(e) + ? html`<a class="file-link" onClick=${() => onPreview(e)}>${e.name}</a>` + : e.name + } + </td> + <td class="file-size">${formatSize(e.size)}</td> + <td class="file-type td-type">${e.type}</td> + <td class="file-date td-date">${formatDate(e.added_at)}</td> + </tr> + `)} + ${sorted.length === 0 && subdirs.length === 0 && html` + <tr><td colspan=${selecting ? 6 : 5} class="file-empty"> + ${filter ? t('group.empty_filter') : t('group.empty_dir')} + </td></tr> + `} + </tbody> + </table> + `} + `; +} + +// ── File Preview (text, images) ───────────────────────────────────────── + +const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; +const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; + +function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { + const [phase, setPhase] = useState('loading'); + const [progress, setProgress] = useState(0); + const [content, setContent] = useState(null); + const [error, setError] = useState(''); + const [downloading, setDownloading] = useState(false); + const blobUrlRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + return; + } + try { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + let downloaded = 0; + const chunks = await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, + ); + if (cancelled) return; + + if (/\.pdf$/i.test(entry.name)) { + // Decrypted here and shown from a blob: URL — the bytes never leave + // the page, and the browser's own viewer renders them. + const blob = new Blob(chunks, { type: 'application/pdf' }); + blobUrlRef.current = URL.createObjectURL(blob); + setContent({ type: 'pdf' }); + } else if (entry.name.match(IMAGE_EXTS)) { + const ext = entry.name.split('.').pop().toLowerCase(); + const mime = ext === 'svg' ? 'image/svg+xml' + : ext === 'png' ? 'image/png' + : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' + : 'image/jpeg'; + const blob = new Blob(chunks, { type: mime }); + blobUrlRef.current = URL.createObjectURL(blob); + setContent({ type: 'image' }); + } else { + const decoder = new TextDecoder('utf-8', { fatal: false }); + const text = chunks.map(c => decoder.decode(c, { stream: true })).join(''); + setContent({ type: 'text', text: text.slice(0, 500000) }); + } + setPhase('ready'); + } catch (err) { + if (!cancelled) { setError(err.message); setPhase('error'); } + } + }; + load(); + return () => { cancelled = true; }; + }, [entry]); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${onDownload && html` + <button class="video-close ${downloading ? 'dl-active' : ''}" + onClick=${() => { + if (!downloading) { + setDownloading(true); + onDownload(); + setTimeout(() => setDownloading(false), 1500); + } + }} + title="${t('group.download')}" disabled=${downloading}> + ${downloading + ? html`<span class="spinner"></span>` + : html`<${Icon} name="download" />`}</button> + `} + <button class="video-close" onClick=${onClose} title="${t('video.close')}"> + <${Icon} name="close" /></button> + </div> + ${phase === 'loading' && html` + <div class="video-loading"> + <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div> + <div class="video-progress-bar"> + <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div> + </div> + </div> + `} + ${phase === 'ready' && content?.type === 'pdf' && html` + <object data=${blobUrlRef.current} type="application/pdf" + class="preview-pdf" aria-label=${entry.name}> + <p class="page-message">${t('preview.pdf_fallback')}</p> + </object> + `} + ${phase === 'ready' && content?.type === 'image' && html` + <div class="preview-image-wrap"> + <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} /> + </div> + `} + ${phase === 'ready' && content?.type === 'text' && html` + <div class="preview-text-wrap"> + <pre class="preview-text">${content.text}</pre> + </div> + `} + ${phase === 'error' && html` + <div class="video-error">${error}</div> + `} + </div> + `; +} + +export { FilesPanel, FilePreview }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js new file mode 100644 index 0000000..1528213 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -0,0 +1,484 @@ +import { + html, useState, useEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { transfers } from './transfers.js'; +import { downloadEntry } from './file-utils.js'; +import { + HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, +} from './hub-client.js'; +import { visibleApps } from './apps.js'; +import { FilePreview } from './files-app.js'; +import { VideoPlayer } from './video-player.js'; +import { GroupSettingsPanel } from './group-settings.js'; + +/** + * The group shell: everything a group's "applications" (Chat, Files, and + * whatever registers in apps.js next) share — the WebRTC connection, the file + * index, and the tab bar that switches between them — plus the group header + * and the Settings tab, which is not itself an app (disabling it would strand + * an operator with no way to re-enable anything). + */ +function GroupPage({ groupId, group, token, username, userId, userPrefs, + onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) { + const [status, setStatus] = useState('idle'); + const [entries, setEntries] = useState([]); + + const [error, setError] = useState(''); + const [videoEntry, setVideoEntry] = useState(null); + const [previewEntry, setPreviewEntry] = useState(null); + const [editingDesc, setEditingDesc] = useState(false); + const [descDraft, setDescDraft] = useState(''); + const [savingDesc, setSavingDesc] = useState(false); + const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat'; + const [tab, setTab] = useState(defaultTab); + useEffect(() => { setTab(defaultTab); }, [groupId]); + const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted)); + + const _lastTouch = useRef(0); + const touchActivity = useCallback(() => { + const now = Date.now(); + if (now - _lastTouch.current < 60_000) return; + _lastTouch.current = now; + const ts = new Date().toISOString(); + if (onGroupUpdated) onGroupUpdated(groupId, { last_activity_at: ts }); + hubFetch(`/v1/groups/${groupId}/activity`, { method: 'POST', token }).catch(() => {}); + }, [groupId, token, onGroupUpdated]); + + const toggleGroupMute = useCallback(async () => { + const next = !groupMuted; + setGroupMuted(next); + try { + await hubFetch(`/v1/groups/${groupId}/mute`, { + method: 'POST', token, body: { muted: next }, + }); + if (onGroupUpdated) onGroupUpdated(groupId, { muted: next }); + } catch (err) { + setGroupMuted(!next); + } + }, [groupMuted, groupId, token, onGroupUpdated]); + + // Directories are not index entries, so a new empty one needs a nudge + // to appear in the breadcrumb listing. + const [nodeDirs, setNodeDirs] = useState([]); + // The group's roots and whether each is readable. A root whose drive is + // unplugged keeps its files listed — they are frozen, not deleted — so this is + // the only thing that lets the UI say which of the two it is. + const [nodeRoots, setNodeRoots] = useState([]); + + const [isNodeAdmin, setIsNodeAdmin] = useState(false); + // Whether ordinary members may upload here. The node decides and enforces it; + // this only says whether to offer the controls. Defaults to true so a node + // that predates the setting behaves as it always did. + const [memberUpload, setMemberUpload] = useState(true); + // Which applications this group has enabled, from the node. Falls back to + // every registered app when a node predates the setting (or hasn't answered + // yet), so nothing disappears for an existing group. + const [enabledApps, setEnabledApps] = useState(null); + // Paired ≠ operator account. `is_node_admin` says the hub account owning this + // node is the one connecting; this says the node pinned *this browser's* key + // as an operator key. Only the second one lets you sign an invite, and only + // the second one should make the pairing form go away. + const [operatorPaired, setOperatorPaired] = useState(false); + const [needsCode, setNeedsCode] = useState(false); + // This browser holds a key the node does not know, for an account it does. + // Not the operator's problem: a device already paired here can admit it. + const [needsDevice, setNeedsDevice] = useState(false); + const [deviceCode, setDeviceCode] = useState(''); + const [codeInput, setCodeInput] = useState(''); + const [retryKey, setRetryKey] = useState(0); + const transportRef = useRef(null); + const gekRef = useRef(null); + // One refresh per group: if a fresh token still says we are not a member, we + // really are not, and retrying forever would hide that. `GroupPage` is + // rendered without a `key` on the `/group/:id` route (switching groups does + // not remount it — see the `[groupId]`-keyed effects below), so this has to + // be reset explicitly per group rather than relying on a fresh mount: a ref + // set to `true` while looking at one group would otherwise silently disable + // the retry for every group opened afterward in the same session, forever. + const refreshedRef = useRef(false); + useEffect(() => { refreshedRef.current = false; }, [groupId]); + + const submitJoinCode = useCallback((e) => { + e.preventDefault(); + const code = codeInput.trim(); + if (!code) return; + session.pendingJoinCode = code; + setCodeInput(''); + setNeedsCode(false); + setError(''); + setRetryKey(k => k + 1); + }, [codeInput]); + + // One place that takes an index from the node and puts it everywhere it has to + // go. Deleting a file used to refresh the table and leave the cache alone, so + // the search page went on offering a file that no longer existed until the + // group was reconnected. + const applyIndex = useCallback((indexMsg) => { + const fresh = indexMsg.entries || []; + setEntries(fresh); + if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); + if (indexMsg.roots) setNodeRoots(indexMsg.roots); + cacheGroupIndex(groupId, group ? group.name : groupId, fresh); + }, [groupId, group]); + + useEffect(() => { + let cancelled = false; + + // The cache is written here and read only by the search page. It used to + // seed this list too, which put a stale index on screen and then raced the + // live one: IndexedDB is async, so a fast node could be overwritten by the + // cache landing afterwards. Files shows what the node says, or says it + // cannot reach the node. + + const connect = async () => { + setStatus('discovering'); + setError(''); + gekRef.current = null; + if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); + try { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (cancelled) return; + if (!nodesData.nodes || nodesData.nodes.length === 0) { + setStatus('offline'); + if (onPresence) onPresence(groupId, 'offline'); + return; + } + + // No keys are carried in: the transport fetches this node's identity + // from the node, or creates one there on a first join. + const sessionKeys = null; + + setStatus('connecting'); + const nodeId = nodesData.nodes[0].node_id; + // Renewed here rather than taken from the prop. This effect no longer + // re-runs when the token rotates (see the dependency list below), so + // the captured one can be older than the session's — and it is used to + // sign the offer to the hub, where an expired one is a 401 and no + // connection at all. Renewals are shared, so if one is already in + // flight this waits for it instead of starting a second. + const live = (await ensureFreshToken()) || token; + // The same base the API calls use: signaling is a hub endpoint like + // any other, and two sources for one address is how they drift. + const transport = new window.MeshBayTransport(HUB, live); + transportRef.current = transport; + + const ack = await transport.connect( + nodeId, live, groupId, null, sessionKeys, session.bundleKey, username, + userId, session.pendingJoinCode); + session.pendingJoinCode = null; + if (cancelled) return; + setIsNodeAdmin(!!ack.is_node_admin); + setMemberUpload(ack.member_upload !== false); + setEnabledApps(ack.enabled_apps || null); + // Changed while we are connected, by an operator who may be someone + // else entirely. Without this the button stays until a reconnection, + // and a button that is still there is a button people press. + transport.onUploadPolicy = (allowed) => setMemberUpload(allowed); + transport.onAppsEnabled = (apps) => setEnabledApps(apps); + setOperatorPaired(transport.memberRole === 'operator'); + + // A first join to this node generated an identity for it; leave it with + // the node so any other browser can become the same person here with the + // passphrase. It is this node's key and no other's. + if (transport.connected && transport.newNodeBundle) { + try { + await transport.storeKeypairBundle(transport.newNodeBundle); + transport.newNodeBundle = null; + } catch (e) { + console.warn('[MeshBay] could not leave our key with the node:', e.message); + } + } + + // Import GEK from transport (fetched from node during handshake) + if (transport.gekRaw && window.MeshBayCrypto) { + gekRef.current = await window.MeshBayCrypto.importGEK( + window.MeshBayCrypto.b64encode(transport.gekRaw)); + } + + setStatus('fetching'); + + transport.onIndexSync = (msg) => { + if (cancelled) return; + applyIndex(msg); + }; + + // We are in: an invitation to this group has served its purpose. + if (onJoined) onJoined(groupId); + + const indexMsg = await transport.fetchIndex(); + if (cancelled) return; + applyIndex(indexMsg); + setStatus('connected'); + touchActivity(); + // First-hand evidence, and the strongest available: this browser spoke + // to the node. It outranks whatever the hub said in the group list. + if (onPresence) onPresence(groupId, 'online'); + } catch (err) { + if (cancelled) return; + + // Our token predates being added to this group. Refresh once and retry + // rather than telling someone who was just invited that they are not a + // member — which is what the node honestly sees, and is useless to them. + if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) { + refreshedRef.current = true; + try { + if (await onRefreshAuth()) { + setRetryKey(k => k + 1); + return; + } + } catch { /* fall through to the message below */ } + } + + // The node has never seen this browser for this account: it needs a + // one-time code from the operator before it will hand over the group + // key. Not an error to shout about — a step in joining. + if (err.reason === 'code_required') setNeedsCode(true); + // A key this node has never pinned, for an account it knows. The way in + // is a device already trusted here, not an operator — which is the + // whole point of device linking: a second browser or a native client + // must not cost anyone a support request. + if (err.reason === 'unknown_device') setNeedsDevice(true); + setError(err.message); + setStatus('error'); + if (transportRef.current) { + try { transportRef.current.close(); } catch { /* already gone */ } + transportRef.current = null; + } + // A refusal means the node answered, so it is up; only a failure to + // reach it at all is evidence of absence. + if (onPresence) { + onPresence(groupId, err.reason ? 'online' : 'offline'); + } + } + }; + + if (token && window.MeshBayTransport) { + connect(); + } else if (!window.MeshBayTransport) { + setStatus('error'); + setError(t('group.err_transport')); + } + + return () => { + cancelled = true; + if (transportRef.current) { + // Handed over rather than closed: a download running when you leave the + // group keeps its connection, and the last transfer using it closes it. + transfers.releaseWhenIdle(transportRef.current); + transportRef.current = null; + } + }; + // applyIndex is deliberately not a dependency: its identity changes with the + // `group` object, which the hub poll re-creates, and re-running this effect + // means tearing down the WebRTC connection. groupId is here, so a real group + // change still re-captures it. + // + // Neither is the token itself, only whether there is one. It used to be a + // dependency and that was harmless while a token never changed during a + // session — it only expired. Now that the session renews itself, the string + // rotates, and this effect tore the WebRTC connection down and rebuilt it + // every time. Worst on arrival: a stored token past its life is renewed the + // instant the page mounts, which is exactly when the group page is + // negotiating ICE, so the connection was abandoned mid-handshake and the + // node sat in `connecting` for ever. The live token is read inside + // `connect()` instead. Signing out unmounts this page; signing in mounts + // it; nothing in between should disturb a working connection. + }, [groupId, Boolean(token), retryKey]); + + const downloadFileForModal = useCallback(async (entry) => { + // The video/preview modals' own download button — the table's row and + // toolbar actions call the same shared helper from files-app.js, since + // only a single open file/video is ever in play here. + const transport = transportRef.current; + if (!transport || !transport.connected) return; + await downloadEntry(transfers, transport, gekRef.current, entry); + }, []); + + const refreshIndex = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + applyIndex(await transport.fetchIndex()); + } catch {} + }, [applyIndex]); + + const saveDescription = useCallback(async (e) => { + e.preventDefault(); + setSavingDesc(true); + try { + const r = await hubFetch(`/v1/groups/${groupId}`, { + method: 'PATCH', token, body: { description: descDraft }, + }); + if (onGroupUpdated) onGroupUpdated(groupId, { description: r.description }); + setEditingDesc(false); + } catch (err) { + setError(err.message); + } finally { + setSavingDesc(false); + } + }, [groupId, token, descDraft, onGroupUpdated]); + + // Asked in two places — the Files toolbar and the chat composer — so it is + // answered once. The operator is never locked out of their own node. + const mayUpload = memberUpload || isNodeAdmin; + + // A single dispatcher so any app can open the right modal without owning + // video/preview state itself — Files' table and Chat's attachments both + // call this the same way. + const onPreview = useCallback((entry) => { + if (entry.type === 'video') setVideoEntry(entry); else setPreviewEntry(entry); + }, []); + + const apps = visibleApps(enabledApps); + const commonProps = { + groupId, transportRef, gekRef, status, username, + entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, + isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + onRefreshIndex: refreshIndex, onActivity: touchActivity, + }; + + return html` + <div> + <div class="group-header"> + <div> + <h2 style="margin-bottom:${group && group.description ? '4px' : '0'}"> + ${group ? group.name : t('group.default_name')} + </h2> + ${editingDesc + ? html` + <form class="group-desc-edit" onSubmit=${saveDescription}> + <textarea rows="2" maxlength="512" autofocus + placeholder="${t('group.desc_placeholder')}" + value=${descDraft} + onInput=${e => setDescDraft(e.target.value)}></textarea> + <div> + <button class="admin-btn" type="submit" disabled=${savingDesc}> + ${savingDesc ? '...' : t('group.desc_save')} + </button> + <button class="btn-secondary" type="button" + onClick=${() => setEditingDesc(false)}>${t('group.desc_cancel')}</button> + </div> + </form> + ` + : html` + ${group && group.description && html` + <p class="group-desc">${group.description}</p> + `} + ${group && group.is_admin && html` + <button class="link-btn" title=${t('group.desc_edit')} + onClick=${() => { setDescDraft(group.description || ''); + setEditingDesc(true); }}> + <${Icon} name="pencil" />${' '} + ${group.description ? t('group.desc_edit') : t('group.desc_add')} + </button> + `} + `} + </div> + ${group && html` + <button class="group-mute-btn" onClick=${toggleGroupMute} + title=${groupMuted ? t('group.unmute') : t('group.mute')}> + <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} /> + </button> + `} + </div> + ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '} + <button class="admin-btn" style="margin-left:8px;font-size:0.9em" + onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button> + </div>`} + ${needsDevice && html` + <div class="invite-form" style="margin-bottom:12px"> + <h4>${t('device.add_title')}</h4> + <p class="settings-hint">${t('device.add_hint')}</p> + ${!deviceCode && html` + <button class="admin-btn" onClick=${async () => { + try { + const transport = transportRef.current; + const out = await transport.requestDeviceAdd(userId); + setDeviceCode(out.code); + } catch (err) { setError(err.message); } + }}>${t('device.add_btn')}</button> + `} + ${deviceCode && html` + <p class="settings-hint">${t('device.add_show')}</p> + <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px"> + ${deviceCode} + </p> + `} + </div> + `} + ${needsCode && html` + <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}> + <h4>${t('group.join_code_title')}</h4> + <p class="settings-hint">${t('group.join_code_hint')}</p> + <div style="display:flex;gap:8px"> + <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" + value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required /> + <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button> + </div> + </form> + `} + ${/* Not gated on the connection any more. Leaving a group, deleting it + and seeing who is in it are hub-side, and moving them into this tab + would otherwise have made them unreachable exactly when a node is + down — which is when someone is most likely to want them. The apps + below still need the node and say so. */ group && html` + <div class="group-tabs"> + ${apps.map(a => html` + <button key=${a.key} class="group-tab ${tab === a.key ? 'active' : ''}" + onClick=${() => setTab(a.key)} title=${t(a.labelKey)}> + <${Icon} name=${a.icon} cls="tab-icon" /></button> + `)} + <button class="group-tab ${tab === 'settings' ? 'active' : ''}" + onClick=${() => setTab('settings')} title=${t('group.tab_settings')}> + <${Icon} name="gear" cls="tab-icon" /></button> + </div> + + ${apps.map(a => tab === a.key && html` + <${a.Component} key=${a.key} ...${commonProps} /> + `)} + + ${tab === 'settings' && html` + <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token} + transportRef=${transportRef} gekRef=${gekRef} + isNodeAdmin=${isNodeAdmin} userId=${userId} + operatorPaired=${operatorPaired} connected=${status === 'connected'} + memberUpload=${memberUpload} + onMemberUpload=${(allowed) => setMemberUpload(allowed)} + enabledApps=${enabledApps} + onEnabledApps=${(keys) => setEnabledApps(keys)} + onLeft=${onLeft} + onPaired=${() => setOperatorPaired(true)} /> + `} + `} + ${status === 'offline' && !group && html` + <p class="page-message"> + ${t('group.offline_title')} + ${' '}${t('group.offline_hint')} + </p> + `} + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> + `} + ${previewEntry && html` + <${FilePreview} + entry=${previewEntry} + transportRef=${transportRef} + gekRef=${gekRef} + onClose=${() => setPreviewEntry(null)} + onDownload=${() => downloadFileForModal(previewEntry)} /> + `} + ${videoEntry && html` + <${VideoPlayer} + entry=${videoEntry} + transportRef=${transportRef} + gekRef=${gekRef} + onClose=${() => setVideoEntry(null)} + onDownload=${() => downloadFileForModal(videoEntry)} /> + `} + </div> + `; +} + +export { GroupPage }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js new file mode 100644 index 0000000..a14fd21 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -0,0 +1,635 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { hubFetch, navigate } from './hub-client.js'; +import { APPS } from './apps.js'; +import * as platform from './platform.js'; + +// ── Members Panel ──────────────────────────────────────────────────────── + +/** + * Everything about the group that is not its files or its chat. + * + * Was "Members", which was a list with three unrelated forms stacked on top of + * it and the group's own controls somewhere else entirely — leaving or deleting + * a group lived in the header, beside its title. One tab now, in sections, with + * the roster last: it is the part that grows without limit, and burying the + * controls under two hundred names is how a tab stops being usable. + */ +function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, + isNodeAdmin, userId, operatorPaired, connected, + memberUpload, onMemberUpload, + enabledApps, onEnabledApps, + onPaired, onLeft }) { + const [members, setMembers] = useState([]); + const [adminId, setAdminId] = useState(''); + const [loading, setLoading] = useState(true); + const [inviteUser, setInviteUser] = useState(''); + const [inviting, setInviting] = useState(false); + const [error, setError] = useState(''); + + // Node loopback state (Electron-only) + const [nodeDetected, setNodeDetected] = useState(false); + const [nodeRoots, setNodeRoots] = useState([]); + const [nodeGroupName, setNodeGroupName] = useState(''); + const [nodeBusy, setNodeBusy] = useState(false); + const [nodeMsg, setNodeMsg] = useState(''); + + const loadNodeInfo = useCallback(async () => { + if (!platform.node.available) return; + try { + const detect = await platform.node.detect(); + if (!detect.detected) { setNodeDetected(false); return; } + setNodeDetected(true); + const data = await platform.node.call('GET', '/api/groups'); + const groups = data.groups || []; + const ng = groups.find(g => g.id === groupId); + if (ng) { + setNodeRoots(ng.roots || []); + setNodeGroupName(ng.name || ''); + } + } catch { setNodeDetected(false); } + }, [groupId]); + + useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); + const [inviteCode, setInviteCode] = useState(null); + const [pairCode, setPairCode] = useState(''); + const [pairStatus, setPairStatus] = useState(''); + const [pairing, setPairing] = useState(false); + // Your own devices on this node. Not a members feature — it is beside them + // because this is where a live connection to the node exists. + const [devices, setDevices] = useState([]); + const [approveCode, setApproveCode] = useState(''); + const [deviceMsg, setDeviceMsg] = useState(''); + + // Pairing lives here rather than in Settings because this is where a live + // connection to the node exists — and it is offered only when the node itself + // says this account is its operator (is_node_admin comes from the authenticated + // handshake_ack, not from the hub). + const loadDevices = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const out = await transport.listDevices(); + setDevices(out.devices); + } catch { /* a node that has none says so by listing none */ } + }, [transportRef]); + + useEffect(() => { loadDevices(); }, [loadDevices]); + + const approveDevice = useCallback(async (e) => { + e.preventDefault(); + const code = approveCode.trim(); + if (!code) return; + setDeviceMsg(''); + try { + await transportRef.current.approveDevice(userId, code); + setApproveCode(''); + setDeviceMsg(t('device.approved')); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [approveCode, userId, transportRef, loadDevices]); + + const revokeDevice = useCallback(async (device) => { + if (!confirm(t('device.revoke_confirm'))) return; + setDeviceMsg(''); + try { + await transportRef.current.revokeDevice( + userId, device.pk_ed25519, device.pk_x25519 || ''); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [userId, transportRef, loadDevices]); + + const doPair = useCallback(async (e) => { + e.preventDefault(); + const code = pairCode.trim(); + if (!code) return; + setPairing(true); + setPairStatus(''); + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) throw new Error('Not connected to the node'); + await transport.pairOperator(userId, code); + setPairCode(''); + setPairStatus('paired'); + // The node has pinned this key as an operator key; the form has nothing + // left to do. It used to stay put through a refresh, because what governed + // it was the account, which pairing does not change. + if (onPaired) onPaired(); + } catch (err) { + setPairStatus(err.message); + } finally { + setPairing(false); + } + }, [pairCode, transportRef, userId]); + + const [uploadBusy, setUploadBusy] = useState(false); + const [uploadMsg, setUploadMsg] = useState(''); + + /** + * Close or open uploading for everyone who is not the operator. + * + * Signed, like removing a member: the node refuses an unsigned instruction, + * so this is a request to the node rather than a decision taken here. The + * button does not move until the node has said it did it. + */ + const setUploads = useCallback(async (allowed) => { + const transport = transportRef && transportRef.current; + setUploadMsg(''); + setUploadBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setMemberUpload(allowed, signFn); + if (onMemberUpload) onMemberUpload(allowed); + } catch (err) { + setUploadMsg(err.message); + } finally { + setUploadBusy(false); + } + }, [transportRef, onMemberUpload]); + + const [appsBusy, setAppsBusy] = useState(false); + const [appsMsg, setAppsMsg] = useState(''); + const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key); + + /** + * Toggle one app in or out of the group's enabled set. Same shape as + * `setUploads`: signed, and the checkbox does not move until the node has + * said it did it. Refuses to submit an empty set client-side — the node + * refuses it too, but there is no reason to make a round trip to learn that. + */ + const toggleApp = useCallback(async (key) => { + const next = activeApps.includes(key) + ? activeApps.filter(k => k !== key) + : [...activeApps, key]; + if (next.length === 0) { + setAppsMsg(t('members.apps_need_one')); + return; + } + const transport = transportRef && transportRef.current; + setAppsMsg(''); + setAppsBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setAppsEnabled(next, signFn); + if (onEnabledApps) onEnabledApps(next); + } catch (err) { + setAppsMsg(err.message); + } finally { + setAppsBusy(false); + } + }, [transportRef, onEnabledApps, activeApps]); + + const [removing, setRemoving] = useState(''); + + /** + * Take someone out of this group: both halves, in the order that fails safe. + * + * The node first, because that is the half that stops the group key being + * wrapped for them; if the hub removal then fails, they are a member on paper + * with no key. The other order would leave them able to reach a node that + * still serves them. + */ + const removeMember = useCallback(async (member) => { + const transport = transportRef && transportRef.current; + setError(''); + setRemoving(member.user_id); + try { + if (platform.node.available) { + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/revoke?group_id=${groupId}`); + } catch { /* best effort — node may not host this group */ } + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/unpin`); + } catch { /* best effort */ } + } else if (transport && transport.connected && operatorPaired) { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.revokeMember(member.user_id, signFn); + } + await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { + method: 'DELETE', token, + }); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setRemoving(''); + } + }, [groupId, token, transportRef, operatorPaired]); + + const loadMembers = useCallback(() => { + setLoading(true); + hubFetch(`/v1/groups/${groupId}/members`, { token }) + .then(data => { + setMembers(data.members || []); + setAdminId(data.admin_id || ''); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [groupId, token]); + + useEffect(() => { loadMembers(); }, [loadMembers]); + + const isAdmin = group && group.is_admin; + + const doInvite = useCallback(async (e) => { + e.preventDefault(); + if (!inviteUser.trim()) return; + setInviting(true); + setError(''); + setInviteCode(null); + try { + const transport = transportRef && transportRef.current; + const username = inviteUser.trim(); + if (!transport || !transport.connected) { + throw new Error('Not connected to the node — it must be online to invite'); + } + + // The hub is asked for the account id, and nothing else. It is no longer + // asked for the invitee's public key: the node wraps the group key itself, + // for a key the invitee proves possession of when they connect (H3). A hub + // that answered with the wrong account here would produce an invite whose + // code it never learns — the code goes to a human, out of band. + const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); + + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + const result = await transport.createInvite( + account.user_id, groupId, username, signFn); + + // Membership on the hub is what lets them reach the node at all; the code + // is what gets them the key. + await hubFetch(`/v1/groups/${groupId}/members/${username}`, { + method: 'POST', token, body: {}, + }); + + setInviteCode({ username, code: result.code, expires: result.expires_at }); + setInviteUser(''); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setInviting(false); + } + }, [groupId, token, inviteUser, loadMembers, transportRef]); + + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; + + const isOwner = Boolean(isAdmin); + + return html` + <div class="members-panel"> + ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + + ${/* Inviting needs the node: it is the node that wraps the group key and + issues the code, not the hub. Public groups admit anyone — no invite. + The form stays in the DOM so a brief reconnect does not destroy the + input the user is typing into — controls are disabled instead. */ + isAdmin && group?.join_policy !== 'open' && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.invite_title')}</h3> + ${!connected ? html` + <p class="settings-hint">${t('group.offline_title')}</p> + ` : !operatorPaired ? html` + <p class="settings-hint"> + ${isNodeAdmin ? t('members.invite_needs_pairing') + : t('members.invite_ask_operator')} + </p> + ` : ''} + <form onSubmit=${doInvite}> + ${inviteCode && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> + <p class="code-display">${inviteCode.code}</p> + <p>${t('members.invite_code_hint')}</p> + </div> + `} + <div class="form-row"> + <input type="text" placeholder="${t('members.username_placeholder')}" + value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} + disabled=${!connected || !operatorPaired} required /> + <button class="admin-btn" type="submit" + disabled=${inviting || !connected || !operatorPaired}> + ${inviting ? '...' : t('members.invite_btn')} + </button> + </div> + </form> + </div> + `} + + ${isNodeAdmin && !operatorPaired && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.pair_title')}</h3> + <p class="settings-hint">${t('members.pair_hint')}</p> + ${pairStatus && html` + <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}> + ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} + </p> + `} + <form class="form-row" onSubmit=${doPair}> + <input type="text" placeholder="XXXX-XXXX" class="code-input" + value=${pairCode} onInput=${e => setPairCode(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${pairing}> + ${pairing ? '...' : t('members.pair_btn')} + </button> + </form> + </div> + `} + + ${/* Which group "applications" members see. New ones (Videos, Music, + Photos) show up here automatically as they register in apps.js — + nothing about this section changes to add one. */ + isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.apps_title')}</h3> + <p class="settings-hint">${t('members.apps_hint')}</p> + <ul class="apps-toggle-list"> + ${APPS.map(a => html` + <li key=${a.key} class="settings-row"> + <label class="settings-label"> + <input type="checkbox" checked=${activeApps.includes(a.key)} + disabled=${appsBusy} + onChange=${() => toggleApp(a.key)} /> + ${' '}${t(a.labelKey)} + </label> + </li> + `)} + </ul> + ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`} + </div> + `} + + ${/* Roots management (Electron-only, when node is local) */ + nodeDetected && nodeRoots.length > 0 && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings_node.roots')}</h3> + ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`} + <div class="node-roots"> + ${nodeRoots.map(r => html` + <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" + key=${r.name}> + <div class="node-root-info"> + <span class="node-root-name"> + <${Icon} name="folder" /> + ${r.name} + </span> + ${r.upload && html` + <span class="node-root-badge">${t('node.upload_root')}</span>`} + ${!r.available && html` + <span class="node-root-badge node-root-badge-warn"> + ${t('node.unavailable')}</span>`} + </div> + ${nodeRoots.length > 1 && !r.upload && html` + <button class="btn btn-small btn-danger" + disabled=${nodeBusy} + onClick=${async () => { + if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return; + setNodeBusy(true); setNodeMsg(''); + try { + await platform.node.call('DELETE', + '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name)); + await platform.node.call('POST', '/api/reload'); + setNodeMsg(t('node.root_removed')); + await loadNodeInfo(); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + ${t('node.remove_root')}</button>`} + </div> + `)} + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${nodeBusy} + onClick=${async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + setNodeBusy(true); setNodeMsg(''); + try { + await platform.node.call('POST', + '/api/groups/' + groupId + '/roots', + { path: chosen.path, name: chosen.name }); + await platform.node.call('POST', '/api/reload'); + setNodeMsg(t('node.root_added')); + await loadNodeInfo(); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + </div> + </div> + `} + + ${/* Operator only, and only with a live connection: the node is what + holds and enforces this, so there is nothing to show or change + without one. */ isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.uploads_title')}</h3> + <div class="settings-row"> + <span class="settings-label"> + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + </span> + <button class="admin-btn" disabled=${uploadBusy} + onClick=${() => setUploads(!memberUpload)}> + ${uploadBusy ? '...' + : (memberUpload ? t('members.uploads_disable') + : t('members.uploads_enable'))} + </button> + </div> + <p class="settings-hint">${t('members.uploads_hint')}</p> + ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`} + </div> + `} + + ${/* Upload toggle via loopback when MNP not connected */ + nodeDetected && !connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.uploads_title')}</h3> + <div class="settings-row"> + <span class="settings-label"> + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + </span> + <button class="admin-btn" disabled=${nodeBusy} + onClick=${async () => { + setNodeBusy(true); setNodeMsg(''); + try { + const newVal = !memberUpload; + await platform.node.call('PUT', + '/api/groups/' + groupId + '/member-upload', + { allowed: newVal }); + if (onMemberUpload) onMemberUpload(newVal); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + ${memberUpload ? t('members.uploads_disable') + : t('members.uploads_enable')} + </button> + </div> + <p class="settings-hint">${t('members.uploads_hint')}</p> + </div> + `} + + ${/* Delete/leave — node detach first (reversible), then hub delete + (irreversible). */ html` + <div class="settings-section"> + <h3 class="settings-heading"> + ${isOwner ? t('group.delete_group') : t('group.leave')} + </h3> + <div class="settings-row"> + <span class="settings-label"> + ${isOwner ? t('members.danger_delete_hint') + : t('members.danger_leave_hint')} + </span> + ${isOwner + ? html` + <button class="admin-btn danger" onClick=${async () => { + if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; + try { + // Node detach first (reversible), then hub delete (irreversible) + if (nodeDetected && nodeGroupName) { + try { + await platform.node.call('POST', '/api/groups/detach', + { name: nodeGroupName }); + } catch (detachErr) { + if (!confirm(t('settings_node.detach_failed_continue'))) return; + } + } + await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); + navigate('/'); + window.location.reload(); + } catch (err) { setError(err.message); } + }}>${t('group.delete_group')}</button> + ` + : html` + <button class="admin-btn danger" onClick=${async () => { + if (!confirm(t('group.leave_confirm', { name: group.name }))) return; + try { + await hubFetch('/v1/groups/' + groupId + '/leave', + { method: 'POST', token }); + if (onLeft) onLeft(groupId); + } catch (err) { setError(err.message); } + }}>${t('group.leave')}</button> + `} + </div> + </div> + `} + + ${connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('device.mine_title')}</h3> + <p class="settings-hint">${t('device.mine_hint')}</p> + ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`} + ${devices.length === 0 + ? html`<p class="settings-hint">${t('device.mine_empty')}</p>` + : html` + <ul class="device-list"> + ${devices.map(d => html` + <li class="device-row" key=${d.pk_ed25519}> + <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span> + <span class="device-meta"> + ${d.is_this_one && html` + <span class="badge">${t('device.this_one')}</span>${' '} + `} + ${d.pinned_via}${d.label ? ' · ' + d.label : ''} + </span> + ${!d.is_this_one && devices.length > 1 && html` + <button class="admin-btn" onClick=${() => revokeDevice(d)}> + ${t('device.revoke')} + </button> + `} + </li> + `)} + </ul> + `} + <form onSubmit=${approveDevice} class="settings-subform"> + <p class="settings-hint">${t('device.approve_hint')}</p> + <div class="form-row"> + <input type="text" placeholder="XXXX-XXXX" class="code-input" + value=${approveCode} onInput=${e => setApproveCode(e.target.value)} /> + <button class="admin-btn" type="submit">${t('device.approve_btn')}</button> + </div> + </form> + </div> + `} + + <div class="settings-section"> + <h3 class="settings-heading"> + ${t('group.tab_members')} (${members.length}) + </h3> + <table class="admin-table"> + <thead> + <tr> + <th>${t('admin.col_username')}</th> + <th>${t('members.group_role')}</th> + <th></th> + </tr> + </thead> + <tbody> + ${members.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td> + ${m.user_id === adminId + ? html`<span class="badge badge-owner">${t('members.owner')}</span>` + : html`<span class="badge">${t('members.member')}</span>` + } + </td> + <td class="admin-actions"> + ${isAdmin && m.user_id !== adminId && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${() => { + if (!confirm(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> + </tr> + `)} + </tbody> + </table> + ${isAdmin && members.length > 1 && html` + <p class="settings-hint">${t('members.remove_hint')}</p> + `} + </div> + </div> + `; +} + +// ── Chat Panel ────────────────────────────────────────────────────────── + +/** + * Message text with its links made clickable. + * + * Only http and https, and built as elements rather than markup: a message is + * something another member wrote, so it must never become HTML. `javascript:` + * and `data:` are not matched at all, and the anchors carry noopener so the new + * tab cannot reach back into this one. + */ +const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; + + +export { GroupSettingsPanel }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js new file mode 100644 index 0000000..aaf7ddb --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -0,0 +1,269 @@ +import * as platform from './platform.js'; + +// Where the hub is. Empty in a browser — it served this page, so a relative +// path cannot be pointed at the wrong place. In the installed app the page +// comes from disk and has no origin of its own, so the base is configured. +// See platform.js. +const HUB = platform.hubBase(); +const AUTH_KEY = 'mb_auth'; +// Renew an access token with this much life left rather than waiting for it to +// fail. Generous against a one-hour token: a film is watched without the hub +// hearing a word, and coming back to a tab that has been asleep for an hour +// should not cost a round trip before the first click works. +const TOKEN_RENEW_MARGIN_S = 600; +const IDB_NAME = 'meshbay'; +const IDB_VERSION = 1; +const IDB_STORE = 'group_indexes'; + +function navigate(path) { + window.location.hash = path; +} + +// ── IndexedDB cache ───────────────────────────────────────────────────────── + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function cacheGroupIndex(groupId, groupName, entries) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put({ + groupId, groupName, entries, cachedAt: Date.now(), + }); + await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); + db.close(); + } catch { /* best-effort */ } +} + +async function getCachedGroupIndex(groupId) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).get(groupId); + const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); + db.close(); + return result || null; + } catch { return null; } +} + +async function getAllCachedIndexes() { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).getAll(); + const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); + db.close(); + return result || []; + } catch { return []; } +} + +// ── Auth persistence ───────────────────────────────────────────────────────── + +// The key that opens a node's keypair bundle, derived once at sign-in, and a +// one-time pairing/join code the user just typed — consumed by the next +// connection attempt. Neither is persisted: the bundle key is re-derived (or +// re-fetched from a node's own backup, see `_loadBundleKey`) each session, and +// a code is single-use and short-lived. A plain object, not two bare `let`s, +// so importing modules can update either field without this module handing +// out a rebindable export. +const session = { bundleKey: null, pendingJoinCode: null }; + +function _openKeyDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open('meshbay_keys', 1); + req.onupgradeneeded = () => req.result.createObjectStore('k'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} +async function _storeBundleKey(key) { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readwrite'); + tx.objectStore('k').put(key, 'bk'); + await new Promise(r => { tx.oncomplete = r; }); + db.close(); + } catch {} +} +async function _loadBundleKey() { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readonly'); + const g = tx.objectStore('k').get('bk'); + const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); + db.close(); + return val || null; + } catch { return null; } +} +async function _clearKeyDB() { + try { + const db = await _openKeyDB(); + const tx = db.transaction('k', 'readwrite'); + tx.objectStore('k').clear(); + await new Promise(r => { tx.oncomplete = r; }); + db.close(); + } catch {} +} + +function loadAuth() { + try { + return JSON.parse(localStorage.getItem(AUTH_KEY)); + } catch { + return null; + } +} + +function saveAuth(auth) { + if (auth) { + localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); + } else { + localStorage.removeItem(AUTH_KEY); + session.bundleKey = null; + _clearKeyDB(); + } +} + +// ── Session ────────────────────────────────────────────────────────────────── +// +// The access token lasts an hour and the refresh token thirty days. Nothing was +// using the second: `hubFetch` reported a 401 as an error like any other, so an +// hour of watching a film — during which the hub hears nothing, because the +// video comes over WebRTC — ended with "token expired or invalid" and no way +// out but signing out and back in. Reopening the tab the next day did the same, +// with a perfectly good refresh token sitting in localStorage beside the stale +// access one. +// +// This lives outside any component because `hubFetch` is a plain function and +// has to be able to renew a token mid-request without every caller passing the +// machinery down to it. + +let _auth = loadAuth(); +let _onAuthChange = null; // set by App, so the UI follows a background renewal +let _refreshing = null; // in flight, shared: see refreshAccessToken + +/** App() calls this once, to hear about a renewal that happened in the background. */ +function setAuthChangeListener(fn) { + _onAuthChange = fn; +} + +function setAuth(auth) { + _auth = auth; + saveAuth(auth); + if (_onAuthChange) _onAuthChange(auth); +} + +/** Seconds until this JWT expires, or null if it says nothing useful. */ +function tokenLifeLeft(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + if (!payload.exp) return null; + return payload.exp - Math.floor(Date.now() / 1000); + } catch { + return null; // not a JWT we can read; treat as unknown, never as expired + } +} + +/** + * Trade the refresh token for a new pair. + * + * The hub rotates: it revokes the token presented and returns a new one, and a + * revoked token presented again revokes the whole family. So the new one must + * be stored — the previous code kept only the access token and dropped its + * replacement, which burned the refresh token on first use and locked the + * account out of renewal on the second. That is why signing out and in was the + * only way back. + * + * Concurrent callers share one request. Two 401s racing would otherwise send + * the same refresh token twice, and the second would look exactly like theft. + */ +async function refreshAccessToken() { + if (!_auth || !_auth.refreshToken) return null; + if (_refreshing) return _refreshing; + _refreshing = (async () => { + try { + const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: _auth.refreshToken }), + }); + if (!r.ok) { + // Expired, revoked, or the family was torn down. Nothing to salvage: + // sign out cleanly rather than leave a session that fails every call. + setAuth(null); + return null; + } + const data = await r.json(); + setAuth({ + ..._auth, + token: data.access_token, + refreshToken: data.refresh_token || _auth.refreshToken, + }); + return data.access_token; + } catch { + return null; // offline: keep the session, the next call can try again + } finally { + _refreshing = null; + } + })(); + return _refreshing; +} + +/** Renew before it bites, rather than after. */ +async function ensureFreshToken() { + if (!_auth || !_auth.token) return null; + const left = tokenLifeLeft(_auth.token); + if (left !== null && left > TOKEN_RENEW_MARGIN_S) return _auth.token; + return refreshAccessToken(); +} + +// ── Hub API ────────────────────────────────────────────────────────────────── + +async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { + const headers = {}; + if (body) headers['Content-Type'] = 'application/json'; + // Prefer the token the session currently holds. Callers read theirs from + // React state, which is a render behind a renewal that happened in the + // background — and sending the stale one would 401 for no reason. + const bearer = token && _auth && _auth.token ? _auth.token : token; + if (bearer) headers['Authorization'] = `Bearer ${bearer}`; + const opts = { method, headers }; + if (body) opts.body = JSON.stringify(body); + const r = await platform.apiFetch(HUB + path, opts); + if (r.status === 401 && bearer && !_retried) { + // The one case worth a second attempt: the access token aged out while + // nothing was talking to the hub. Renew once and replay. If the renewal + // fails it signs out, and the replay below is skipped. + const fresh = await refreshAccessToken(); + if (fresh) { + return hubFetch(path, { method, body, token: fresh, _retried: true }); + } + } + if (!r.ok) { + const err = await r.json().catch(() => ({ detail: r.statusText })); + const detail = Array.isArray(err.detail) + ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ') + : (err.detail || r.statusText); + throw new Error(String(detail)); + } + return r.json(); +} + +export { + HUB, navigate, session, + cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, + _storeBundleKey, _loadBundleKey, _clearKeyDB, + loadAuth, saveAuth, setAuth, setAuthChangeListener, + tokenLifeLeft, refreshAccessToken, ensureFreshToken, hubFetch, +}; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js new file mode 100644 index 0000000..4dc24c0 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -0,0 +1,91 @@ +import { html } from './vendor/htm-preact.js'; + +// ── Icons ──────────────────────────────────────────────────────────────────── +// +// One stroked set, drawn in currentColor and sized in em, so an icon takes the +// weight and colour of the text beside it. The Administration entry was already +// an outline shield while the rest of the site was colour emoji — a different +// drawing on every operating system, and never the same line weight twice. +// +// The explorer keeps its emoji on purpose. There the icon says what kind of file +// this is, and the colour is doing real work; a wall of identical grey outlines +// would be a worse file list. + +const ICON_PATHS = { + menu: ['M4 7h16M4 12h16M4 17h16'], + bell: ['M18 9a6 6 0 1 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', + 'M10.3 20a2 2 0 0 0 3.4 0'], + shield: ['M12 3l7.5 3v5.2c0 4.6-3.1 8.6-7.5 10.3-4.4-1.7-7.5-5.7-7.5-10.3V6z'], + globe: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18', + 'M3.4 9.2h17.2M3.4 14.8h17.2', + 'M12 3c-2.6 2.4-4 5.6-4 9s1.4 6.6 4 9c2.6-2.4 4-5.6 4-9s-1.4-6.6-4-9'], + archive: ['M3 7.5h18v3H3z', 'M4.5 10.5V19a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-8.5', + 'M10 14h4'], + play: ['M8 5.5v13l11-6.5z'], + eye: ['M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12', + 'M12 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5'], + trash: ['M4 7h16', 'M10 11v6M14 11v6', + 'M6 7l1 12.5A1.5 1.5 0 0 0 8.5 21h7a1.5 1.5 0 0 0 1.5-1.5L18 7', + 'M9.5 7V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v2'], + user: ['M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8', + 'M4.5 20a7.5 7.5 0 0 1 15 0'], + gear: ['M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6', + 'M19.2 14.4a1.7 1.7 0 0 0 .3 1.9 2 2 0 1 1-2.8 2.8 1.7 1.7 0 0 0-2.9 1.2 2 2 0 0 1-4 0 1.7 1.7 0 0 0-2.9-1.2 2 2 0 1 1-2.8-2.8 1.7 1.7 0 0 0-1.2-2.9 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.2-2.9 2 2 0 1 1 2.8-2.8 1.7 1.7 0 0 0 2.9-1.2 2 2 0 0 1 4 0 1.7 1.7 0 0 0 2.9 1.2 2 2 0 1 1 2.8 2.8 1.7 1.7 0 0 0 1.2 2.9 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1z'], + sun: ['M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6', + 'M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4'], + moon: ['M20.8 13.4A8.6 8.6 0 1 1 10.6 3.2a6.9 6.9 0 0 0 10.2 10.2z'], + power: ['M12 3.2v8.4', 'M6.9 6.6a7.6 7.6 0 1 0 10.2 0'], + lock: ['M5.5 11h13a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1V12a1 1 0 0 1 1-1z', + 'M8 11V7.4a4 4 0 0 1 8 0V11'], + envelope: ['M4 5.5h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1z', + 'M3.4 6.6L12 13.4l8.6-6.8'], + door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5', + 'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'], + download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'], + upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'], + transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5', + 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'], + search: ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5'], + dots: ['M12 5.6h.01', 'M12 12h.01', 'M12 18.4h.01'], + checkbox: ['M5.5 4h13a1.5 1.5 0 0 1 1.5 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-13A1.5 1.5 0 0 1 5.5 4z'], + home: ['M4 11.2L12 4.5l8 6.7', 'M6.2 9.8V19a1 1 0 0 0 1 1h9.6a1 1 0 0 0 1-1V9.8'], + 'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z', + 'M12 11.4v5', 'M9.5 13.9h5'], + plus: ['M12 5v14', 'M5 12h14'], + clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'], + pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3', + 'M14.5 6.5l3 3'], + check: ['M4.5 12.5l5 5 10-11'], + chevron: ['M6 9.5l6 6 6-6'], + close: ['M6 6l12 12M18 6L6 18'], + chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'], + folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'], + 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', + 'M10.3 20a2 2 0 0 0 3.4 0', + 'M4 4l16 16'], + server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', + 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', + 'M8 7.5h.01', 'M8 16.5h.01'], + cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21', + 'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6', + 'M2 21h.01'], +}; + +// The M of the wordmark is a picture; the rest is text. Resolved from this +// module's own URL so the hub's fingerprinted path and the application's +// app:// scheme both come out right without either being named here. +const BRAND_M = new URL('./meshbay-m.png', import.meta.url).href; + +function Icon({ name, cls = '' }) { + const paths = ICON_PATHS[name]; + if (!paths) return null; + return html` + <svg class="icon ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false" + fill="none" stroke="currentColor" stroke-width="1.6" + stroke-linecap="round" stroke-linejoin="round"> + ${paths.map((d, i) => html`<path key=${i} d=${d} />`)} + </svg> + `; +} + +export { Icon }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 1a151e2..59127af 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -85,6 +85,9 @@ export default { 'members.uploads_disable': "Ausschalten", 'members.uploads_enable': "Einschalten", 'members.uploads_hint': "Gilt für alle außer Ihnen. Der Knoten lehnt den Upload selbst ab — es geht nicht darum, eine Schaltfläche zu verbergen.", + 'members.apps_title': "Anwendungen", + 'members.apps_hint': "Welche Teile dieser Gruppe Mitglieder sehen. Mindestens eine muss aktiviert bleiben.", + 'members.apps_need_one': "Mindestens eine Anwendung muss aktiviert bleiben.", 'members.danger_delete_hint': "Die Gruppe verschwindet für alle Mitglieder. Das lässt sich nicht rückgängig machen.", 'group.filter': 'Dateien filtern …', 'group.col_name': 'Name', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index dfd400b..58590b1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -86,6 +86,9 @@ export default { 'members.uploads_disable': "Turn off", 'members.uploads_enable': "Turn on", 'members.uploads_hint': "Applies to everyone but you. The node refuses the upload itself, so this is not a matter of hiding a button.", + 'members.apps_title': "Applications", + 'members.apps_hint': "Which parts of this group members see. At least one must stay on.", + 'members.apps_need_one': "At least one application must stay enabled.", 'members.danger_delete_hint': "This removes the group for every member. It cannot be undone.", 'group.filter': 'Filter files...', 'group.col_name': 'Name', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 498fc38..75a67b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -83,6 +83,9 @@ export default { 'members.uploads_disable': "Desactivar", 'members.uploads_enable': "Activar", 'members.uploads_hint': "Se aplica a todos menos a usted. El nodo rechaza la subida por sí mismo: no se trata de ocultar un botón.", + 'members.apps_title': "Aplicaciones", + 'members.apps_hint': "Qué partes de este grupo ven los miembros. Al menos una debe permanecer activada.", + 'members.apps_need_one': "Al menos una aplicación debe permanecer activada.", 'members.danger_delete_hint': "El grupo desaparece para todos sus miembros. No se puede deshacer.", 'group.filter': 'Filtrar archivos...', 'group.col_name': 'Nombre', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 9e21135..d60c5d0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -84,6 +84,9 @@ export default { 'members.uploads_disable': "Désactiver", 'members.uploads_enable': "Activer", 'members.uploads_hint': "S’applique à tout le monde sauf vous. C’est le nœud qui refuse l’envoi : il ne s’agit pas de masquer un bouton.", + 'members.apps_title': "Applications", + 'members.apps_hint': "Les parties de ce groupe visibles par les membres. Au moins une doit rester activée.", + 'members.apps_need_one': "Au moins une application doit rester activée.", 'members.danger_delete_hint': "Le groupe disparaît pour tous ses membres. C’est irréversible.", 'group.filter': 'Filtrer les fichiers...', 'group.col_name': 'Nom', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 7e2b100..b65e2ce 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -84,6 +84,9 @@ export default { 'members.uploads_disable': "Disattiva", 'members.uploads_enable': "Attiva", 'members.uploads_hint': "Vale per tutti tranne te. È il nodo a rifiutare il caricamento: non si tratta di nascondere un pulsante.", + 'members.apps_title': "Applicazioni", + 'members.apps_hint': "Quali parti di questo gruppo vedono i membri. Almeno una deve restare attiva.", + 'members.apps_need_one': "Almeno un'applicazione deve restare attiva.", 'members.danger_delete_hint': "Il gruppo scompare per tutti i membri. Non è reversibile.", 'group.filter': 'Filtra i file...', 'group.col_name': 'Nome', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index ee55fc2..e965590 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -82,6 +82,9 @@ export default { 'members.uploads_disable': "無効にする", 'members.uploads_enable': "有効にする", 'members.uploads_hint': "あなた以外の全員に適用されます。ノード自身がアップロードを拒否するため、ボタンを隠すだけの話ではありません。", + 'members.apps_title': "アプリケーション", + 'members.apps_hint': "メンバーに表示されるこのグループの機能です。少なくとも1つは有効のままにしてください。", + 'members.apps_need_one': "少なくとも1つのアプリケーションを有効にしておく必要があります。", 'members.danger_delete_hint': "グループはすべてのメンバーから消えます。元に戻せません。", 'group.filter': 'ファイルを絞り込み…', 'group.col_name': '名前', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 986d653..b37be08 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -85,6 +85,9 @@ export default { 'members.uploads_disable': "Uitschakelen", 'members.uploads_enable': "Inschakelen", 'members.uploads_hint': "Geldt voor iedereen behalve u. De node weigert de upload zelf — het gaat niet om het verbergen van een knop.", + 'members.apps_title': "Toepassingen", + 'members.apps_hint': "Welke onderdelen van deze groep leden zien. Minstens één moet ingeschakeld blijven.", + 'members.apps_need_one': "Er moet minstens één toepassing ingeschakeld blijven.", 'members.danger_delete_hint': "De groep verdwijnt voor alle leden. Dit kan niet ongedaan worden gemaakt.", 'group.filter': 'Bestanden filteren...', 'group.col_name': 'Naam', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index ca3f60c..b445f79 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -89,6 +89,9 @@ export default { 'members.uploads_disable': "Wyłącz", 'members.uploads_enable': "Włącz", 'members.uploads_hint': "Dotyczy wszystkich poza Tobą. To węzeł odrzuca przesłanie — nie chodzi o ukrycie przycisku.", + 'members.apps_title': "Aplikacje", + 'members.apps_hint': "Które części tej grupy widzą członkowie. Co najmniej jedna musi pozostać włączona.", + 'members.apps_need_one': "Co najmniej jedna aplikacja musi pozostać włączona.", 'members.danger_delete_hint': "Grupa zniknie dla wszystkich członków. Tego nie można cofnąć.", 'group.filter': 'Filtruj pliki...', 'group.col_name': 'Nazwa', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 8231daa..c3220ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -85,6 +85,9 @@ export default { 'members.uploads_disable': "Desativar", 'members.uploads_enable': "Ativar", 'members.uploads_hint': "Vale para todos menos você. O nó recusa o envio por conta própria: não se trata de esconder um botão.", + 'members.apps_title': "Aplicativos", + 'members.apps_hint': "Quais partes deste grupo os membros veem. Pelo menos um deve permanecer ativado.", + 'members.apps_need_one': "Pelo menos um aplicativo deve permanecer ativado.", 'members.danger_delete_hint': "O grupo desaparece para todos os membros. Não há como desfazer.", 'group.filter': 'Filtrar arquivos...', 'group.col_name': 'Nome', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 48122d3..0151c4b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -82,6 +82,9 @@ export default { 'members.uploads_disable': "关闭", 'members.uploads_enable': "开启", 'members.uploads_hint': "适用于除您之外的所有人。节点自身会拒绝上传,并非只是隐藏按钮。", + 'members.apps_title': "应用", + 'members.apps_hint': "成员可见的群组功能。至少需保留一个启用。", + 'members.apps_need_one': "至少需要保留一个已启用的应用。", 'members.danger_delete_hint': "该群组将对所有成员消失,且无法恢复。", 'group.filter': '筛选文件…', 'group.col_name': '名称', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 2cdf15d..da98253 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -104,6 +104,7 @@ class MeshBayTransport { set onStreamError(fn) { this._onStreamError = fn; } set onIndexSync(fn) { this._onIndexSync = fn; } set onUploadPolicy(fn) { this._onUploadPolicy = fn; } + set onAppsEnabled(fn) { this._onAppsEnabled = fn; } get sessionKeys() { return this._sessionKeys; } @@ -613,6 +614,26 @@ class MeshBayTransport { return msg; } + /** + * Turn a group "application" (Chat, Files, ...) on or off for everyone. + * + * Takes the whole set in one signed message rather than one op per app, so + * ticking several boxes in Settings costs one signature. `apps` is sorted + * and joined the same way on the node before it is shown for signing — + * `_authorizeAdminOp` below checks the two match. + */ + async setAppsEnabled(apps, signFn) { + const msg = await this._sendAndWait({ + type: 'apps_enabled', v: '0.1', apps, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'apps_enabled', [...apps].sort().join(','), signFn); + } + return msg; + } + async revokeMember(userId, signFn) { const msg = await this._sendAndWait({ type: 'member_revoke', v: '0.1', user_id: userId, @@ -1224,6 +1245,12 @@ class MeshBayTransport { this._onUploadPolicy(Boolean(msg.allowed)); } + // Same shape: the operator changed which apps are shown, and everyone + // connected hears about it without reconnecting. + if (msg.type === 'apps_enabled_ack' && this._onAppsEnabled) { + this._onAppsEnabled(msg.apps || []); + } + if (msg.type === 'index_sync' && msg.entries) { if (this._onIndexSync) this._onIndexSync(msg); const oldest = this._pending.entries().next(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js new file mode 100644 index 0000000..42842d2 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -0,0 +1,992 @@ +import { + html, useState, useEffect, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize } from './file-utils.js'; +import { loadAuth } from './hub-client.js'; +import * as platform from './platform.js'; + +// Seconds of already-watched video kept in the SourceBuffer, and the queue depth +// past which we start making room before being forced to. +const BUFFER_BEHIND_S = 60; +// How far past the playhead we are willing to pull. The browser caps a video +// SourceBuffer at a few hundred megabytes and refuses the append that goes +// past, so "as fast as the network allows" is not a strategy for a film: the +// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a +// ten-megabit second fills the ceiling in the first minute. Buffering by time +// rather than by bytes keeps a two-hour film and a two-minute clip alike. +const BUFFER_AHEAD_S = 90; +// While we deliberately hold credit back, the node must still hear from us: its +// own stall timeout is two minutes, and a paused film is not a gone viewer. +const CREDIT_KEEPALIVE_MS = 20000; +// Segments allowed in flight while there is room to put them. This is a window, +// topped up as segments land, and not a debt released in one go: accumulating a +// credit per append and handing the lot over when the buffer finally had room +// sent 6 MB in a burst, overshot the target by a minute of film, and then said +// nothing for the next forty-six seconds. Measured in Chrome against real +// fragmented MP4. A stream that arrives in gulps has no margin for a network +// that hesitates, and looks like a hang while it is quiet. +const STREAM_WINDOW = 8; +// Dragging the scrubber fires `seeking` continuously, and every seek we act on +// kills an ffmpeg and spawns another. Only where the finger stops is worth a +// restart. +const SEEK_DEBOUNCE_MS = 350; +// A position is remembered per file, in this browser. Below the first threshold +// there is nothing to resume; above the second the film is finished and +// offering to resume thirty seconds before the credits is a nuisance. +const RESUME_MIN_S = 30; +const RESUME_MAX_FRACTION = 0.97; +const QUEUE_HIGH_WATER = 12; + +function _mseSupported(codec) { + if (!window.MediaSource) return false; + const mime = `video/mp4; codecs="${codec}"`; + return MediaSource.isTypeSupported(mime); +} + +/** Seconds as h:mm:ss, or m:ss under an hour. */ +function formatClock(seconds) { + const s = Math.max(0, Math.floor(seconds || 0)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = String(s % 60).padStart(2, '0'); + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`; +} + +/** + * Where *this account on this device* last left off in a given file. + * + * localStorage rather than the node: it needs no protocol, no storage anyone + * else has to keep, and nothing new learns what you watch. The cost is that + * the position does not follow you from the laptop to the phone. + * + * The account has to be in the key. Without it the position is per *device* — + * so a second person signing in on the same machine was offered "resume where + * you left off" in a film they had never opened, which is both wrong and a + * small disclosure of what someone else watches. Found by signing in with a + * fresh account and being offered a resume point. + */ +function resumeKey(fileId) { + const auth = loadAuth(); + return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null; +} + +function readResumePosition(fileId) { + try { + const key = resumeKey(fileId); + if (!key) return 0; + const raw = localStorage.getItem(key); + const at = raw ? parseFloat(raw) : 0; + return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0; + } catch { + return 0; // private browsing, or storage disabled + } +} + +function writeResumePosition(fileId, at, duration) { + try { + const key = resumeKey(fileId); + if (!key) return; + if (!Number.isFinite(at) || at < RESUME_MIN_S + || (duration && at > duration * RESUME_MAX_FRACTION)) { + localStorage.removeItem(key); + return; + } + localStorage.setItem(key, String(Math.floor(at))); + } catch { /* nothing to be done, and nothing worth failing over */ } +} + +/** + * Drop the positions written before they were scoped to an account. + * + * Re-keying them is not possible — there is no record of whose they were, and + * guessing would hand them to whoever signs in next, which is the bug. They go. + */ +function purgeUnscopedResumePositions() { + try { + const stale = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + // `mb:pos:<file>` is the old shape; `mb:pos:<user>:<file>` is current. + if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) { + stale.push(key); + } + } + stale.forEach((key) => localStorage.removeItem(key)); + } catch { /* storage disabled: nothing was written either */ } +} + +purgeUnscopedResumePositions(); + +function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { + const [dlBusy, setDlBusy] = useState(false); + const [phase, setPhase] = useState('loading'); + const [error, setError] = useState(''); + const videoRef = useRef(null); + const msRef = useRef(null); + const sbRef = useRef(null); + const blobUrlRef = useRef(null); + const queueRef = useRef([]); + const appendingRef = useRef(false); + const endedRef = useRef(false); + const durationRef = useRef(0); + // Segments the node is allowed to have in flight but has not sent yet, and + // when we last said anything to it at all. + const outstandingRef = useRef(0); + const lastPokeRef = useRef(0); + // Diagnostics reported to the node: how many appends the browser refused for + // want of room, and whether the element itself says it is starved. + const quotaRef = useRef(0); + const stalledRef = useRef(false); + // Seeking. `awaitingInit` is true from the moment we ask the node to restart + // somewhere else until its new `stream_init` arrives: the channel is ordered, + // so everything in between belongs to the stream we just abandoned and would + // otherwise be appended on top of the new one. `seekTarget` is where to put + // the playhead once the buffer actually covers it. + const awaitingInitRef = useRef(false); + const seekTargetRef = useRef(null); + const seekTimerRef = useRef(null); + // The seek is built inside the effect, where the transport and `cancelled` + // live; the render needs to reach it for "start from the beginning". + const requestSeekRef = useRef(null); + const [resumedFrom, setResumedFrom] = useState(0); + const [castActive, setCastActive] = useState(false); + const [castUrl, setCastUrl] = useState(null); + const [castPickerOpen, setCastPickerOpen] = useState(false); + const [castDevices, setCastDevices] = useState([]); + const [castScanning, setCastScanning] = useState(false); + const [castDeviceName, setCastDeviceName] = useState(null); + const castActiveRef = useRef(false); + const castCodecRef = useRef(null); + const initSegmentRef = useRef(null); + const castRestartPendingRef = useRef(false); + const castDeviceRef = useRef(null); + const castRestartGenRef = useRef(0); + const landingPlayheadRef = useRef(false); + + /** + * The buffered range the playhead is actually in, or null. + * + * Seeking makes the buffer discontinuous, and "the last range" stops meaning + * "the one being watched" the moment there is more than one: measuring the + * read-ahead against a range on the far side of a gap reports a full buffer + * while the player starves. + */ + const currentRange = useCallback(() => { + const sb = sbRef.current; + const v = videoRef.current; + if (!sb || !v) return null; + try { + const t = v.currentTime; + for (let i = 0; i < sb.buffered.length; i++) { + // Half a second of slack: the playhead sits exactly on a boundary + // often enough, and a strict test there reports nothing buffered. + if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) { + return [sb.buffered.start(i), sb.buffered.end(i)]; + } + } + } catch { /* the SourceBuffer went away under us */ } + return null; + }, []); + + /** + * Drop what has already been watched. + * + * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes + * and refuse the append that goes past. Keeping a minute behind the playhead + * is enough for a small seek backwards and bounded for a three-hour film. + */ + const evictBehind = useCallback(() => { + const sb = sbRef.current; + const v = videoRef.current; + if (!sb || !v || sb.updating || !sb.buffered.length) return false; + const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S); + // The range being watched, not the first one: after a seek backwards the + // first range is somewhere else entirely, and removing from its start to + // just behind the playhead would take out everything in between — + // including what is playing. + const range = currentRange(); + const start = range ? range[0] : sb.buffered.start(0); + if (keepFrom - start < 10) return false; + try { + sb.remove(start, keepFrom); + return true; + } catch { + return false; + } + }, [currentRange]); + + /** Seconds of film held past the playhead. */ + const bufferedAhead = useCallback(() => { + const v = videoRef.current; + const range = currentRange(); + if (!v || !range) return 0; + return Math.max(0, range[1] - v.currentTime); + }, [currentRange]); + + const flushQueue = useCallback(() => { + const sb = sbRef.current; + if (!sb || appendingRef.current || sb.updating) return; + if (queueRef.current.length === 0) { + if (endedRef.current && msRef.current?.readyState === 'open') { + try { msRef.current.endOfStream(); } catch {} + } + return; + } + appendingRef.current = true; + const chunk = queueRef.current[0]; + try { + sb.appendBuffer(chunk); + queueRef.current.shift(); + } catch (e) { + appendingRef.current = false; + if (e.name === 'QuotaExceededError') { + quotaRef.current += 1; + // The segment stays at the head of the queue and is tried again once + // there is room. Dropping it — which is what this used to do — leaves a + // hole in the middle of the film and no error anywhere. + if (!evictBehind()) { + console.warn('[MSE] buffer full and nothing to evict yet'); + } + return; + } + queueRef.current.shift(); + console.error('[MSE] appendBuffer error:', e); + } + }, [evictBehind]); + + /** + * Decide whether the node may send more, and keep the pipeline moving. + * + * This is the only place credit is granted, and the only thing that can + * restart a pipeline the buffer ceiling has stopped. That second job is why + * it exists: an append refused for quota fires no `updateend`, so it grants + * no credit, so the node sends nothing, so no segment arrives to call + * `flushQueue` again. Every wakeup the append path had was downstream of the + * append that just failed — the player deadlocked against itself and sat on + * "buffering" for good, which is what a 500 MB film did at around 100 MB. + * + * So the clock drives this, not the data. + */ + const pump = useCallback(() => { + if (awaitingInitRef.current) return; + const transport = transportRef.current; + evictBehind(); + flushQueue(); + if (endedRef.current && queueRef.current.length === 0) return; + if (!transport || !transport.connected) return; + + if (bufferedAhead() > BUFFER_AHEAD_S + || queueRef.current.length > QUEUE_HIGH_WATER) { + // Far enough ahead. Grant nothing, but do not go silent: two minutes of + // silence is how the node decides nobody is watching, and pausing a film + // for two minutes is an ordinary thing to do. + const now = Date.now(); + if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) { + lastPokeRef.current = now; + transport.grantStreamCredit(0); + } + return; + } + + // Top the window back up to what is allowed in flight, rather than paying + // off everything owed at once. Called on every arriving segment as well as + // on the clock, so credit trickles out as room appears instead of being + // released in one gulp when the buffer finally drains. + const room = STREAM_WINDOW - outstandingRef.current; + if (room > 0) { + outstandingRef.current += room; + lastPokeRef.current = Date.now(); + transport.grantStreamCredit(room); + } + }, [evictBehind, flushQueue, bufferedAhead]); + + useEffect(() => { + let cancelled = false; + // Reset here, not in the teardown of the run before: switching video while + // an append was in flight left `appendingRef` true, and flushQueue bails + // out on it. The new SourceBuffer then never appended anything, so no + // `updateend` ever cleared the flag, no credit went back to the node, and + // the player sat on "buffering" for good. `endedRef` surviving is the same + // shape of bug — the next stream would call endOfStream() the first time + // its queue ran dry and truncate the film. + appendingRef.current = false; + endedRef.current = false; + queueRef.current = []; + outstandingRef.current = 0; + lastPokeRef.current = Date.now(); + quotaRef.current = 0; + stalledRef.current = false; + // The same shape again, and the seek refs are worse than the others. + // Switching film while a seek was in flight leaves `awaitingInit` true, + // and only reinitAt() ever lowers it — which the next film does not go + // through, because it builds a new SourceBuffer. Every segment of the new + // film is then dropped as though it belonged to the one we left, for good. + // A stale `seekTarget` is milder: the new film jumps to a position from + // the old one the moment that much is buffered. + awaitingInitRef.current = false; + seekTargetRef.current = null; + clearTimeout(seekTimerRef.current); + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + return; + } + + const onStarved = () => { stalledRef.current = true; pump(); }; + const onFed = () => { stalledRef.current = false; }; + + /** The buffered ranges, short enough for a log line. */ + const describeRanges = () => { + const sb = sbRef.current; + if (!sb) return '(no buffer)'; + try { + let s = ''; + for (let i = 0; i < sb.buffered.length; i++) { + s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `; + } + return s.trim() || '(empty)'; + } catch { + return '?'; + } + }; + + /** + * Ask the node to restart the film somewhere else. + * + * Debounced, because dragging the scrubber fires `seeking` continuously and + * each request kills an ffmpeg and spawns another. Only the position the + * finger stops on is worth acting on. + */ + const requestSeek = (target) => { + clearTimeout(seekTimerRef.current); + seekTimerRef.current = setTimeout(() => { + const t = transportRef.current; + if (cancelled || !t || !t.connected) return; + // Everything arriving from here until the new `stream_init` belongs to + // the stream being abandoned. The channel is ordered, so this flag is + // enough to tell them apart without a sequence number in the protocol. + // Rare enough to report every time, and the node logs it at INFO. A + // seek nobody asked for is the kind of thing only this line can show: + // from the node's side it is indistinguishable from a viewer dragging + // the scrubber. + t.sendStreamDiag({ + event: 'seek', target: +target.toFixed(1), + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + ready: videoRef.current ? videoRef.current.readyState : null, + offset: sbRef.current ? sbRef.current.timestampOffset : null, + ranges: describeRanges(), + }); + awaitingInitRef.current = true; + seekTargetRef.current = target; + outstandingRef.current = STREAM_WINDOW; + setPhase('loading'); + console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW); + t.requestStream(entry.id, STREAM_WINDOW, target); + }, SEEK_DEBOUNCE_MS); + }; + requestSeekRef.current = requestSeek; + + /** + * Move the playhead onto a seek once the data for it has arrived. + * + * Setting `currentTime` into a region that is not buffered yet leaves the + * element waiting with nothing to show, and on a seek backwards it would + * be overwritten by the playhead the browser restores. So the position is + * remembered and applied on the first append that actually covers it. + */ + const landPlayhead = () => { + const target = seekTargetRef.current; + const v = videoRef.current, sb = sbRef.current; + if (target === null || !v || !sb) return; + try { + for (let i = 0; i < sb.buffered.length; i++) { + const a = sb.buffered.start(i), b = sb.buffered.end(i); + if (target >= a - 1 && target < b) { + seekTargetRef.current = null; + // ffmpeg lands on the keyframe at or before what we asked for, so + // the range can begin slightly later than the target; never seek + // behind what is actually there. + if (Math.abs(v.currentTime - target) > 0.5) { + landingPlayheadRef.current = true; + v.currentTime = Math.max(target, a); + } + v.play().catch(() => {}); + return; + } + } + } catch { /* the SourceBuffer went away */ } + }; + + /** Wait for whatever the SourceBuffer is doing to finish. */ + const settled = (sb) => new Promise((resolve) => { + if (!sb.updating) return resolve(); + sb.addEventListener('updateend', resolve, { once: true }); + }); + + /** + * Put the SourceBuffer back to an empty state that starts at `start`. + * + * Everything buffered is dropped rather than kept alongside the new + * material. A discontinuous buffer is legal and every piece of code that + * reads `buffered` then has to reason about which range it means — the + * eviction, the read-ahead, the seek test — for the sake of a few + * megabytes of film the viewer has just navigated away from. + * + * `abort()` first: ffmpeg was killed mid-fragment, so the parser is + * holding half of one, and appending the next stream's header on top of + * that is a decode error. + */ + const reinitAt = async (start) => { + const sb = sbRef.current; + if (!sb) return; + try { sb.abort(); } catch { /* not in a state that needs it */ } + await settled(sb); + try { + sb.remove(0, Infinity); + await settled(sb); + } catch { /* nothing buffered */ } + // ffmpeg restarts its timestamps at zero however far in we asked it to + // seek, so this is what puts the fragments back on the film's timeline. + try { sb.timestampOffset = start; } catch { /* older browsers */ } + const tr = transportRef.current; + if (tr) { + tr.sendStreamDiag({ + event: 'reinit', target: start, offset: sb.timestampOffset, + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + ranges: describeRanges(), + }); + } + queueRef.current = []; + appendingRef.current = false; + endedRef.current = false; + quotaRef.current = 0; + awaitingInitRef.current = false; + seekTargetRef.current = start; + console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length); + setPhase('streaming'); + pump(); + }; + + const onSeeking = () => { + if (landingPlayheadRef.current) { + landingPlayheadRef.current = false; + return; + } + const v = videoRef.current; + if (!v || cancelled) return; + const target = v.currentTime; + // Inside what is buffered, the browser handles it and the node need not + // hear about it at all — unless a cast is active, because the relay + // cannot seek within its HTTP stream and must be restarted. + if (!castActiveRef.current) { + const sb = sbRef.current; + if (sb) { + try { + for (let i = 0; i < sb.buffered.length; i++) { + if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) { + return; + } + } + } catch { /* fall through and ask the node */ } + } + } + requestSeek(target); + }; + + const startStream = async () => { + transport.onStreamError = (msg) => { + if (cancelled) return; + // Say what the node said. Sitting on "buffering" with the reason + // already delivered is the worst of both. + setError(msg.detail || t('video.err_transport')); + setPhase('error'); + }; + + transport.onStreamInit = (msg) => { + if (cancelled) return; + if (msg.file_id && msg.file_id !== entry.id) return; + const mime = `video/mp4; codecs="${msg.codec}"`; + castCodecRef.current = msg.codec; + + if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { + setError(t('video.err_mse', { codec: msg.codec })); + setPhase('error'); + return; + } + + durationRef.current = msg.duration || 0; + + // A second init on a live SourceBuffer is a seek landing, not a new + // film. Reuse what is there: rebuilding the MediaSource would reset the + // element's src, blank the picture and throw away the duration the + // scrubber is drawn from. + if (sbRef.current && msRef.current + && msRef.current.readyState === 'open') { + console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current); + initSegmentRef.current = null; + if (castActiveRef.current && platform.cast.available) { + platform.cast.stop().catch(() => {}); + castRestartPendingRef.current = true; + } + reinitAt(msg.start || 0).catch(() => { + setError(t('video.err_transport')); + setPhase('error'); + }); + return; + } + + // If we reach here during a seek (readyState was 'ended' after the + // previous stream finished), the seek-landing path above could not run. + // A fresh MediaSource is needed, but the seek state must still be reset + // or awaitingInit stays true and every segment is dropped forever. + awaitingInitRef.current = false; + endedRef.current = false; + appendingRef.current = false; + queueRef.current = []; + sbRef.current = null; + initSegmentRef.current = null; + if (castActiveRef.current && platform.cast.available) { + platform.cast.stop().catch(() => {}); + castRestartPendingRef.current = true; + } + + const ms = new MediaSource(); + msRef.current = ms; + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + const url = URL.createObjectURL(ms); + blobUrlRef.current = url; + + ms.addEventListener('sourceopen', () => { + if (cancelled) return; + if (durationRef.current > 0) { + ms.duration = durationRef.current; + } + const sb = ms.addSourceBuffer(mime); + sbRef.current = sb; + // 'segments', not 'sequence': the fragments must land where they + // belong on the film's timeline rather than one after another, or a + // stream that started at 40 minutes would be buffered at zero and + // the scrubber would lie about everything. + sb.mode = 'segments'; + try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ } + if (msg.start) seekTargetRef.current = msg.start; + transport.sendStreamDiag({ + event: 'first-init', target: msg.start || 0, + offset: sb.timestampOffset, duration: durationRef.current, + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + }); + sb.addEventListener('updateend', () => { + // No credit is granted here, deliberately. Appending is not the + // same question as having room, and tying the two meant `remove()` + // — which fires this event too — paid the node for the player's own + // evictions. What may be in flight is decided from the buffer, in + // pump(), and nowhere else. + appendingRef.current = false; + landPlayhead(); + pump(); + }); + setPhase('streaming'); + flushQueue(); + }); + + if (videoRef.current) { + videoRef.current.src = url; + videoRef.current.addEventListener('seeking', onSeeking); + videoRef.current.addEventListener('timeupdate', pump); + // The element's own verdict. "buffering" on screen is this, and it + // is the one thing the node cannot infer from a stream it is feeding. + videoRef.current.addEventListener('waiting', onStarved); + videoRef.current.addEventListener('stalled', onStarved); + videoRef.current.addEventListener('playing', onFed); + videoRef.current.addEventListener('canplay', onFed); + } + }; + + transport.onStreamData = async (msg) => { + if (cancelled) return; + // A segment arrived, so it is no longer in flight — whatever we go on + // to do with it. This has to come before every early return below, and + // it did not: skipping the count for segments we discard leaks a slot + // out of the window each time, and the window never grows back. + // + // `reinitAt` is asynchronous — it waits for two `updateend` events — + // and a seek's first segments arrive during that gap and are dropped + // by the flag below. Lose all eight and the player believes a full + // window is in flight, grants nothing ever again, and the node waits + // for credit that cannot come. A race, which is why the same seek + // worked twice and hung on the third. + outstandingRef.current = Math.max(0, outstandingRef.current - 1); + if (awaitingInitRef.current) { + console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current); + } + // Between asking for a seek and its `stream_init`, everything on the + // channel is the film we just left. Same file, so `file_id` cannot + // tell them apart — ordering can. + if (awaitingInitRef.current) return; + // Late segments from the stream we just left. The DataChannel is + // ordered, so they arrive before the new stream's first segment and + // would otherwise be decrypted against the wrong file — which fails, + // loudly, in the console, for something that is simply not ours. + if (msg.file_id && msg.file_id !== entry.id) return; + try { + const plaintext = await window.MeshBayCrypto.decryptChunkBin( + gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct); + if (initSegmentRef.current === null) { + initSegmentRef.current = plaintext; + if (castRestartPendingRef.current && castActiveRef.current + && platform.cast.available) { + castRestartPendingRef.current = false; + const gen = ++castRestartGenRef.current; + const device = castDeviceRef.current; + platform.cast.start({ + codec: castCodecRef.current, + initSegment: plaintext, + }).then(async (result) => { + if (castRestartGenRef.current !== gen) return; + if (!result) return; + setCastUrl(result.url); + const status = await platform.cast.status(); + if (status && status.chromecast && status.chromecast.connected) { + await platform.cast.chromecastReload({ mediaUrl: result.url }); + } else if (device) { + await platform.cast.chromecastConnect({ + deviceId: device.id, mediaUrl: result.url, + }); + setCastDeviceName(device.name); + } else { + navigator.clipboard.writeText(result.url).catch(() => {}); + } + }).catch((err) => { + if (castRestartGenRef.current !== gen) return; + console.error('[cast] restart failed:', err); + platform.cast.stop().catch(() => {}); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); setCastDeviceName(null); + }); + } + } + if (castActiveRef.current && !castRestartPendingRef.current + && platform.cast.available) { + platform.cast.push(plaintext).catch(() => {}); + } + queueRef.current.push(plaintext); + // pump(), not flushQueue(): arriving data is the moment to top the + // window back up, and that is what keeps the stream continuous. + pump(); + } catch (e) { + console.error('[MSE] decrypt error:', e); + } + }; + + transport.onStreamEnd = (msg) => { + if (cancelled) return; + // The end of the previous film is not the end of this one. + if (msg && msg.file_id && msg.file_id !== entry.id) return; + // Nor is the end of the stream we abandoned by seeking: taking it + // would call endOfStream() and truncate the film at the seek point. + if (awaitingInitRef.current) return; + endedRef.current = true; + if (castActiveRef.current && platform.cast.available) { + platform.cast.finish().catch(() => {}); + } + flushQueue(); + }; + + // The opening window, and the count that tracks it. Asking for more here + // than pump() maintains would leave the node holding credit this side + // does not know about, which is the whole window's worth of overshoot on + // the very first breath of the stream. + outstandingRef.current = STREAM_WINDOW; + const resumeAt = readResumePosition(entry.id); + if (resumeAt) setResumedFrom(resumeAt); + transport.requestStream(entry.id, STREAM_WINDOW, resumeAt); + }; + + // Closing the tab, or backgrounding it on a phone, never runs a React + // cleanup — so the node hears nothing and keeps transcoding. `pagehide` + // fires in both cases and is the one event mobile browsers honour on the + // way out; `visibilitychange` covers switching apps. The node stops the + // stream by itself when the connection drops, but that costs a round of + // detection, and this message is a single datagram already in flight. + const leave = (why) => { + const t = transportRef.current; + console.log('[MeshBay] stopStream:', why); + if (t && t.connected) t.stopStream(); + }; + const onPageHide = () => leave('pagehide'); + // NOT wired to stopStream. Android fires visibilitychange when a video goes + // fullscreen, so cutting the stream here killed the film the moment it was + // watched properly. Logged only, until that is confirmed or ruled out. + const onVisibility = () => { + console.log('[MeshBay] visibilitychange:', document.visibilityState); + }; + window.addEventListener('pagehide', onPageHide); + document.addEventListener('visibilitychange', onVisibility); + + // `timeupdate` is silent while the film is paused, and the append path + // cannot wake itself once the ceiling has refused a segment. This is the + // clock that guarantees something is still driving the pipeline. + const pumpTimer = setInterval(pump, 1000); + + // What the player sees, into the node's log. A hang on a phone shows the + // node feeding a stream quite happily; the half that says otherwise is in + // here, and there is no console to read it from. + const diagTimer = setInterval(() => { + const v = videoRef.current, sb = sbRef.current; + const t = transportRef.current; + if (!t || !v) return; + // Cheap, and the only thing that makes "resume where I stopped" work + // when the tab is closed rather than the player. + if (!v.paused) { + writeResumePosition(entry.id, v.currentTime, durationRef.current); + } + let ranges = ''; + try { + for (let i = 0; sb && i < sb.buffered.length; i++) { + ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `; + } + } catch { ranges = '?'; } + t.sendStreamDiag({ + t: +v.currentTime.toFixed(1), + ahead: +bufferedAhead().toFixed(1), + ranges: ranges.trim(), + ready: v.readyState, // 0 = nothing, 4 = enough to play through + paused: v.paused, + stalled: stalledRef.current, + q: queueRef.current.length, + inflight: outstandingRef.current, + appending: appendingRef.current, + updating: sb ? sb.updating : null, + quota: quotaRef.current, + ms: msRef.current ? msRef.current.readyState : null, + err: v.error ? `${v.error.code}:${v.error.message}` : null, + }); + }, 5000); + + startStream().catch(err => { + if (!cancelled) { setError(err.message); setPhase('error'); } + }); + + return () => { + cancelled = true; + clearInterval(pumpTimer); + clearInterval(diagTimer); + clearTimeout(seekTimerRef.current); + // Closing the player is the commonest way to stop watching, so this is + // the write that matters most. + if (videoRef.current) { + writeResumePosition(entry.id, videoRef.current.currentTime, + durationRef.current); + } + if (castActiveRef.current && platform.cast.available) { + platform.cast.chromecastDisconnect().catch(() => {}); + platform.cast.stop().catch(() => {}); + castActiveRef.current = false; + } + window.removeEventListener('pagehide', onPageHide); + document.removeEventListener('visibilitychange', onVisibility); + if (videoRef.current) { + videoRef.current.removeEventListener('seeking', onSeeking); + videoRef.current.removeEventListener('timeupdate', pump); + videoRef.current.removeEventListener('waiting', onStarved); + videoRef.current.removeEventListener('stalled', onStarved); + videoRef.current.removeEventListener('playing', onFed); + videoRef.current.removeEventListener('canplay', onFed); + } + if (transport) { + // Tell the node first: dropping the handlers only makes us deaf, and a + // stream nobody is listening to still occupies a transcode slot. + transport.stopStream(); + transport.onStreamInit = null; + transport.onStreamData = null; + transport.onStreamEnd = null; + transport.onStreamError = null; + } + // The queue can hold several megabytes of decrypted video. + queueRef.current = []; + const ms = msRef.current; + if (ms && ms.readyState === 'open') { + try { ms.endOfStream(); } catch { /* already ended */ } + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + sbRef.current = null; + msRef.current = null; + }; + }, [entry, flushQueue, pump]); + + useEffect(() => { + if (phase === 'streaming' && videoRef.current) { + videoRef.current.play().catch(() => {}); + } + }, [phase]); + + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${platform.capabilities.lanCast && html` + <div class="cast-wrapper" style="position:relative"> + <button class="video-close ${castActive ? 'cast-active' : ''}" + onClick=${async () => { + if (castActive) { + await platform.cast.chromecastDisconnect().catch(() => {}); + await platform.cast.stop(); + setCastActive(false); castActiveRef.current = false; + setCastUrl(null); + setCastDeviceName(null); + castDeviceRef.current = null; + } else if (castCodecRef.current && initSegmentRef.current) { + if (castPickerOpen) { + setCastPickerOpen(false); + } else { + setCastPickerOpen(true); + setCastScanning(true); + setCastDevices([]); + platform.cast.discover().then((devices) => { + setCastDevices(devices || []); + setCastScanning(false); + }).catch(() => setCastScanning(false)); + } + } + }} + title="${castActive ? t('cast.stop') : t('cast.start')}"> + <${Icon} name="cast" /></button> + ${castPickerOpen && html` + <div class="cast-picker"> + ${castScanning && html` + <div class="cast-picker-item cast-picker-scanning"> + <span class="spinner" style="width:14px;height:14px"></span> + ${t('cast.scanning')} + </div> + `} + ${castDevices.map(d => html` + <button class="cast-picker-item" onClick=${() => { + setCastPickerOpen(false); + setCastDeviceName(d.name); + setCastActive(true); castActiveRef.current = true; + castRestartPendingRef.current = true; + castDeviceRef.current = d; + if (videoRef.current && requestSeekRef.current) { + requestSeekRef.current(videoRef.current.currentTime); + } + }}> + <${Icon} name="cast" /> ${d.name} + </button> + `)} + ${!castScanning && castDevices.length === 0 && html` + <div class="cast-picker-item cast-picker-empty"> + ${t('cast.no_devices')} + </div> + `} + <div class="cast-picker-sep"></div> + <button class="cast-picker-item" onClick=${async () => { + setCastPickerOpen(false); + setCastActive(true); castActiveRef.current = true; + castRestartPendingRef.current = true; + if (videoRef.current && requestSeekRef.current) { + requestSeekRef.current(videoRef.current.currentTime); + } + }}> + <${Icon} name="clip" /> ${t('cast.copy_url')} + </button> + </div> + `} + </div> + `} + ${castUrl && html` + <span class="cast-status-label"> + ${castDeviceName + ? castDeviceName + : html`<input class="cast-url-input" readOnly value=${castUrl} + onClick=${(e) => { + e.target.select(); + navigator.clipboard.writeText(castUrl).catch(() => {}); + }} + title="${t('cast.copy_url')}" />` + } + </span> + `} + ${onDownload && html` + <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy} + onClick=${() => { + if (!dlBusy) { + setDlBusy(true); + onDownload(); + setTimeout(() => setDlBusy(false), 1500); + } + }} + title="${t('group.download')}"> + ${dlBusy + ? html`<span class="spinner"></span>` + : html`<${Icon} name="download" />`}</button> + `} + <button class="video-close" onClick=${onClose} title="${t('video.close')}"> + <${Icon} name="close" /></button> + </div> + + ${phase === 'loading' && html` + <div class="video-loading"> + <div class="video-loading-label"> + <span class="spinner"></span>${' '}${t('video.buffering')} + </div> + </div> + `} + + ${(phase === 'streaming' || phase === 'loading') && html` + <div class="video-container"> + <video ref=${videoRef} controls autoplay /> + ${resumedFrom > 0 && html` + <div class="video-resumed"> + ${t('video.resumed_at', { time: formatClock(resumedFrom) })} + <button class="linklike" onClick=${() => { + setResumedFrom(0); + writeResumePosition(entry.id, 0, durationRef.current); + if (requestSeekRef.current) requestSeekRef.current(0); + }}>${t('video.from_start')}</button> + </div> + `} + </div> + `} + + ${phase === 'error' && html` + <div class="video-error">${error}</div> + `} + </div> + `; +} + +// ── Search Page (cross-group file search) ─────────────────────────────────── + +/** + * "3 hours ago", in the reader's language. + * + * The search page needs it because its results come from a cache: a file that + * was deleted an hour ago is still listed until the group is opened again, and + * the honest thing is to say how old the answer is rather than to imply it is + * live. + */ + +export { VideoPlayer }; diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py index 73ff0f1..46d8357 100644 --- a/packages/meshbay-hub/tests/harness/scroll_probe.py +++ b/packages/meshbay-hub/tests/harness/scroll_probe.py @@ -28,7 +28,9 @@ import time from pathlib import Path STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" -APP = STATIC / "app.js" +# fit() is ChatPanel's own viewport-sizing logic, moved to chat-app.js in the +# group-page refactor. +APP = STATIC / "chat-app.js" PORT = 8736 FRAG = Path(sys.argv[1]).read_text() HEIGHTS = ([int(h) for h in sys.argv[2].split(",")] diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs index 3be862e..0334813 100644 --- a/packages/meshbay-hub/tests/harness/session_harness.mjs +++ b/packages/meshbay-hub/tests/harness/session_harness.mjs @@ -24,8 +24,8 @@ const between = (from, to) => { if (j < 0) throw new Error(`not found: ${to}`); return app.slice(i, j); }; -const sessionBlock = between('let _auth = loadAuth();', '\n// ── Theme'); -const hubFetchFn = between('async function hubFetch(', '\n// ── Router'); +const sessionBlock = between('let _auth = loadAuth();', '\n// ── Hub API'); +const hubFetchFn = between('async function hubFetch(', '\nexport {'); // ── The world ──────────────────────────────────────────────────────────────── const store = new Map(); diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index 85bf488..80c3b05 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -145,7 +145,9 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): src = SW.read_text() assert "if (entry.size > 0)" in src - app = (STATIC / "app.js").read_text() + # The zip-directory download is Files' own, moved to files-app.js in the + # group-page refactor. + app = (STATIC / "files-app.js").read_text() zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):] zip_call = zip_call[:zip_call.index(");") + 2] assert zip_call.rstrip().endswith(", 0);"), ( diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index cd9a11e..dac1357 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -26,6 +26,17 @@ import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APP = STATIC / "app.js" +# One monolithic app.js used to hold every component; the group-page refactor +# split it into one file per "application" (chat-app.js, files-app.js, +# video-player.js, group-settings.js) plus the group shell (group-page.js). +# A future Videos/Music/Photos app lands in its own file the same way — add it +# here so this test keeps seeing it, since `_all_components` below only walks +# the files named in this list. +STATIC_FILES = [ + "app.js", "group-page.js", "chat-app.js", "files-app.js", + "video-player.js", "group-settings.js", +] + pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") # `const NAME = useCallback(` / `useMemo(` — the declarations that both define a @@ -40,18 +51,28 @@ def app(): return APP.read_text() -def _components(app: str): - """Each top-level component, with the offset it starts at.""" - for m in re.finditer(r"^function ([A-Z]\w*)\(", app, re.M): +def _components(src: str): + """Each top-level component in one file, with the offset it starts at.""" + for m in re.finditer(r"^function ([A-Z]\w*)\(", src, re.M): start = m.start() - nxt = app.find("\nfunction ", start + 1) - yield m.group(1), app[start:nxt if nxt > 0 else len(app)] + nxt = src.find("\nfunction ", start + 1) + yield m.group(1), src[start:nxt if nxt > 0 else len(src)] + + +def _all_components(): + """Every top-level component across every static file that can hold one.""" + for name in STATIC_FILES: + path = STATIC / name + if not path.exists(): + continue + for cname, body in _components(path.read_text()): + yield f"{name}:{cname}", body -def test_no_hook_depends_on_something_declared_below_it(app): - """The whole file, not just the player that was broken by it.""" +def test_no_hook_depends_on_something_declared_below_it(): + """Every static file that can hold a component, not just app.js.""" problems = [] - for name, body in _components(app): + for name, body in _all_components(): # Where each hook binding becomes usable. declared_at = {m.group(1): m.start() for m in DECL.finditer(body)} for deps in DEPS.finditer(body): @@ -68,13 +89,13 @@ def test_no_hook_depends_on_something_declared_below_it(app): + "\n ".join(problems)) -def test_the_check_would_notice(app): +def test_the_check_would_notice(): """A test that cannot fail proves nothing — so make it fail on purpose. Swaps two declarations in the real file and confirms the rule fires. If this stops working the rule above has quietly become decoration. """ - body = next(b for n, b in _components(app) if n == "VideoPlayer") + body = next(b for n, b in _all_components() if n == "video-player.js:VideoPlayer") decls = list(DECL.finditer(body)) assert len(decls) >= 2, "VideoPlayer has too few hooks to test the check" diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py index 0e2444c..11a6d05 100644 --- a/packages/meshbay-hub/tests/test_layout_responsive.py +++ b/packages/meshbay-hub/tests/test_layout_responsive.py @@ -112,7 +112,9 @@ def test_the_toolbar_still_fits_a_360px_screen(css): # ── The chat panel's height ─────────────────────────────────────────────────── -APP = STATIC / "app.js" +# fit() and its constants are ChatPanel's own viewport-sizing logic, moved to +# chat-app.js in the group-page refactor. +APP = STATIC / "chat-app.js" @pytest.fixture(scope="module") diff --git a/packages/meshbay-hub/tests/test_resume_position.py b/packages/meshbay-hub/tests/test_resume_position.py index ef239c5..07ac23a 100644 --- a/packages/meshbay-hub/tests/test_resume_position.py +++ b/packages/meshbay-hub/tests/test_resume_position.py @@ -21,7 +21,7 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -APP = STATIC / "app.js" +APP = STATIC / "video-player.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not APP.exists(), diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py index 6c47f7a..a839f1e 100644 --- a/packages/meshbay-hub/tests/test_session_renewal.py +++ b/packages/meshbay-hub/tests/test_session_renewal.py @@ -34,7 +34,12 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -APP = STATIC / "app.js" +APP = STATIC / "hub-client.js" +# The session/token machinery lives in hub-client.js (APP, above); the group +# shell's own WebRTC-connect effect that consumes it is in group-page.js; the +# periodic re-check that catches a backgrounded tab is App() in app.js. +GROUP_PAGE = STATIC / "group-page.js" +APP_JS = STATIC / "app.js" HARNESS = Path(__file__).parent / "harness" / "session_harness.mjs" pytestmark = pytest.mark.skipif( @@ -182,7 +187,7 @@ def test_renewal_happens_before_expiry_not_after(): assert margin >= 300, ( f"{margin} s of margin against a one-hour token is thin: a backgrounded " "tab has its timers throttled and may not check for minutes") - assert "visibilitychange" in src, ( + assert "visibilitychange" in APP_JS.read_text(), ( "nothing re-checks when the tab comes back, which is exactly when the " "token is most likely to have aged out unnoticed") @@ -203,7 +208,7 @@ def test_renewing_does_not_tear_down_the_webrtc_connection(): Signing in or out must still re-run it, so the dependency is whether there is a token, not which one. """ - src = APP.read_text() + src = GROUP_PAGE.read_text() i = src.index("means tearing down the WebRTC connection") deps = src[i:src.index(");", i)] assert "Boolean(token)" in deps, ( @@ -218,7 +223,7 @@ def test_the_connection_signs_its_offer_with_a_live_token(): It signs the offer relayed through the hub, where an expired one is a 401 and no connection at all. """ - src = APP.read_text() + src = GROUP_PAGE.read_text() connect = src[src.index("const connect = async () => {"):] connect = connect[:connect.index("\n };")] assert "await ensureFreshToken()" in connect, ( diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index dfc92f9..d00b9a0 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -113,14 +113,26 @@ def test_the_ack_still_verifies_the_announced_node_key(): # Users tab referencing `doInvite`, `members` and `adminId` — none of which are # defined there. +# The group-page refactor split what used to be one app.js into one file per +# "application" (chat-app.js, files-app.js, video-player.js) plus +# group-settings.js and the group shell itself, group-page.js. AdminPage and +# App() stayed in app.js. `_component` below is told which file to read a +# given top-level component from. APP = STATIC / "app.js" +COMPONENT_FILES = { + "GroupSettingsPanel": STATIC / "group-settings.js", + "GroupPage": STATIC / "group-page.js", + "ChatPanel": STATIC / "chat-app.js", + "FilesPanel": STATIC / "files-app.js", + "VideoPlayer": STATIC / "video-player.js", +} def _component(name: str) -> str: """The source of one top-level `function Name(...)`, up to the next one.""" - source = APP.read_text() + source = COMPONENT_FILES.get(name, APP).read_text() start = source.find(f"\nfunction {name}(") - assert start != -1, f"{name} is gone from app.js — update this test" + assert start != -1, f"{name} is gone — update this test" end = source.find("\nfunction ", start + 1) return source[start:end if end != -1 else len(source)] @@ -220,7 +232,7 @@ def test_no_caller_waits_for_one_chunk_at_a_time(): # two ends of that, since neither shows up in any Python test. def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it(): - app = APP.read_text() + app = _component("GroupPage") cleanup = app[app.index(" return () => {\n cancelled = true;"):] cleanup = cleanup[:cleanup.index("\n }, [groupId")] assert "releaseWhenIdle" in cleanup, ( @@ -252,7 +264,7 @@ def test_a_multi_file_download_waits_for_each_picker(): meant the first opened a dialog and the rest were rejected — two files selected, one file downloaded. """ - app = APP.read_text() + app = STATIC.joinpath("files-app.js").read_text() # Anchored on the loop rather than on the markup around it: the toolbar # moved from a dropdown to icon buttons and took the old wrapper with it, # while the property under test — one picker at a time — did not change. @@ -269,7 +281,7 @@ def test_links_in_chat_are_built_as_elements_not_markup(): never HTML, and only for http(s) — otherwise javascript: would be one message away from running here. """ - app = APP.read_text() + app = STATIC.joinpath("chat-app.js").read_text() fn = app[app.index("function linkify("):] fn = fn[:fn.index("\nfunction ", 1)] assert "innerHTML" not in fn and "dangerouslySetInnerHTML" not in fn diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 35fe1cc..86d58cd 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -209,7 +209,7 @@ def test_the_colour_is_defined_for_that_mark(): def test_a_folder_name_carries_no_trailing_slash(): """The folder icon in the cell beside it already says what it is.""" - source = APP.read_text(encoding="utf-8") + source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8") row = source[source.index('class="file-row dir-row"'):] row = row[:row.index("</tr>")] assert "${d}/" not in row, "the folder name is rendered with a trailing slash" diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 5dc3b7b..e014518 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -23,6 +23,12 @@ import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" TRANSPORT = STATIC / "transport.js" APP = STATIC / "app.js" +# Chat's history paging/scroll-anchoring logic and GroupPage's own connect() +# effect moved out of app.js in the group-page refactor. +CHAT_APP = STATIC / "chat-app.js" +GROUP_PAGE = STATIC / "group-page.js" +SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", + STATIC / "video-player.js", STATIC / "group-settings.js"] pytestmark = pytest.mark.skipif( not TRANSPORT.exists(), reason="the SPA sources are not available") @@ -38,6 +44,16 @@ def app(): return APP.read_text() +@pytest.fixture(scope="module") +def chat(): + return CHAT_APP.read_text() + + +@pytest.fixture(scope="module") +def group_page(): + return GROUP_PAGE.read_text() + + def test_chat_history_pages_backwards(transport): body = transport[transport.index("async fetchChatHistory"):] body = body[:body.index("\n }")] @@ -53,15 +69,15 @@ def test_chat_history_reports_whether_more_exists(transport): "without it the 'load older' control cannot know when to stop offering") -def test_the_browser_asks_for_the_newest_page_first(app): +def test_the_browser_asks_for_the_newest_page_first(chat): """A group opens on the newest messages, not the oldest.""" - assert "fetchChatHistory({ limit: CHAT_PAGE })" in app - assert re.search(r"const CHAT_PAGE\s*=\s*100", app) - assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", app) + assert "fetchChatHistory({ limit: CHAT_PAGE })" in chat + assert re.search(r"const CHAT_PAGE\s*=\s*100", chat) + assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", chat) -def test_older_pages_are_requested_with_a_cursor_not_an_offset(app): - assert "before: messages[0].id" in app, ( +def test_older_pages_are_requested_with_a_cursor_not_an_offset(chat): + assert "before: messages[0].id" in chat, ( "paging by offset repeats or skips messages when one arrives mid-scroll") @@ -88,20 +104,20 @@ def test_ping_can_time_out_sooner_than_a_transfer(transport): assert "timeoutMs" in body -def test_scroll_position_is_anchored_when_older_messages_are_prepended(app): +def test_scroll_position_is_anchored_when_older_messages_are_prepended(chat): """Everything above the viewport grows, so scrollTop alone is not enough.""" - assert "scrollHeight - list.scrollTop" in app, "the anchor is measured from the bottom" - assert "list.scrollTop = list.scrollHeight - anchorRef.current" in app - assert "useLayoutEffect" in app, ( + assert "scrollHeight - list.scrollTop" in chat, "the anchor is measured from the bottom" + assert "list.scrollTop = list.scrollHeight - anchorRef.current" in chat + assert "useLayoutEffect" in chat, ( "correcting after paint shows the jump it is meant to prevent") -def test_the_view_only_follows_new_messages_when_already_at_the_bottom(app): - assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in app, ( +def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat): + assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in chat, ( "scrolling unconditionally fights someone reading back through history") -def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app): +def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(chat): """scrollIntoView on a zero-height marker stops short of the true bottom. The list has padding and a flex gap below the last bubble, so aligning an @@ -110,15 +126,15 @@ def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app): """ # The call, not the word: a comment explaining why it is gone should not # be able to fail this. - assert ".scrollIntoView(" not in app - assert "list.scrollTo({ top: list.scrollHeight" in app, ( + assert ".scrollIntoView(" not in chat + assert "list.scrollTo({ top: list.scrollHeight" in chat, ( "jumping to the latest should also land on the real bottom") -def test_messages_are_keyed_by_id_not_index(app): +def test_messages_are_keyed_by_id_not_index(chat): """Index keys plus prepending makes Preact reuse the wrong bubbles.""" - assert "key=${m.id}" in app - assert "key=${i}" not in app.split("function ChatPanel")[1].split("\n}")[0] + assert "key=${m.id}" in chat + assert "key=${i}" not in chat.split("function ChatPanel")[1].split("\n}")[0] def test_presence_has_three_states_and_a_label_for_each(app): @@ -137,9 +153,9 @@ def _string(source: str, key: str) -> str: return source[start:end] -def test_a_refusal_from_the_node_counts_as_present(app): +def test_a_refusal_from_the_node_counts_as_present(group_page): """The node answering "no" proves it is up; only silence proves nothing.""" - assert "err.reason ? 'online' : 'offline'" in app + assert "err.reason ? 'online' : 'offline'" in group_page # ── The create-group form ───────────────────────────────────────────────────── @@ -191,7 +207,7 @@ def test_the_form_starts_on_a_combination_the_api_accepts(app): # ── Dead references in the SPA ──────────────────────────────────────────────── -def test_no_setter_survives_the_state_it_belonged_to(app): +def test_no_setter_survives_the_state_it_belonged_to(): """A removed useState leaves its setter behind, and nothing complains. `setActionsOpen` outlived `actionsOpen` when the Actions dropdown became a @@ -202,28 +218,45 @@ def test_no_setter_survives_the_state_it_belonged_to(app): A grep for the state name does not find it — `setActionsOpen` does not contain `actionsOpen`, the capital breaks the match. That is exactly how it got through. + + Checked per file rather than on one concatenated blob: the group-page + refactor split what used to be one app.js into several, and a setter + defined in one (e.g. `setAuth`, imported from hub-client.js) must not be + mistaken for covering an orphan call of the same name in another. Lifting + state to the shared shell and passing its setter down as a prop is the + same idea one level lower — `FilesPanel`'s `setEntries` is real, just + declared in group-page.js's own `useState` rather than here — so a setter + named in a component's own destructured props is treated as defined too. """ import re - declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app)) - # Names brought in from another module are defined, just not here. - imported = set() - for names in re.findall(r"import\s*\{([^}]*)\}\s*from", app): - imported.update(n.strip().split(" as ")[-1].strip() for n in names.split(",")) - # A bare call only: `downloads.setMode(...)` and `view.setUint32(...)` belong - # to their object, not to this component. - called = set(re.findall(r"(?<![.\w])(set[A-Z]\w*)\s*\(", app)) - builtin = {"setTimeout", "setInterval"} - # A `setX` that is a plain function of this module is not an orphan setter: - # `setAuth` writes the session to localStorage and has no `useState` behind - # it by design. Without this the rule reports every such helper, and a rule - # that cries wolf is one someone eventually silences. - defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M)) - defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M)) + for path in SPLIT_FILES: + app = path.read_text() + declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app)) + # Names brought in from another module are defined, just not here. + imported = set() + for names in re.findall(r"import\s*\{([^}]*)\}\s*from", app): + imported.update(n.strip().split(" as ")[-1].strip() for n in names.split(",")) + # A bare call only: `downloads.setMode(...)` and `view.setUint32(...)` + # belong to their object, not to this component. + called = set(re.findall(r"(?<![.\w])(set[A-Z]\w*)\s*\(", app)) + builtin = {"setTimeout", "setInterval"} + # A `setX` that is a plain function of this module is not an orphan + # setter: `setAuth` writes the session to localStorage and has no + # `useState` behind it by design. Without this the rule reports every + # such helper, and a rule that cries wolf is one someone eventually + # silences. + defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M)) + defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M)) + # A setter named in a `function Component({ ..., setX, ... })` prop + # list is handed down from wherever it is really declared. + for params in re.findall(r"^function [A-Z]\w*\(\{([^}]*)\}", app, re.M): + defined.update(re.findall(r"\b(set[A-Z]\w*)\b", params)) - orphans = sorted(called - declared - imported - builtin - defined) - assert not orphans, ( - f"setter(s) called with no useState behind them: {orphans} — " - "each one is a ReferenceError the moment that code path runs") + orphans = sorted(called - declared - imported - builtin - defined) + assert not orphans, ( + f"{path.name}: setter(s) called with no useState behind them: " + f"{orphans} — each one is a ReferenceError the moment that code " + "path runs") # ── Parallel uploads ────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py index d859125..9290a94 100644 --- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py +++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py @@ -17,7 +17,15 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +# The group-page refactor split what used to be one app.js into one file per +# "application" plus the group shell. mayUpload itself is still derived once, +# in the shell (group-page.js) — Files and Chat each moved to their own file +# and receive it as a prop, the same shape ChatPanel already took. APP = STATIC / "app.js" +GROUP_PAGE = STATIC / "group-page.js" +FILES_APP = STATIC / "files-app.js" +CHAT_APP = STATIC / "chat-app.js" +GROUP_SETTINGS = STATIC / "group-settings.js" TRANSPORT = STATIC / "transport.js" pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable") @@ -25,7 +33,7 @@ pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailabl @pytest.fixture(scope="module") def app() -> str: - return APP.read_text(encoding="utf-8") + return GROUP_PAGE.read_text(encoding="utf-8") def _component(app: str, name: str) -> str: @@ -36,15 +44,15 @@ def _component(app: str, name: str) -> str: # ── Both controls ─────────────────────────────────────────────────────────── -def test_the_files_toolbar_hides_its_upload_button(app): - page = _component(app, "GroupPage") +def test_the_files_toolbar_hides_its_upload_button(): + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("group.mkdir")] assert "mayUpload &&" in toolbar, "the Upload button is offered regardless" -def test_the_chat_composer_hides_its_paperclip(app): - chat = _component(app, "ChatPanel") +def test_the_chat_composer_hides_its_paperclip(): + chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") composer = chat[chat.index("chat-input-row"):] assert "mayUpload &&" in composer, ( "the chat attachment is the second way in and is still offered") @@ -53,15 +61,19 @@ def test_the_chat_composer_hides_its_paperclip(app): def test_both_read_the_same_answer(app): """Two derivations would eventually disagree, and the disagreement would be one of them offering an upload the node refuses.""" - page = _component(app, "GroupPage") - assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", page), ( + assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), ( "mayUpload is no longer derived in one place") - assert "mayUpload=${mayUpload}" in page, "the chat panel is told separately" + # Files and Chat both receive it from the same `commonProps` object the + # shell spreads into whichever app tab is active — one derivation feeding + # one object, rather than two hand-written prop attributes that could + # drift apart. + props = app[app.index("const commonProps = {"):app.index("return html`")] + assert "mayUpload," in props or "mayUpload:" in props, ( + "mayUpload is not in the shared props object every app receives") def test_the_operator_keeps_their_own_controls(app): - page = _component(app, "GroupPage") - assert "memberUpload || isNodeAdmin" in page, ( + assert "memberUpload || isNodeAdmin" in app, ( "turning uploads off would hide the operator's own upload button") @@ -70,27 +82,24 @@ def test_the_operator_keeps_their_own_controls(app): def test_the_answer_comes_from_the_node(app): """Not from the hub, which has no say in what may be written to someone else's disk, and no way to be believed about it.""" - page = _component(app, "GroupPage") - assert "ack.member_upload !== false" in page, ( + assert "ack.member_upload !== false" in app, ( "the handshake ack is what carries this") - assert "hubFetch" not in page[page.index("ack.member_upload") - 400: - page.index("ack.member_upload")] + assert "hubFetch" not in app[app.index("ack.member_upload") - 400: + app.index("ack.member_upload")] def test_an_older_node_is_treated_as_permissive(app): """A node that predates the setting sends no such field. Reading a missing field as "off" would close every group on the older half of the network.""" - page = _component(app, "GroupPage") - assert "!== false" in page[page.index("ack.member_upload"): - page.index("ack.member_upload") + 60] + assert "!== false" in app[app.index("ack.member_upload"): + app.index("ack.member_upload") + 60] def test_a_change_reaches_people_already_connected(app): """The operator may be someone else entirely, changing it while you have the group open. A button that survives until the next reconnection is a button somebody presses.""" - page = _component(app, "GroupPage") - assert "transport.onUploadPolicy" in page + assert "transport.onUploadPolicy" in app transport = TRANSPORT.read_text(encoding="utf-8") assert "member_upload_ack" in transport, "nothing routes the node's notice" @@ -115,8 +124,8 @@ def test_changing_it_is_signed(app): "an unsigned instruction would let any member turn uploads back on") -def test_only_the_operator_is_offered_the_setting(app): - panel = _component(app, "GroupSettingsPanel") +def test_only_the_operator_is_offered_the_setting(): + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") section = panel[panel.index("members.uploads_title") - 400: panel.index("members.uploads_title")] assert "isNodeAdmin && connected" in section diff --git a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py index cf38117..59f97de 100644 --- a/packages/meshbay-hub/tests/test_video_buffer_ceiling.py +++ b/packages/meshbay-hub/tests/test_video_buffer_ceiling.py @@ -45,7 +45,7 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -APP = STATIC / "app.js" +APP = STATIC / "video-player.js" NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" / "meshbay_node" / "transport" / "webrtc_server.py") @@ -61,7 +61,8 @@ def app(): def _player(app: str) -> str: i = app.index("function VideoPlayer(") - return app[i:app.index("\nfunction ", i + 1)] + nxt = app.find("\nfunction ", i + 1) + return app[i:nxt if nxt > 0 else len(app)] # ── The shipped functions, run against a browser that has a ceiling ─────────── diff --git a/packages/meshbay-hub/tests/test_video_seek.py b/packages/meshbay-hub/tests/test_video_seek.py index 51f65ce..85e774d 100644 --- a/packages/meshbay-hub/tests/test_video_seek.py +++ b/packages/meshbay-hub/tests/test_video_seek.py @@ -36,7 +36,7 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -APP = STATIC / "app.js" +APP = STATIC / "video-player.js" TRANSPORT = STATIC / "transport.js" NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" / "meshbay_node" / "transport" / "webrtc_server.py") @@ -51,7 +51,8 @@ def app(): def _player(app: str) -> str: i = app.index("function VideoPlayer(") - return app[i:app.index("\nfunction ", i + 1)] + nxt = app.find("\nfunction ", i + 1) + return app[i:nxt if nxt > 0 else len(app)] # ── The node ────────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py index 464d1f7..80d9421 100644 --- a/packages/meshbay-hub/tests/test_video_stream_switch.py +++ b/packages/meshbay-hub/tests/test_video_stream_switch.py @@ -35,7 +35,7 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -APP = STATIC / "app.js" +APP = STATIC / "video-player.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not APP.exists(), @@ -49,7 +49,8 @@ def app(): def _player(app: str) -> str: i = app.index("function VideoPlayer(") - return app[i:app.index("\nfunction ", i + 1)] + nxt = app.find("\nfunction ", i + 1) + return app[i:nxt if nxt > 0 else len(app)] # ── The flag that stalled everything ────────────────────────────────────────── diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index b34e710..c68f999 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -264,6 +264,10 @@ class NodeDaemon: # place, so the two never drift within a run. "member_upload": await self._roster.member_upload_allowed( group_cfg.id) if self._roster else True, + # Same reasoning: read once at load, kept current in place + # by the signed operation that changes it. + "enabled_apps": await self._roster.enabled_apps( + group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS), } if not groups_ctx: @@ -584,6 +588,9 @@ class NodeDaemon: "member_upload": ( await self._roster.member_upload_allowed(group_cfg.id) if self._roster else True), + "enabled_apps": ( + await self._roster.enabled_apps(group_cfg.id) + if self._roster else list(Roster.DEFAULT_APPS)), "chat_store": store, } groups_ctx[group_cfg.id] = new_ctx diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index daddf17..c9d862a 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -711,6 +711,25 @@ async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: return {"allowed": allowed, "group_id": group_id} +# ── Applications ───────────────────────────────────────────────────────────── + +async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict: + """ + Which group "applications" (Chat, Files, ...) are shown to members. + + Same shape as `set_member_upload`: lives on the node (roster.db), takes + effect without a restart, and is signed by the operator (webrtc_server.py + checks the caller's own admin-authority allow-list before this runs). + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_enabled_apps(group_id, apps, + set_by=state.get("node_user_id", "")) + ctx["enabled_apps"] = apps + log.info("Enabled apps for group %s: %s", group_id[:8], ",".join(sorted(apps))) + return {"apps": apps, "group_id": group_id} + + # ── Reload ────────────────────────────────────────────────────────────────── async def reload_config(state: dict) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 9a52b53..1cc8cae 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -22,6 +22,7 @@ The code is what binds a public key to an account without asking the hub from __future__ import annotations import hashlib +import json import logging import os import secrets @@ -577,6 +578,27 @@ class Roster: "1" if allowed else "0", set_by) return allowed + # Which group "applications" (Chat, Files, and whatever registers later in + # apps.js) are shown to members. Unset means every app that exists — an + # existing group's tabs must not disappear because a node was upgraded. + SETTING_ENABLED_APPS = "enabled_apps" + DEFAULT_APPS = ("chat", "files") + + async def enabled_apps(self, group_id: str) -> list[str]: + value = await self.get_setting(group_id, self.SETTING_ENABLED_APPS) + if value is None: + return list(self.DEFAULT_APPS) + try: + return list(json.loads(value)) + except (ValueError, TypeError): + return list(self.DEFAULT_APPS) + + async def set_enabled_apps(self, group_id: str, apps: list[str], + set_by: str = "") -> list[str]: + await self.set_setting(group_id, self.SETTING_ENABLED_APPS, + json.dumps(sorted(apps)), set_by) + return apps + async def create_invite( self, group_id: str, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 947d1f9..b6f572a 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -62,6 +62,7 @@ from meshbay_common.adminop import ( OP_GEK_ROTATE, OP_MEMBER_UNPIN, OP_MEMBER_UPLOAD, + OP_APPS_ENABLED, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -406,6 +407,8 @@ class WebRTCPeerSession: self._spawn(self._do_device_revoke(msg)) elif mtype == MNP.MEMBER_UPLOAD: self._do_member_upload(msg) + elif mtype == MNP.APPS_ENABLED: + self._do_apps_enabled(msg) elif mtype == MNP.MEMBER_UNPIN: self._do_member_unpin(msg) elif mtype == MNP.GEK_ROTATE: @@ -651,6 +654,11 @@ class WebRTCPeerSession: # permission — the node refuses regardless — but without it the # only way to discover the answer is to try. "member_upload": bool(self._group_ctx().get("member_upload", True)), + # Which group "applications" to show. Absent/empty falls back to + # every registered one client-side, so a node that predates this + # setting (or one whose context has not loaded it yet) hides + # nothing. + "enabled_apps": list(self._group_ctx().get("enabled_apps") or []), } if node_user_id: ack["node_user_id"] = node_user_id @@ -1586,6 +1594,60 @@ class WebRTCPeerSession: except Exception: pass + # Every "application" a group can show — Chat and Files today. Videos, + # Music, Photos join this set (and apps.js's registry, client-side) when + # they land; nothing else about this handler changes. + ALLOWED_APPS = frozenset({"chat", "files"}) + + def _do_apps_enabled(self, msg: dict) -> None: + """ + Turn a group "application" on or off for everyone, for this group. + + Signed like `member_upload`: this decides what a member sees, and an + unsigned message would let any member turn a disabled one back on. + """ + apps = msg.get("apps") + if not isinstance(apps, list) or not apps: + self._send({"type": "error", "detail": "Missing or empty apps"}) + return + unknown = set(apps) - self.ALLOWED_APPS + if unknown: + self._send({"type": "error", + "detail": f"Unknown app(s): {', '.join(sorted(unknown))}"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + # The subject is what the operator is shown before signing, and what + # the client compares its own request against (transport.js) — a + # canonical form so both sides build the same transcript. + self._issue_admin_challenge(OP_APPS_ENABLED, ",".join(sorted(apps))) + + async def _admin_exec_apps_enabled( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + apps = pending["subject"].split(",") if pending["subject"] else [] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"apps_enabled:{pending['subject']}") + return + try: + await self._run_op( + ops.set_enabled_apps, self._group_id or "", apps) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("apps_enabled", pending["subject"]) + + # Everyone already connected is told, so a disabled tab disappears + # without waiting for a reconnection. + notice = {"type": MNP.APPS_ENABLED_ACK, "v": MNP_VERSION, "apps": apps} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + # ── Node management (D5) ───────────────────────────────────────────────── async def _do_node_status(self, msg: dict) -> None: @@ -2561,6 +2623,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_MEMBER_UPLOAD: self._spawn( self._admin_exec_member_upload(pending, transcript, sig_bytes)) + elif pending["op"] == OP_APPS_ENABLED: + self._spawn( + self._admin_exec_apps_enabled(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_apps_enabled_policy.py b/packages/meshbay-node/tests/test_apps_enabled_policy.py new file mode 100644 index 0000000..671005a --- /dev/null +++ b/packages/meshbay-node/tests/test_apps_enabled_policy.py @@ -0,0 +1,137 @@ +""" +The operator decides which group "applications" (Chat, Files, ...) are shown. + +Same shape as `test_member_upload_policy.py`, because it is the same kind of +setting: changed by a signed operator instruction, stored on the node rather +than the hub, and safe for an existing group to have never heard of. The two +things specific to this one: the whole set is signed in one message rather +than one op per app, and an empty or unrecognised set is refused before a +challenge is ever issued — there is no file write to refuse afterwards the +way an unsigned upload is refused, so the check has to happen up front. +""" + +from pathlib import Path + +import pytest + +from meshbay_common.adminop import OP_APPS_ENABLED +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.transport.webrtc_server import WebRTCPeerSession +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_an_empty_set_is_refused(tmp_path): + """Never let the operator lock a group down to nothing — no round trip + to the operator's browser needed to learn that.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": []}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_an_unknown_app_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": ["chat", "videos"]}) + + assert not issued, "videos is not registered yet — accepting it would " \ + "silently store a setting no client can act on" + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._has_admin_authority = lambda: False + + session._do_apps_enabled({"apps": ["chat"]}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +# ── Who may change it ─────────────────────────────────────────────────────── + +async def test_changing_it_needs_a_signature(tmp_path): + """The request only ever produces a challenge. Nothing is applied until a + signature over the transcript verifies — the same path as member_upload.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": ["files"]}) + + assert issued == [(OP_APPS_ENABLED, "files")] + + +async def test_the_subject_is_the_sorted_set_so_both_sides_build_the_same_transcript(tmp_path): + """The operator's browser and the node must independently arrive at the + same subject string to sign/verify — order in the request must not + matter.""" + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_apps_enabled({"apps": ["files", "chat"]}) + + assert issued == [(OP_APPS_ENABLED, "chat,files")] + + +# ── Where it is stored ────────────────────────────────────────────────────── + +async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + try: + assert sorted(await roster.enabled_apps("g1")) == ["chat", "files"], ( + "absent must mean every registered app, or an upgrade hides one " + "for every existing group") + await roster.set_enabled_apps("g1", ["chat"], set_by="op") + assert await roster.enabled_apps("g1") == ["chat"] + finally: + await roster.close() + + reopened = Roster(db_path=tmp_path / "roster.db") + await reopened.open() + try: + assert await reopened.enabled_apps("g1") == ["chat"] + assert sorted(await reopened.enabled_apps("g2")) == ["chat", "files"], ( + "one group's setting must not answer for another") + finally: + await reopened.close() |