import { html, useState, useEffect, useCallback, useRef, useMemo, } 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, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; import { APPS, visibleApps } from './apps.js'; import { GroupName } from './group-name.js'; import { useStickyBand } from './sticky.js'; import { FilePreview } from './files-app.js'; import { lazy } from './lazy.js'; // Fetched the first time a video is played / the Settings tab is opened. const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer', html`
`); const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel'); import { reportIndexPush } from './index-dock.js'; import { clearPending, nodePkFromLink, pendingFor } from './invite-link.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'); // The tab bar pins under the navigation bar and tells the application's own // toolbar how far down to pin (style.css, "Sticky chrome"). const tabBand = useStickyBand('--chrome-h'); // Whether this connection has identified a device to the node (`device_hello`). // Held as state, not read off the transport at render time: it is settled // inside connect() and re-settled by every reconnect, and the Chat composer // gates on it — a value only a ref knows about leaves that composer disabled // with no event to bring it back. Fed by transport.onDeviceIdentity below. const [deviceReady, setDeviceReady] = useState(false); 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]); // Whether the tab on screen is still the one the preference picked. The // preferences come from the hub after sign-in, so a group opened directly — a // reload, a link — mounts on the built-in 'chat' before they are known, and // has to move when they land. Not once the reader has picked a tab, and not // when the wizard asked for one. const onDefaultTabRef = useRef(true); const [tab, setTab] = useState(() => { const override = consumeTabOverride(); onDefaultTabRef.current = !override; return override || defaultTab; }); const chooseTab = useCallback((key) => { onDefaultTabRef.current = false; setTab(key); }, []); // 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; const override = consumeTabOverride(); onDefaultTabRef.current = !override; setTab(override || defaultTab); }, [groupId]); useEffect(() => { if (onDefaultTabRef.current) setTab(defaultTab); }, [defaultTab]); 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([]); // Read by the index_progress handler, which connect() installs once. const nodeRootsRef = useRef(nodeRoots); nodeRootsRef.current = nodeRoots; const [isNodeAdmin, setIsNodeAdmin] = useState(false); // 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/MESHBAY_DESIGN.md §9.7. Null until the handshake ack arrives. const [tmdbConfig, setTmdbConfig] = useState(null); // Which folders each app works over. One shape for all of them — a list, // always, even where an app only wants one (docs/MESHBAY_DESIGN.md §9.3): // Videos and Music were single values, which meant a library spread over two // drives could not be described at all. Empty means nothing configured yet, // which every app reads as "show nothing", never "the whole group index". const [appDirectories, setAppDirectories] = useState({}); const appDirs = useCallback( (key) => appDirectories[key] || [], [appDirectories]); // Where chat attachments are written — one directory, because Chat has one // destination rather than a set of folders it reads. const [chatDirectory, setChatDirectory] = useState(''); const [chatLinkPreview, setChatLinkPreview] = useState(true); // Whether members' cross-group Search lists this group. Not an app setting: // it is about the group as a whole, and it hides nothing from this page. const [searchListed, setSearchListed] = useState(true); // MusicBrainz on/off (per-group) — docs/MESHBAY_DESIGN.md §9.8. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); // `op` rides through to the shell — 'replace', 'next' or 'append' // (docs/playlists.md §9.2). It has to be named here: a wrapper that takes // two arguments and forwards two silently turns every "add to queue" in this // group into a "play", and nothing about that reads as wrong at the call // site or here. const onPlayQueue = useCallback((tracks, startIndex, op) => { // Only a replace changes what is on screen; enqueueing something does not // close whatever the reader was already looking at. if (!op || op === 'replace') setVideoEntry(null); if (parentOnPlayQueue) { const annotated = tracks.map(tr => tr.groupId ? tr : { ...tr, groupId }); parentOnPlayQueue(annotated, startIndex, { transportRef, gekRef, groupId }, op); } }, [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 = { ...(await window.MeshBayKeys.bundleKeyPairFields(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]); // Everything one handshake ack tells this page, applied in one place. // // Called by the first connect and again by every automatic reconnect: a node // that restarted is a different process, and its answers are not the ones the // first handshake got. Written once because the two paths drifting is how // `helloworld`'s directories went missing from one of them. const applyAck = useCallback((ack) => { if (!ack) return; setIsNodeAdmin(!!ack.is_node_admin); 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 || '', }); // Every `${group.description}
`} ${group && group.is_admin && html` `} `}${t('device.add_hint')}
${!deviceCode && html` `} ${deviceCode && html`${t('device.add_show')}
${deviceCode}
`}