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`
${group.description}
`} ${group && group.is_admin && html` `} `}${t('device.add_hint')}
${!deviceCode && html` `} ${deviceCode && html`${t('device.add_show')}
${deviceCode}
`}