summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/group-page.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js484
1 files changed, 484 insertions, 0 deletions
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 };