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, cacheGroupIndex, 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 { VideoPlayer } from './video-player.js';
import { GroupSettingsPanel } from './group-settings.js';
import { reportIndexPush } from './index-dock.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/mediacenter.md §5.5. 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/refactor-groups.md §1.6):
// 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/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,
{ video: appDirs('video'), music: appDirs('music'),
photo: appDirs('photo') });
}, [groupId, group, appDirs]);
// 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) => {
// The roots table rides on the delta as of MNP 1.1. Before that it
// travelled only on a full index_sync, which is sent on request — so a
// root added, removed, ejected or plugged by anyone left every other
// client's directory table stale until they reloaded the page.
if (Array.isArray(deltaMsg.roots) && deltaMsg.roots.length) {
setNodeRoots(deltaMsg.roots);
}
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,
{ video: appDirs('video'), music: appDirs('music'),
photo: appDirs('photo') });
return fresh;
});
}, [groupId, group, appDirs]);
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('');
// Belongs to the connection about to be made, not the group just left.
setDeviceReady(false);
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');
// 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;
// Every node the hub lists, in turn — not `nodes[0]` and nothing else.
// The list is in hub registration order, and its head is not
// necessarily a node that can serve the group: one whose config does
// not list it refuses the handshake with "Group not hosted on this
// node". Stopping at the first made that refusal indistinguishable
// from the group being down, while a node that *could* serve it stood
// second in the same list — which is how `media` went dark on
// 2026-09-11 with its only real host online the whole time. The hub no
// longer registers a node for a group it does not claim; trying the
// rest is what keeps one bad entry from being fatal again.
// `transport` holds the attempt in progress, and keeps whichever one
// answers — so it is null after the loop exactly when none did.
let transport = null, ack = null, lastErr = null;
for (const n of nodesData.nodes) {
// 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.
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;
// Set before connect(), because connect() is where device_hello runs —
// and again on every reconnect it makes, which is the case this exists
// for: nothing else tells the page the answer changed.
transport.onDeviceIdentity = (ok) => {
if (!cancelled) setDeviceReady(ok);
};
try {
ack = await transport.connect(
n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
username, userId, session.pendingJoinCode, session.recoveryKey);
break;
} catch (e) {
lastErr = e;
// Closed and dropped before anything else looks at either: an
// attempt that failed must not be handed to `releaseWhenIdle` by
// the unmount cleanup, which exists to keep a *working* connection
// alive for a download still using it.
try { transport.close(); } catch { /* never opened */ }
transport = null;
transportRef.current = null;
if (cancelled) return;
// A refusal that names a state of *this browser* — a code to enter,
// a passphrase, a device to approve — is the same answer from every
// node, and the operator to act on is this one's. Trying the next
// node would only replace it with a less useful message.
if (e.reason && e.reason !== 'not_hosted') throw e;
}
}
if (!transport) throw (lastErr || new Error('no node served this group'));
session.pendingJoinCode = null;
if (cancelled) 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 `_directories` the ack carries, keyed by the app's own
// name — read off the ack rather than from a list of app names held
// here, so an application the node knows about is one this page already
// handles. Three names were hardcoded until 2026-09-10 and `helloworld`
// was not among them, so the app that exists to prove a new one needs
// no special-casing had its directories dropped on arrival. The live
// path below (`onAppDirectories`) was always generic; this was the half
// that was not.
setAppDirectories(Object.fromEntries(
Object.keys(ack)
.filter((k) => k.endsWith('_directories'))
.map((k) => [k.slice(0, -'_directories'.length), ack[k] || []])));
setChatDirectory(ack.chat_directory || '');
setChatLinkPreview(ack.chat_link_preview !== false);
setSearchListed(ack.search_listed !== false);
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
});
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 }));
// One handler for every app's directories, keyed by the app's name.
transport.onAppDirectories = (app, dirs) =>
setAppDirectories((prev) => ({ ...prev, [app]: dirs }));
transport.onChatDirectory = (path) => setChatDirectory(path);
transport.onChatLinkPreview = (on) => setChatLinkPreview(on);
transport.onSearchListed = (listed) => setSearchListed(listed);
transport.onMusicbrainzEnabled = (enabled) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
transport.onRootsChanged = (msg) => {
// `msg.roots &&` would accept `[]`, and an empty array is truthy —
// so a node that could not describe its roots would blank the
// operator's table on an op that actually succeeded. A group always
// has at least one root, so nothing legitimate is dropped here.
if (Array.isArray(msg.roots) && msg.roots.length) {
setNodeRoots(msg.roots);
}
};
// 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) return;
if (onPresence) {
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);
}
// The operator's indexing dock. The push names no root, so the
// page that opened the roots table names it.
if (transport.memberRole === 'operator') {
reportIndexPush(groupId, status, nodeRootsRef.current);
}
};
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');
}
}
if (transport.memberRole === 'operator' && ack.indexing) {
reportIndexPush(groupId, ack.indexing, indexMsg.roots || nodeRootsRef.current);
}
} 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;
// Nothing will update this group's dock row once the page lets go of it.
reportIndexPush(groupId, null);
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) {
// Same reasoning as files-app.js's downloadFile: a click that does
// nothing at all is worse than a refusal.
setError(t('group.download_offline'));
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]);
// Where an attachment goes, answered once for the whole page.
//
// Files does not use this — it uploads into the root being browsed, which is
// the only unambiguous answer once a group can have several writable roots.
// Chat has no folder to browse, so it needs one picked for it, and this is
// the same rule the node applies when a client names no root at all. It
// becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7).
//
const writableRoots = useMemo(
() => nodeRoots.filter((r) => r.writable && r.available !== false),
[nodeRoots]);
// The operator's chosen attachment folder wins where there is one — that is
// what the Chat settings pane is for. Its root has to be writable and
// present, or the choice is stale (they made it read-only, or ejected the
// drive) and the fallback is better than a refusal at send time.
const chatDirRoot = chatDirectory ? chatDirectory.split('/')[0] : '';
const chatDirUsable = Boolean(
chatDirRoot && writableRoots.some((r) => r.name === chatDirRoot));
const attachDir = chatDirUsable ? chatDirectory : '';
// Nowhere to write is a real answer: the paperclip says so rather than
// picking a read-only root and failing at send time.
const attachRoot = chatDirUsable ? chatDirRoot
: writableRoots.length ? writableRoots[0].name
: '';
// 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 unavailRoots = useMemo(() => {
const s = new Set();
for (const r of nodeRoots) if (!r.available) s.add(r.name);
return s;
}, [nodeRoots]);
const availableEntries = useMemo(
() => entries.filter((e) => {
const root = (e.path || '').split('/')[0];
return !root || !unavailRoots.has(root);
}), [entries, unavailRoots]);
// Everything the operator settings panes read, in one object. Built here
// because this is where the state already lives, and passed through
// `group-settings.js` untouched — that page renders the panes without
// knowing what any of them is for, which is what makes adding an app a
// registry entry rather than an edit to the page.
// `Directories` for every registered app, derived from the registry
// rather than written out. Naming them here would mean adding an app
// required editing this file, which is the one thing the plugin
// architecture is supposed to have removed — and the reference app
// (docs/refactor-groups.md §4.1) is what made the difference visible.
const perAppDirectories = useMemo(() => {
const out = {};
for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key);
return out;
}, [appDirs]);
const appSettings = useMemo(() => ({
...perAppDirectories,
chatDirectory,
chatLinkPreview,
tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true,
tmdbLanguage: (tmdbConfig && tmdbConfig.language) || '',
tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized),
musicbrainzEnabled: musicbrainzConfig
? musicbrainzConfig.enabled !== false : true,
}), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig,
musicbrainzConfig]);
const commonProps = {
groupId, transportRef, gekRef, status, username, deviceReady,
entries, availableEntries, nodeDirs, nodeRoots,
setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
// Plural everywhere, and built from the registry rather than a list of app
// names kept here: Videos and Music read a list, Photos always did, and an
// application added to `APPS` gets its own entry without this file
// changing. The scalar `videoRoot`/`audioRoot` shapes are gone from the
// wire too, so nothing anywhere carries them.
...perAppDirectories,
tmdbConfig,
musicbrainzConfig, onPlayQueue, userPrefs,
};
return html`
`}
${needsPass && html`
`}
${needsCode && html`
`}
${/* 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 + '-' + groupId} ...${commonProps} />
`)}
${tab === 'settings' && html`
<${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token}
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
operatorPaired=${operatorPaired} connected=${status === 'connected'}
mnpRoots=${nodeRoots}
enabledApps=${enabledApps}
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
onScanSettings=${(s) => setScanSettings(s)}
searchListed=${searchListed}
entries=${entries} nodeDirs=${nodeDirs}
appSettings=${appSettings}
${/* The saving pane already knows what it asked for; this is so
the page's own copy moves at the same time, rather than
waiting for the ack it will not be handed (transport.js
resolves an admin ack against the pending request). */''}
onAppDirectories=${(app, dirs) =>
setAppDirectories((prev) => ({ ...prev, [app]: dirs }))}
onRefreshIndex=${refreshIndex}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
`}
${status === 'offline' && !group && html`