summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
-rw-r--r--CLAUDE.md24
-rw-r--r--docs/apps.md192
-rw-r--r--docs/meshbay-draft-v6.md42
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js3539
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js449
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js207
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js620
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js484
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js635
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js269
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js992
-rw-r--r--packages/meshbay-hub/tests/harness/scroll_probe.py4
-rw-r--r--packages/meshbay-hub/tests/harness/session_harness.mjs4
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py4
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py41
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py4
-rw-r--r--packages/meshbay-hub/tests/test_resume_position.py2
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py13
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py22
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py2
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py113
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py51
-rw-r--r--packages/meshbay-hub/tests/test_video_buffer_ceiling.py5
-rw-r--r--packages/meshbay-hub/tests/test_video_seek.py5
-rw-r--r--packages/meshbay-hub/tests/test_video_stream_switch.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py65
-rw-r--r--packages/meshbay-node/tests/test_apps_enabled_policy.py137
46 files changed, 4542 insertions, 3627 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 9f63595..ece3828 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -72,10 +72,13 @@ dans le bon `pyproject.toml`.
- All three packages share the same version number (released together)
### Protocol versions (independent)
-- MNP: `0.2` → bumped independently of package version
+- MNP: `0.4` → bumped independently of package version
- 0.2 added `PING`/`PONG` and backward chat paging (`before` / `has_more`).
Additive, so an 0.1 peer still works: it sends no `before` and is answered
with the newest page, which is what it wanted
+ - 0.4 added `apps_enabled`/`apps_enabled_ack` and `enabled_apps` on the
+ handshake ack, for the group-applications registry (`docs/apps.md`).
+ Additive, same reasoning
- MHP: `0.1` → bumped independently of package version
- Every wire message carries a `v` field
- Breaking change → MAJOR bump; backward-compatible → MINOR bump
@@ -699,8 +702,9 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| WebRTC transport (browser) | `meshbay_node.transport.webrtc_server` | Phase 9.3 — `aiortc` DataChannel |
| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay |
| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel |
-| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router |
-| File download (large) | `static/app.js` | File System Access API (`showSaveFilePicker`) — stream to disk |
+| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) |
+| Group applications (adding one) | `docs/apps.md` | Chat/Files today, Videos/Music/Photos planned. Props contract, enablement mechanism, checklist |
+| File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk |
| Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected |
| Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave |
| Download backpressure (node) | `meshbay_node.transport.webrtc_server` | `DOWNLOAD_BUFFER_HIGH` — 8 × 1 MB answered blind queues 8 MB on the channel |
@@ -730,10 +734,10 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| Ed25519 sign (browser) | `static/keyderive.js` | `signChallenge()` — admin challenge-response |
| Auth key derivation (browser) | `static/keyderive.js` | `deriveAuthKey()` — password split, hub never sees raw password |
| GEK wrap AES (Python) | `meshbay_common.crypto` | Phase 10b.2 — `wrap_gek_aes()` / `unwrap_gek_aes()` |
-| IndexedDB cache (browser) | `static/app.js` | Phase 10b.5 — group index caching |
+| IndexedDB cache (browser) | `static/hub-client.js` | Phase 10b.5 — group index caching, moved out of app.js in the 2026-08-23 split |
| Cross-group search (browser) | `static/app.js` | Phase 10b.6 — SearchPage, client-side |
| MSE video streaming (node) | `meshbay_node.transport.webrtc_server` | Phase 10c — ffmpeg fMP4 remux + encrypted segments |
-| MSE video streaming (browser) | `static/app.js` | Phase 10c — MediaSource + SourceBuffer progressive playback |
+| MSE video streaming (browser) | `static/video-player.js` | Phase 10c — MediaSource + SourceBuffer progressive playback, moved out of app.js in the 2026-08-23 split |
| Video codec detection | `meshbay_node.transport.webrtc_server` | Phase 10c — `_probe_video()` ffprobe + MSE codec strings |
| Node daemon (production) | `meshbay_node.daemon` | Phase 11 — WebRTC + WS + chat + HTTP + audit all wired |
| Node config | `meshbay_node.config` | `node.toml` loader, `data_dir` for chat/audit DBs |
@@ -743,24 +747,24 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| Bundle store (node) | `meshbay_node.bundle_store` | SQLite P2P GEK + keypair bundles, `data_dir/bundles.db` — hub never stores crypto |
| P2P bundle exchange (MNP) | `meshbay_common.protocol` | GEK + keypair bundle STORE/FETCH/RESP message types |
| Bundle via DataChannel | `static/transport.js` | GEK + keypair bundle fetch during handshake, store after connect |
-| Key persistence (browser) | `static/app.js` | `_bundleKey` in IndexedDB, `_sessionKeys` in sessionStorage |
+| Key persistence (browser) | `static/hub-client.js` | `session.bundleKey` (renamed from the bare `_bundleKey` in the 2026-08-23 split) in IndexedDB, `_sessionKeys` in sessionStorage |
| pkX from private key | `static/transport.js` | `_pkFromSk()` — JWK export to derive X25519 public key |
| Group delete (hub) | `meshbay_hub.api.groups` | `DELETE /v1/groups/{group_id}` — admin only |
| JWT scope enforcement | `meshbay_hub.api.deps` | `require_user_scope` — blocks node-scoped tokens from mutations |
| Operator operations | `meshbay_node.ops` | **One implementation, several front doors.** The loopback API, the CLI and the signed MNP handlers all call these; they take the daemon `state`, raise `OpError`, and know nothing about HTTP. Two implementations of one operation with two authorization checks is C1/C6 one size down |
| Node local admin UI | `meshbay_node.ui.app` | Dashboard, peers, groups, audit log (localhost:18000). Each operation endpoint is one `_op(...)` line — logic there is a rule the MNP path does not have |
| Demo scripts | — | `QE/demo-v1/*.py`, `QE/demo-v2/*.py`, `QE/demo-v3/*.py` (not versioned) |
-| Video flow control (browser) | `static/app.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data |
+| Video flow control (browser) | `static/video-player.js` | `pump()` — the only place credit is granted. Read-ahead bounded by `BUFFER_AHEAD_S` of film, `STREAM_WINDOW` segments in flight, driven by a clock and by playback, never by arriving data |
| Player under test | `tests/harness/mse_harness.mjs` | Runs the real `pump`/`flushQueue`/`evictBehind` against a fake SourceBuffer with a ceiling. Do not write a second model of them |
| Asset versioning (hub) | `meshbay_hub.api.webapp` | `_asset_version()` — content hash; whole module graph served under `/a/<hash>/` so a cache cannot mix two builds |
| Stream capacity (node) | `meshbay_node.config` | `[node] max_concurrent_streams` (default 8) — a slot is held for the length of a film, so it counts simultaneous viewers |
| Stream diagnosis (node) | `webrtc_server.py` | `client_diag` at DEBUG — the player's own view (`ready`, `quota`, `ranges`, `err`) in the node's log. The only window into a phone |
| Stream probe (no browser) | — | `QE/deploy/stream_probe.py` — pulls a real film over real MNP, `--start` to seek. Answers "is it the node or the browser" in one run (not versioned) |
-| Seeking (browser) | `static/app.js` | `requestSeek` → node restarts ffmpeg with `-ss`; `reinitAt` clears the buffer and sets `timestampOffset`. `-copyts` does *not* preserve position — measured — so the offset comes from the client |
+| Seeking (browser) | `static/video-player.js` | `requestSeek` → node restarts ffmpeg with `-ss`; `reinitAt` clears the buffer and sets `timestampOffset`. `-copyts` does *not* preserve position — measured — so the offset comes from the client |
| Seeking (node) | `webrtc_server.py` | `start` on `stream_req`; `-ss` **before** `-i` (index seek, not decode-and-discard), clamped away from the end, echoed in `stream_init` |
-| Resume position | `static/app.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch |
+| Resume position | `static/video-player.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch |
| Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values |
-| Session renewal (browser) | `static/app.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays |
+| Session renewal (browser) | `static/hub-client.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays. Moved out of app.js in the 2026-08-23 split |
| Token lifetimes (hub) | `meshbay_hub.config` | `[jwt] access_token_ttl` 4 h, `refresh_token_ttl` 30 days. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there |
## meshbay.org server (état cible)
diff --git a/docs/apps.md b/docs/apps.md
new file mode 100644
index 0000000..94116c6
--- /dev/null
+++ b/docs/apps.md
@@ -0,0 +1,192 @@
+# Group applications — adding one
+
+> Status: **current, as built.** Describes the plug-in architecture that
+> replaced the monolithic `static/app.js`, landed 2026-08-23. See
+> `meshbay-draft-v6.md` §2.7 for why this exists and what it changes; this
+> document is the how-to.
+
+A group has "applications" — Chat and Files today, Videos/Music/Photos planned
+(Netflix-style browsing, Spotify-style playback, an album viewer). None of the
+planned ones need an MNP protocol change: video/audio/image files are already
+classified by the node's indexer (`meshbay_node/indexer/indexer.py`, `type:
+video|audio|image`) and flow through the same `index_sync`/`file_req`/
+`stream_req` messages Files and `VideoPlayer` already use. Adding one is a new
+file plus one registry entry — nothing about the group shell changes.
+
+---
+
+## 1. The shape
+
+```
+group-page.js ─┬─ owns: connection (transportRef/gekRef), the file index
+ (the shell) │ (entries/nodeDirs/nodeRoots), admin flags, which apps are
+ │ enabled, the tab bar, the video/preview modals
+ │
+ ├─ apps.js ─── the registry: [{ key, icon, labelKey, Component }]
+ │
+ ├─ chat-app.js ──────── ChatPanel
+ ├─ files-app.js ─────── FilesPanel, FilePreview
+ └─ (video-app.js, music-app.js, photos-app.js — not built)
+
+group-settings.js ─── not an app. Always present, not toggleable — disabling
+ it would strand an operator with no way to re-enable
+ anything. Holds the "Applications" checkbox list.
+
+Shared infrastructure (imported by app.js AND every per-app file — this is
+why they exist as separate modules rather than being re-exported from app.js,
+which would make a circular import):
+ icon.js — the <Icon> component and its SVG path table
+ file-utils.js — formatSize/formatDate/canPreview/FILE_ICONS, the
+ download/decrypt pipeline (pipelinedDownload, downloadEntry,
+ _openDownloadTarget, _saveBlob), CHUNK_SIZE
+ hub-client.js — HUB, hubFetch, the auth/session/token-renewal machinery,
+ the group-index IndexedDB cache, the keypair-bundle cache,
+ `session` (mutable {bundleKey, pendingJoinCode}), navigate
+```
+
+`app.js` itself is what's left after the split: routing, every *other* page
+(Login/Register/Home/Explore/Search/Profile/Settings/Admin/Node/
+CreateGroupWizard), and nothing group-application-specific.
+
+## 2. What every app receives
+
+`group-page.js` builds one `commonProps` object per render and spreads it into
+whichever app is active:
+
+```js
+const commonProps = {
+ groupId, transportRef, gekRef, status, username,
+ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ onRefreshIndex: refreshIndex, onActivity: touchActivity,
+};
+...
+${apps.map(a => tab === a.key && html`<${a.Component} key=${a.key} ...${commonProps} />`)}
+```
+
+Every registered component gets the same context and destructures what it
+needs — a new app does not get a bespoke prop list. Notable ones:
+
+| Prop | What it is | Why it's here, not local state |
+|---|---|---|
+| `entries`, `nodeDirs`, `nodeRoots` | the group's file index | Chat needs it too, for image attachments — lifting it avoids two copies going stale against each other |
+| `applyIndex(indexMsg)` | writes a fresh index into the three above, plus the search cache | anything that mutates files (upload, delete, mkdir) calls this so every app sees the result |
+| `onPreview(entry)` | opens the shell's video/preview modal | `entry.type === 'video'` routes to `VideoPlayer`, anything else to `FilePreview` — an app just calls this, it does not own modal state |
+| `transportRef`, `gekRef` | refs to the live MNP transport and the imported group key | never state — a ref, so reconnects don't force a re-render of every app |
+| `mayUpload` | `memberUpload || isNodeAdmin`, computed once | Files' toolbar and Chat's composer both gate on it; a second derivation would eventually disagree with the first |
+
+An app that needs **local** state (Files' `selecting`/`sortKey`/`currentPath`,
+for instance) owns it itself with `useState`, same as before the split. One
+thing worth keeping if you add a tab with a notion of "current location within
+the group" the way Files has a path: reset it on `groupId` change.
+`files-app.js` does this —
+
+```js
+useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]);
+```
+
+— because a directory from the group just left rarely exists in the one just
+entered, and without the reset the panel shows a stale path and lists
+nothing. This was a real bug, fixed before the split; carry the pattern into
+any app with similar per-group local state.
+
+## 3. Enable/disable: the mechanism
+
+Same shape as `member_upload` (`meshbay-draft-v6.md` §2.1b) — an
+operator-signed setting, stored on the node, enforced by absence rather than
+by the client's honesty.
+
+**Node side** (`meshbay_node/roster.py`):
+```python
+SETTING_ENABLED_APPS = "enabled_apps" # in the existing group_settings table
+DEFAULT_APPS = ("chat", "files") # what an unset group gets
+async def enabled_apps(group_id) -> list[str]: ...
+async def set_enabled_apps(group_id, apps, set_by="") -> list[str]: ...
+```
+`meshbay_node/ops.py` has `set_enabled_apps(state, group_id, apps)`, called
+from exactly one place: `webrtc_server.py`'s `_admin_exec_apps_enabled`, after
+`_verify_admin_sig` — nothing is applied before the signature checks out.
+
+`_do_apps_enabled` in `webrtc_server.py` validates before it ever issues a
+challenge:
+- `apps` non-empty — the operator can never lock a group down to nothing.
+- every entry in `WebRTCPeerSession.ALLOWED_APPS` (`{"chat", "files"}` today)
+ — **this is the line a new app's node-side registration touches.**
+
+The whole set is signed in one message (`apps_enabled`, `OP_APPS_ENABLED` in
+`meshbay_common.adminop`) rather than one op per app — ticking several boxes
+in Settings costs one signature, not N. The transcript's subject is the
+sorted, comma-joined app list (`"chat,files"`), built the same way on both
+sides so the operator's browser and the node arrive at identical bytes to
+sign/verify.
+
+`enabled_apps` rides in `handshake_ack` and `node_status`, next to
+`member_upload`. Changing it broadcasts `apps_enabled_ack` to everyone already
+connected — `transport.js`'s `onAppsEnabled` — so a disabled tab disappears
+without waiting for a reconnection, the same as `member_upload`'s live
+broadcast.
+
+**Client side:** `apps.js`'s `visibleApps(enabledKeys)` filters the registry;
+`group-page.js` calls it with `enabledApps` state (from the ack, `null` until
+one arrives, which `visibleApps` reads as "show everything registered" — a
+node that predates an app, or hasn't answered yet, hides nothing). The
+Settings toggle list in `group-settings.js` iterates the *same* `APPS`
+registry, so a newly-registered app gets a checkbox for free.
+
+## 4. Adding an app — checklist
+
+1. **`<name>-app.js`**, exporting a component with the standard props shape
+ (§2). Use `files-app.js` as the reference if the app is file/media-centric
+ (it will be, for Videos/Music/Photos — all three are views over `entries`
+ filtered by `type`), or `chat-app.js` if it needs its own local realtime
+ state. Import shared helpers from `file-utils.js`/`hub-client.js`/
+ `icon.js` — do not re-implement `formatSize`, the download pipeline, or
+ `Icon`.
+2. **Register it** in `apps.js`'s `APPS` array: `{ key, icon, labelKey,
+ Component }`. `key` is the wire identifier — it must match what you add to
+ the node's allow-list next.
+3. **Node-side allow-list**: add the key to `ALLOWED_APPS` in
+ `webrtc_server.py`. Without this the node refuses `apps_enabled` for any
+ set naming it (`"Unknown app(s): ..."`), so an operator can never turn it
+ on.
+4. **i18n**: at minimum, a `group.tab_<name>` key (the tab's tooltip/label,
+ reused as the Settings checkbox label) in all ten `static/locales/*.js`
+ files. `test_locales.py` holds them to the same key set.
+5. **`webapp.py`'s `_ASSETS`** tuple: add the new file. This is the
+ cache-busting hash's input list — a file imported by the page but missing
+ here can change without the served URL changing, which is the exact bug
+ class `test_asset_versioning.py` exists for. Forgetting this step is
+ silent: nothing errors, a browser just keeps an old copy.
+6. **Test coverage that scans the file set**: `test_hook_ordering.py`
+ (`STATIC_FILES`) and `test_transport_contracts.py`
+ (`test_no_setter_survives_the_state_it_belonged_to`, `SPLIT_FILES`) walk a
+ fixed list of files looking for a whole class of bug each — add the new
+ file to both lists, or it is simply never checked, which fails silently
+ rather than loudly.
+7. **`sync-ui.js`** needs no change — it copies the whole `static/` tree
+ verbatim. Run `npm run sync-ui` in `meshbay-client` after adding the file
+ and confirm it's reported.
+
+No protocol change, no hub change, no `daemon.py` change — steps 3 and 6 are
+the only node-side touches, and both are allow-lists, not new wire messages.
+
+## 5. What does not exist yet
+
+- **Thumbnails/posters.** No generation mechanism, client or node side. A
+ Netflix-style grid or a photo album needs *something* here; the pragmatic
+ v1 (decided but not built) is lazy, client-side: decrypt the image, or a
+ video's first segment, only for tiles actually visible, with an in-memory
+ cache — no new MNP message, no node-side store. This is real per-tile cost
+ (a full chunk-pipelined decrypt per thumbnail), acceptable for a browsing
+ grid, not for hundreds of tiles rendered at once — a virtualized grid is
+ part of building Videos/Photos, not optional.
+- **Videos, Music, Photos themselves.** Deliberately out of scope for the
+ refactor that built this — see `meshbay-draft-v6.md` §2.7. The
+ infrastructure above is proven end-to-end with the two apps that already
+ existed (Chat, Files); a new one is additive.
+- **The offline/loopback settings path.** `member_upload` can be toggled two
+ ways: over a live MNP connection, or (Electron only) via the node's local
+ HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member-
+ upload')`, `group-settings.js`). `apps_enabled` only has the MNP path today.
+ Adding the loopback twin is a `meshbay_node.ui` endpoint plus a
+ `group-settings.js` branch, mirroring the existing `member_upload` one.
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md
index 2ea73c4..72cfdbf 100644
--- a/docs/meshbay-draft-v6.md
+++ b/docs/meshbay-draft-v6.md
@@ -26,6 +26,7 @@
| `docs/invite-pairing-v1.md` | invitations, pairing codes, the node roster — **as built** |
| `docs/per-node-identity-v1.md` | identity keys are per node; the hub stores none |
| `docs/desktop-client-v1.md` | the desktop client in full — shell, device linking, roots, packaging, execution order |
+| `docs/apps.md` | the group UI's plug-in architecture — as built, and how to add an application |
| `devel-phases-next.md` | the roadmap |
---
@@ -43,6 +44,7 @@
| 7 | Accounts | Native registration is **hybrid**: passphrase-derived `auth_key` (the recovery path) plus a device Ed25519 key for day-to-day authentication | E3 / decision 4 |
| 8 | Authorship | Chat senders are **cryptographically authenticated to each other**; an upload has a **provable owner** who may delete it, as the operator may. v5's node-asserted attribution is replaced | operator decision, §2.4b |
| 9 | Node authority | The operator may **close uploading to everyone but themselves**, per group. Signed MNP op, stored on the node, enforced by the node — the hidden button is a courtesy, the refusal is the control | §2.1b |
+| 10 | Client | A group's UI is a **set of pluggable applications** (Chat, Files today), not one monolithic page. Which are shown is a per-group, operator-signed setting on the same pattern as change 9 | §2.7 |
---
@@ -222,6 +224,43 @@ No parameter changes. `keyderive.py` now has a third consumer: the desktop clien
it, and the standing warning is unchanged — **never change those parameters in one
place**; a mismatch does not look like an error, it looks like an account nobody can open.
+### 2.7 The group UI becomes a set of applications
+
+New (2026-08-23). A group had two fixed tabs, Chat and Files, both defined inside one
+monolithic `static/app.js`. Two things motivated splitting it before adding to it: the file
+had become the thing every unrelated change touched, and the roadmap wants three more
+group-level surfaces — a Netflix-style video browser, a Spotify-style music player, a photo
+album viewer. None of the three need a protocol change: the node's indexer already
+classifies files as `video`/`audio`/`image`, and they would read the same `index_sync` /
+`file_req` / `stream_req` messages Files and the video player already use. What they need is
+somewhere to live that is not one file, and a way for an operator to turn one off.
+
+**The shape.** `group-page.js` is now the shell: the WebRTC connection, the file index, the
+tab bar, and the video/preview modals, none of which are Files- or Chat-specific. `apps.js`
+holds the registry — `[{ key, icon, labelKey, Component }]` — and every registered
+component receives the same props object from the shell, spread rather than hand-listed, so
+adding an app changes no code in the shell itself. Chat and Files each moved to their own
+file (`chat-app.js`, `files-app.js`) to prove the mechanism; nothing else exists yet.
+
+**Enablement is the same pattern as change 9, on purpose.** `apps_enabled` is a per-group
+setting: lives on the node (`roster.db`, not the hub, not `node.toml`, for the identical
+reason `member_upload` does — a hub or a config file that decided this would have authority
+over the node), changed by a signed operator instruction (`OP_APPS_ENABLED`), enforced by
+the node refusing to store an unrecognised or empty set rather than by the client's
+honesty. **Settings itself is not an app** and cannot be disabled — the one way back if
+everything else were turned off.
+
+**What this does not change.** No new server state on the hub (change 5 stands unmoved: the
+enabled-apps set is group-related state, and it lives on the node like everything else in
+that category). No new adversary or trust boundary — this is a display policy, not a key or
+a permission over content; a member whose client shows a hidden tab's data anyway would
+still be a member the node already serves that data to.
+
+Full detail — the props contract, the file layout, and a checklist for adding a new
+application — is `docs/apps.md`, on the same basis `docs/desktop-client-v1.md` holds the
+desktop client's detail: this document states what changed and what holds, not how to build
+on it.
+
---
## 3. Filesystem portability as a security property
@@ -302,3 +341,6 @@ design, which is long and belongs in one place:
**`docs/desktop-client-v1.md`** — shell requirements, device-linking protocol and schema,
account creation, node management over signed MNP ops, several roots per group, filesystem
portability, packaging and first run, the web tier, and the execution order for all of it.
+
+**`docs/apps.md`** — the group UI's plug-in architecture (§2.7): the props every
+application receives, the enablement mechanism end to end, and a checklist for adding one.
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()