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); // 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(''); // 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]); // 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, 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; 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({ enabled: ack.tmdb_enabled !== false, tokenCustomized: !!ack.tmdb_token_customized, language: ack.tmdb_language || '', }); setVideoRoot(ack.video_root || ''); // 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); transport.onTmdbConfig = (cfg) => setTmdbConfig(cfg); transport.onVideoRoot = (path) => setVideoRoot(path); // 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. 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, videoRoot, onVideoRoot: (path) => setVideoRoot(path), tmdbConfig, }; return html`

${group ? group.name : 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(cfg)} entries=${entries} nodeDirs=${nodeDirs} videoRoot=${videoRoot} onVideoRoot=${(path) => setVideoRoot(path)} 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)} /> `}
`; } export { GroupPage };