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