import {
html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
import { t, getLocale, LOCALES } from './i18n.js';
import { Icon } from './icon.js';
import { hubFetch, navigate } from './hub-client.js';
import { APPS } from './apps.js';
import * as platform from './platform.js';
// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
// expects — the two don't share a format (MeshBay's "en" vs TMDB's
// required region, "en-US"). Used only to pre-fill the TMDB language field
// with the operator's own current UI language, a reasonable default they
// can still change; the node never guesses this on its own.
const TMDB_LANGUAGE_BY_LOCALE = {
en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
};
// ── Members Panel ────────────────────────────────────────────────────────
/**
* Everything about the group that is not its files or its chat.
*
* Was "Members", which was a list with three unrelated forms stacked on top of
* it and the group's own controls somewhere else entirely — leaving or deleting
* a group lived in the header, beside its title. One tab now, in sections, with
* the roster last: it is the part that grows without limit, and burying the
* controls under two hundred names is how a tab stops being usable.
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
tmdbConfig, onTmdbConfig, onTmdbEnabled,
musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled,
entries, nodeDirs, videoRoot, onVideoRoot,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
// Node loopback state (Electron-only)
const [nodeDetected, setNodeDetected] = useState(false);
const [nodeRoots, setNodeRoots] = useState([]);
const [nodeGroupName, setNodeGroupName] = useState('');
const [nodeBusy, setNodeBusy] = useState(false);
const [nodeMsg, setNodeMsg] = useState('');
// Bytes-based indexing progress while a newly added directory is being
// scanned — same source as the Create Group wizard's step, see
// platform.watchIndexProgress.
const [nodeIndexProgress, setNodeIndexProgress] = useState(null);
const loadNodeInfo = useCallback(async () => {
if (!platform.node.available) return;
try {
const detect = await platform.node.detect();
if (!detect.detected) { setNodeDetected(false); return; }
setNodeDetected(true);
const data = await platform.node.call('GET', '/api/groups');
const groups = data.groups || [];
const ng = groups.find(g => g.id === groupId);
if (ng) {
setNodeRoots(ng.roots || []);
setNodeGroupName(ng.name || '');
}
} catch { setNodeDetected(false); }
}, [groupId]);
useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]);
const [inviteCode, setInviteCode] = useState(null);
const [pairCode, setPairCode] = useState('');
const [pairStatus, setPairStatus] = useState('');
const [pairing, setPairing] = useState(false);
// Your own devices on this node. Not a members feature — it is beside them
// because this is where a live connection to the node exists.
const [devices, setDevices] = useState([]);
const [approveCode, setApproveCode] = useState('');
const [deviceMsg, setDeviceMsg] = useState('');
// Pairing lives here rather than in Settings because this is where a live
// connection to the node exists — and it is offered only when the node itself
// says this account is its operator (is_node_admin comes from the authenticated
// handshake_ack, not from the hub).
const loadDevices = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
const out = await transport.listDevices();
setDevices(out.devices);
} catch { /* a node that has none says so by listing none */ }
}, [transportRef]);
useEffect(() => { loadDevices(); }, [loadDevices]);
const approveDevice = useCallback(async (e) => {
e.preventDefault();
const code = approveCode.trim();
if (!code) return;
setDeviceMsg('');
try {
await transportRef.current.approveDevice(userId, code);
setApproveCode('');
setDeviceMsg(t('device.approved'));
await loadDevices();
} catch (err) { setDeviceMsg(err.message); }
}, [approveCode, userId, transportRef, loadDevices]);
const revokeDevice = useCallback(async (device) => {
if (!confirm(t('device.revoke_confirm'))) return;
setDeviceMsg('');
try {
await transportRef.current.revokeDevice(
userId, device.pk_ed25519, device.pk_x25519 || '');
await loadDevices();
} catch (err) { setDeviceMsg(err.message); }
}, [userId, transportRef, loadDevices]);
const doPair = useCallback(async (e) => {
e.preventDefault();
const code = pairCode.trim();
if (!code) return;
setPairing(true);
setPairStatus('');
try {
const transport = transportRef && transportRef.current;
if (!transport || !transport.connected) throw new Error('Not connected to the node');
await transport.pairOperator(userId, code);
setPairCode('');
setPairStatus('paired');
// The node has pinned this key as an operator key; the form has nothing
// left to do. It used to stay put through a refresh, because what governed
// it was the account, which pairing does not change.
if (onPaired) onPaired();
} catch (err) {
setPairStatus(err.message);
} finally {
setPairing(false);
}
}, [pairCode, transportRef, userId]);
const [uploadBusy, setUploadBusy] = useState(false);
const [uploadMsg, setUploadMsg] = useState('');
/**
* Close or open uploading for everyone who is not the operator.
*
* Signed, like removing a member: the node refuses an unsigned instruction,
* so this is a request to the node rather than a decision taken here. The
* button does not move until the node has said it did it.
*/
const setUploads = useCallback(async (allowed) => {
const transport = transportRef && transportRef.current;
setUploadMsg('');
setUploadBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.setMemberUpload(allowed, signFn);
if (onMemberUpload) onMemberUpload(allowed);
} catch (err) {
setUploadMsg(err.message);
} finally {
setUploadBusy(false);
}
}, [transportRef, onMemberUpload]);
const [appsBusy, setAppsBusy] = useState(false);
const [appsMsg, setAppsMsg] = useState('');
const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key);
/**
* Toggle one app in or out of the group's enabled set. Same shape as
* `setUploads`: signed, and the checkbox does not move until the node has
* said it did it. Refuses to submit an empty set client-side — the node
* refuses it too, but there is no reason to make a round trip to learn that.
*/
const toggleApp = useCallback(async (key) => {
const next = activeApps.includes(key)
? activeApps.filter(k => k !== key)
: [...activeApps, key];
if (next.length === 0) {
setAppsMsg(t('members.apps_need_one'));
return;
}
const transport = transportRef && transportRef.current;
setAppsMsg('');
setAppsBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.setAppsEnabled(next, signFn);
if (onEnabledApps) onEnabledApps(next);
} catch (err) {
setAppsMsg(err.message);
} finally {
setAppsBusy(false);
}
}, [transportRef, onEnabledApps, activeApps]);
const [scanBusy, setScanBusy] = useState(false);
const [scanMsg, setScanMsg] = useState('');
const [reconcileMinutes, setReconcileMinutes] = useState(
scanSettings ? Math.round(scanSettings.reconcile_interval_secs / 60) : 10);
const [debounceSeconds, setDebounceSeconds] = useState(
scanSettings ? Math.round(scanSettings.debounce_secs) : 2);
// The node is the source of truth; once it has answered, the fields track
// it rather than whatever this browser guessed before connecting.
useEffect(() => {
if (!scanSettings) return;
setReconcileMinutes(Math.round(scanSettings.reconcile_interval_secs / 60));
setDebounceSeconds(Math.round(scanSettings.debounce_secs));
}, [scanSettings]);
/**
* How often the reconciliation backstop runs, and how long a changed file
* is left alone before being hashed. Same shape as toggleApp: signed, and
* the fields do not claim success until the node has confirmed it.
*/
const saveScanSettings = useCallback(async () => {
const transport = transportRef && transportRef.current;
setScanMsg('');
setScanBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.setScanSettings(reconcileMinutes * 60, debounceSeconds, signFn);
const applied = {
reconcile_interval_secs: reconcileMinutes * 60,
debounce_secs: debounceSeconds,
};
if (onScanSettings) onScanSettings(applied);
setScanMsg(t('settings_node.scan_saved'));
} catch (err) {
setScanMsg(err.message);
} finally {
setScanBusy(false);
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
const [tmdbBusy, setTmdbBusy] = useState(false);
const [tmdbMsg, setTmdbMsg] = useState('');
const [tmdbTokenDraft, setTmdbTokenDraft] = useState('');
const [tmdbEnabledBusy, setTmdbEnabledBusy] = useState(false);
const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
// Pre-filled from the operator's own current UI language the first time
// this renders with nothing configured yet — a sensible default, not a
// claim about what the node is actually using until they hit Save.
const [tmdbLanguage, setTmdbLanguage] = useState(
() => (tmdbConfig && tmdbConfig.language)
|| TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
useEffect(() => {
if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language);
}, [tmdbConfig && tmdbConfig.language]);
/**
* Whether TMDB is used at all — per-group (2026-08-24, used to be bundled
* into the same signed op as the token/language below): a real
* media-library group and a test/demo group on the same node need not
* share this decision. Saves immediately on toggle, same as an ordinary
* checkbox-style setting elsewhere — there is nothing else on the form to
* batch it with any more.
*/
const saveTmdbEnabled = useCallback(async (nextEnabled) => {
const transport = transportRef && transportRef.current;
setTmdbMsg('');
setTmdbEnabledBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.setTmdbEnabled(nextEnabled, signFn);
if (onTmdbEnabled) onTmdbEnabled(nextEnabled);
} catch (err) {
setTmdbMsg(err.message);
} finally {
setTmdbEnabledBusy(false);
}
}, [transportRef, onTmdbEnabled]);
/**
* An optional custom API token, and the language TMDB is queried in —
* node-wide, not per-group (docs/mediacenter.md §5.5): one shared
* credential and cache. Same shape as saveScanSettings: signed, and the
* button does not claim success until the node confirms it. The token
* field is cleared after a save either way: it is never echoed back by
* the node (tmdb_config_ack carries only whether one is set, never the
* value), so there is nothing to keep showing.
*/
const saveTmdbConfig = useCallback(async () => {
const transport = transportRef && transportRef.current;
setTmdbMsg('');
setTmdbBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
const token = tmdbTokenDraft.trim();
await transport.setTmdbConfig(token || undefined, tmdbLanguage, signFn);
setTmdbTokenDraft('');
if (onTmdbConfig) {
onTmdbConfig({
tokenCustomized: token
? true
: (tmdbConfig ? tmdbConfig.tokenCustomized : false),
language: tmdbLanguage,
});
}
setTmdbMsg(t('settings_node.scan_saved'));
} catch (err) {
setTmdbMsg(err.message);
} finally {
setTmdbBusy(false);
}
}, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
// A node that has never had a language explicitly set would otherwise
// query TMDB with none at all — which TMDB itself resolves to English,
// regardless of who the operator is — even though this form already
// *suggests* their own UI language as the value. Applied once,
// automatically, the first time the operator (the only one who can sign
// this) is actually connected to see it: a real default tied to whoever
// runs this particular node, never a single hardcoded language for every
// node. `tmdbConfig.language` being set at all — from this or from an
// explicit save — is what stops it from ever firing again, so "unless
// manually changed" holds regardless of which of the two set it first.
const autoLanguageSetRef = useRef(false);
useEffect(() => {
if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return;
if (autoLanguageSetRef.current) return;
autoLanguageSetRef.current = true;
saveTmdbConfig();
}, [isNodeAdmin, connected, tmdbConfig, saveTmdbConfig]);
const [mbBusy, setMbBusy] = useState(false);
const [mbMsg, setMbMsg] = useState('');
const [mbContactDraft, setMbContactDraft] = useState('');
const [mbEnabledBusy, setMbEnabledBusy] = useState(false);
const mbEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true;
/**
* Whether MusicBrainz is used at all — per-group from the start
* (docs/musicbay.md §3.2/§6). Same shape as saveTmdbEnabled.
*/
const saveMusicbrainzEnabled = useCallback(async (nextEnabled) => {
const transport = transportRef && transportRef.current;
setMbMsg('');
setMbEnabledBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.setMusicbrainzEnabled(nextEnabled, signFn);
if (onMusicbrainzEnabled) onMusicbrainzEnabled(nextEnabled);
} catch (err) {
setMbMsg(err.message);
} finally {
setMbEnabledBusy(false);
}
}, [transportRef, onMusicbrainzEnabled]);
/**
* The node-wide MusicBrainz contact string (docs/musicbay.md §3.2) — not
* a secret, unlike TMDB's token, but still cleared from the draft field
* after a save: the node never echoes it back
* (musicbrainz_config_ack carries only whether one is set), so there is
* nothing to keep showing.
*/
const saveMusicbrainzConfig = useCallback(async () => {
const transport = transportRef && transportRef.current;
setMbMsg('');
setMbBusy(true);
try {
if (!transport || !transport.connected) {
throw new Error('Not connected to the node');
}
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
const contact = mbContactDraft.trim();
await transport.setMusicbrainzConfig(contact || undefined, signFn);
setMbContactDraft('');
if (onMusicbrainzConfig) {
onMusicbrainzConfig({
contactConfigured: contact
? true
: (musicbrainzConfig ? musicbrainzConfig.contactConfigured : false),
});
}
setMbMsg(t('settings_node.scan_saved'));
} catch (err) {
setMbMsg(err.message);
} finally {
setMbBusy(false);
}
}, [transportRef, onMusicbrainzConfig, mbContactDraft, musicbrainzConfig]);
// Every folder anywhere in the group's shared index, deepest included —
// `entries[].path` is each file's containing directory (files-app.js's own
// convention), so every ancestor prefix of it is a real folder, and
// `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
//