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 { GroupName } from './group-name.js'; import { FilePreview } from './files-app.js'; import { VideoPlayer } from './video-player.js'; import { MusicPlayerBar } from './music-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); // 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) + whether a contact string is configured // (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); // What music-app.js hands over when a track/album is clicked — owned here // (not by music-app.js) so playback survives switching tabs, the same // reasoning the video/preview modals are shell-owned. `nonce` makes // "play this same album again from track 0" a distinct value every time, // so MusicPlayerBar's queue-init effect always re-runs. const [musicQueue, setMusicQueue] = useState(null); // Starting one player stops the other — both would otherwise keep playing // at once, found live: opening a film while a track was going left both // audio tracks running together. onPlayQueue closes the video modal for // the same reason `entry.type === 'video'` below stops the music queue. const onPlayQueue = useCallback((tracks, startIndex) => { setVideoEntry(null); setMusicQueue({ tracks, startIndex, nonce: Date.now() }); }, []); // This component instance is not remounted when switching to a *different* // group on the same /group/:id route (only groupId as a prop changes, see // the connect effect's own comment below) — so leaving music playing here // would carry it into whatever group is opened next. Tab switches inside // one group must not stop it; leaving the group itself must. useEffect(() => { setMusicQueue(null); }, [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(''); 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, group ? group.owner_username : null, fresh); }, [groupId, group]); // 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); return 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; // 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.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, contactConfigured: !!ack.musicbrainz_contact_configured, }); // 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.onMusicbrainzConfig = (cfg) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg })); 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.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); }; transport.onIndexDelta = (msg) => { if (cancelled) return; applyIndexDelta(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. // 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); // 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) => { // Opening a film stops whatever the Music player was doing — see the // matching setVideoEntry(null) in onPlayQueue above for the reverse case. if (entry.type === 'video') { setMusicQueue(null); 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]); 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, videoRoot, onVideoRoot: (path) => setVideoRoot(path), audioRoot, onAudioRoot: (path) => setAudioRoot(path), photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots), tmdbConfig, musicbrainzConfig, onPlayQueue, }; return html`

${group ? html`<${GroupName} name=${group.name} owner=${group.owner_username} />` : t('group.default_name')}

${editingDesc ? html`
` : html` ${group && group.description && html`

${group.description}

`} ${group && group.is_admin && html` `} `}
${group && html` `}
${error && html`
${error}${' '}
`} ${needsDevice && html`

${t('device.add_title')}

${t('device.add_hint')}

${!deviceCode && html` `} ${deviceCode && html`

${t('device.add_show')}

${deviceCode}

`}
`} ${needsCode && html`

${t('group.join_code_title')}

${t('group.join_code_hint')}

setCodeInput(e.target.value)} required />
`} ${/* 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`
${apps.map(a => html` `)}
${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)} scanSettings=${scanSettings} onScanSettings=${(s) => setScanSettings(s)} tmdbConfig=${tmdbConfig} onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))} onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))} musicbrainzConfig=${musicbrainzConfig} onMusicbrainzConfig=${(cfg) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg }))} onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))} entries=${entries} nodeDirs=${nodeDirs} videoRoot=${videoRoot} onVideoRoot=${(path) => setVideoRoot(path)} audioRoot=${audioRoot} onAudioRoot=${(path) => setAudioRoot(path)} photoRoots=${photoRoots} onPhotoRoots=${(roots) => setPhotoRoots(roots)} onRefreshIndex=${refreshIndex} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> `} `} ${status === 'offline' && !group && html`

${t('group.offline_title')} ${' '}${t('group.offline_hint')}

`} ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`

${' '}${t('status.connecting_short')}

`} ${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)} /> `} ${/* Outside the tab-switched area on purpose (docs/musicbay.md §2.3): once something has been played this session, the bar stays mounted and keeps playing regardless of which tab is active — switching to Chat or Files must not stop the music. Renders nothing of its own until onPlayQueue has been called once. Its own close button, and the effect above on leaving the group, both go through the same setMusicQueue(null) — the bar's own unmount cleanup is what actually stops playback. */ musicQueue && html` <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue} userPrefs=${userPrefs} onClose=${() => setMusicQueue(null)} /> `}
`; } export { GroupPage };