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, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; import { visibleApps } from './apps.js'; import { GroupName } from './group-name.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, onPlayQueue: parentOnPlayQueue, onStopMusic }) { 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'; // The create-group wizard can request a one-shot landing tab (Settings, where // the invite form is) via session.openGroupTab. It applies once, only to the // group it names, and never touches the user's default-tab preference — every // other way into a group still lands on that preference, or 'chat'. const consumeTabOverride = useCallback(() => { const o = session.openGroupTab; if (o && o.groupId === groupId) { session.openGroupTab = null; return o.tab; } return null; }, [groupId]); const [tab, setTab] = useState(() => consumeTabOverride() || defaultTab); // GroupPage is not remounted when switching groups (see the refreshedRef note // below), so react to a real groupId change here — but not to the initial // mount, where useState already picked the right tab. const tabGroupRef = useRef(groupId); useEffect(() => { if (tabGroupRef.current === groupId) return; tabGroupRef.current = groupId; setTab(consumeTabOverride() || 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); // Declared here rather than beside the render, because the effect below // depends on it and a `const` further down would be in its temporal dead // zone — the hook-ordering trap this codebase has already paid for. const apps = visibleApps(enabledApps); // The landing tab is chosen before the node has said which applications this // group has, and a preference is a preference — not a promise that the app // exists here. Two ways to land on a tab that renders nothing at all, with no // tab shown active and no way to tell what went wrong: the group has Chat // disabled while 'chat' is the default, or the reader's preferred app is one // this group does not run. The first app the group *does* offer is the // answer to both. // // Also covers an operator disabling the app someone is currently looking at: // `enabledApps` changes live over `apps_enabled`, and being moved to a // working tab beats being left staring at an empty panel. // // Settings is exempt: it is not an application, it is never in `apps`, and // the create-group wizard lands on it deliberately. useEffect(() => { if (tab === 'settings') return; if (!apps.length || apps.some(a => a.key === tab)) return; setTab(apps[0].key); }, [enabledApps, tab]); // Reconcile interval / debounce currently in effect on the node — shown // to the operator in Settings, not enforced from here (indexer.py owns // that). Null until the handshake ack arrives. const [scanSettings, setScanSettings] = useState(null); // TMDB on/off + whether a custom token is set, node-wide (not per-group) — // docs/mediacenter.md §5.5. Null until the handshake ack arrives. const [tmdbConfig, setTmdbConfig] = useState(null); // Which folder is the Videos app's entry point for this group — '' // (the default) means the whole group index. Set from Files, per-group. const [videoRoot, setVideoRoot] = useState(''); // Same shape — the Music app's own entry point. const [audioRoot, setAudioRoot] = useState(''); // The Photos app's entry points — a *list*, unlike videoRoot/audioRoot // above (docs/photos.md §2.1: a photo library is routinely scattered // across several folders). Empty means nothing configured yet. const [photoRoots, setPhotoRoots] = useState([]); // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); const onPlayQueue = useCallback((tracks, startIndex) => { setVideoEntry(null); if (parentOnPlayQueue) { const annotated = tracks.map(tr => tr.groupId ? tr : { ...tr, groupId }); parentOnPlayQueue(annotated, startIndex, { transportRef, gekRef, groupId }); } }, [parentOnPlayQueue, groupId]); // 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(''); // This browser has never derived the passphrase-bundle key (fresh browser, // cleared storage, or a device-key sign-in). Ask for the passphrase here // rather than sending someone back to the browser they registered on. const [needsPass, setNeedsPass] = useState(false); const [passInput, setPassInput] = useState(''); const [passBusy, setPassBusy] = useState(false); 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; setNeedsPass(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]); const submitPass = useCallback(async (e) => { e.preventDefault(); const pass = passInput; if (!pass || !window.MeshBayKeys) return; setPassBusy(true); setError(''); try { // Same derivation as sign-in — the token is already ours, only the key // that opens node bundles is missing here. Persisted so this browser is // set up from now on. session.bundleKey = { v2: await window.MeshBayKeys.deriveEncryptionKey(pass, username), v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username), }; await _storeBundleKey(session.bundleKey); setPassInput(''); setNeedsPass(false); setRetryKey(k => k + 1); } catch (err) { setError(err.message); } finally { setPassBusy(false); } }, [passInput, username]); // 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, group ? group.owner_username : null, fresh, { videoRoot, audioRoot, photoRoots }); }, [groupId, group, videoRoot, audioRoot, photoRoots]); // additions/deletions/updates (daemon.py _broadcast_index_change, once // there is a previous snapshot to diff against) — applied on top of // whatever applyIndex last put in `entries`, instead of replacing the // whole table for one changed file. `updates` is the Videos app's async // enrichment (duration/thumb_hash/display_title/...) arriving for a file // already in the table — same id, new fields (see group_index.py diff()). const applyIndexDelta = useCallback((deltaMsg) => { setEntries((prev) => { const deletions = new Set(deltaMsg.deletions || []); const kept = prev.filter((e) => !deletions.has(e.id)); const updates = new Map((deltaMsg.updates || []).map((e) => [e.id, e])); const updated = kept.map((e) => updates.get(e.id) || e); // The index is keyed by content hash: an addition whose id is already // present is the same duplicate-content case indexer.py's own // reconcile sweep leaves alone, not a second row for one file. const keptIds = new Set(updated.map((e) => e.id)); const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id)); const fresh = updated.concat(additions); cacheGroupIndex(groupId, group ? group.name : groupId, group ? group.owner_username : null, fresh, { videoRoot, audioRoot, photoRoots }); return fresh; }); }, [groupId, group, videoRoot, audioRoot, photoRoots]); 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(); // Persisted (docs/auth-confirm.md §4.3) so a group joined in a later // session still leaves a recovery-wrapped identity copy on its node. if (!session.recoveryKey) session.recoveryKey = await _loadRecoveryKey(); if (!session.bundleKey && window.MeshBayKeys) { // Nothing to sign or unwrap with in this browser yet — ask for the // passphrase instead of failing into a "go back to your other browser" // message. if (!cancelled) { setNeedsPass(true); setStatus('idle'); } return; } 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; // Consulted only by the automatic reconnect after a WebRTC failure // (transport.js's _reconnectLoop) — the token captured by this // connect() call can be stale by then, since the whole point is that // some real time (screen lock, a dead NAT mapping) passed unnoticed. transport.onNeedToken = async () => (await ensureFreshToken()) || token; const ack = await transport.connect( nodeId, live, groupId, null, sessionKeys, session.bundleKey, username, userId, session.pendingJoinCode, session.recoveryKey); session.pendingJoinCode = null; if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); setMemberUpload(ack.member_upload !== false); setEnabledApps(ack.enabled_apps || null); setScanSettings(ack.scan_settings || null); setTmdbConfig({ // Per-group (2026-08-24, used to be node-wide). enabled: ack.tmdb_enabled !== false, // Node-wide — one shared credential/cache. tokenCustomized: !!ack.tmdb_token_customized, language: ack.tmdb_language || '', }); setVideoRoot(ack.video_root || ''); setAudioRoot(ack.audio_root || ''); setPhotoRoots(ack.photo_roots || []); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== 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); transport.onAppsEnabled = (apps) => setEnabledApps(apps); // Two independent acks now (tmdb_config_ack: token/language, // node-wide; tmdb_enabled_ack: the per-group switch) — each merges // its own slice into the one tmdbConfig object rather than // replacing it, so one changing does not clobber the other's most // recent value. transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg })); transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled })); transport.onVideoRoot = (path) => setVideoRoot(path); transport.onAudioRoot = (path) => setAudioRoot(path); transport.onPhotoRoots = (roots) => setPhotoRoots(roots); transport.onMusicbrainzEnabled = (enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled })); // The node's own scan (a root added while we were already connected, // or reconcile catching one back up) — never the entries, just // enough to animate the sidebar dot. Guaranteed a final push at the // False transition (daemon.py _progress_pusher), so this always // settles back to 'online' rather than getting stuck. transport.onIndexProgress = (status) => { if (cancelled || !onPresence) return; const pct = status.total_bytes ? Math.min(100, Math.round(100 * status.scanned_bytes / status.total_bytes)) : 0; onPresence(groupId, status.scanning ? 'indexing' : 'online', pct); }; 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.newNodeBundleRecovery); transport.newNodeBundle = null; transport.newNodeBundleRecovery = 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); }; transport.onIndexDelta = (msg) => { if (cancelled) return; applyIndexDelta(msg); }; // A pushed message that will not open under the group key ends the // session (transport.js _failSession). Nothing is waiting on a push, so // without this the page would keep showing a stale index with nothing // wrong on screen — the worst of the three failure shapes. transport.onSessionFailed = (err) => { if (cancelled) return; setError(err.message); setStatus('error'); if (onPresence) onPresence(groupId, 'online'); }; // 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. // A scan already under way at the moment of connecting (ack.indexing, // webrtc_server.py _complete_handshake) shows as indexing right away // rather than waiting for the next periodic push. if (onPresence) { const idx = ack.indexing; if (idx && idx.scanning) { const pct = idx.total_bytes ? Math.min(100, Math.round(100 * idx.scanned_bytes / idx.total_bytes)) : 0; onPresence(groupId, 'indexing', pct); } else { 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); // The node has no bundle for us and this browser derived no key to make // one — the passphrase form below is the way in, not a support request. if (err.reason === 'no_keys') setNeedsPass(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. Audio goes to the same persistent player Music // uses (onPlayQueue) rather than a modal — but the queue it builds is // Explorer's own next/previous, deliberately not Music's: every audio // entry sharing this file's literal containing directory, in filename // order, never Music's artist/album grouping, which would pull in files // nowhere near this one on disk. onPlayQueue always replaces whatever is // already playing (from Music, or a previous Files click) rather than // merging with it, so there is nothing to special-case here. const onPreview = useCallback((entry) => { if (entry.type === 'video') { if (onStopMusic) onStopMusic(); setVideoEntry(entry); return; } if (entry.type === 'audio') { const siblings = entries .filter((e) => e.type === 'audio' && e.path === entry.path) .sort((a, b) => a.name.localeCompare(b.name)); const startIndex = Math.max(0, siblings.findIndex((e) => e.id === entry.id)); onPlayQueue(siblings, startIndex); return; } setPreviewEntry(entry); }, [entries, onPlayQueue, onStopMusic]); const commonProps = { groupId, transportRef, gekRef, status, username, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, onRefreshIndex: refreshIndex, onActivity: touchActivity, videoRoot, onVideoRoot: (path) => setVideoRoot(path), audioRoot, onAudioRoot: (path) => setAudioRoot(path), photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots), tmdbConfig, musicbrainzConfig, onPlayQueue, }; return html`
${group.description}
`} ${group && group.is_admin && html` `} `}${t('device.add_hint')}
${!deviceCode && html` `} ${deviceCode && html`${t('device.add_show')}
${deviceCode}
`}