summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js501
1 files changed, 467 insertions, 34 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 3fdba07..50ee9f6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -638,21 +638,23 @@ function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) {
href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a>
<a class="sidebar-item ${route === '/search' ? 'active' : ''}"
href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a>
- <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}"
- href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a>
</div>
${platform.capabilities.nodeAdmin && hasNodeKey && html`
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.node')}</div>
<a class="sidebar-item ${route === '/node' ? 'active' : ''}"
href="#/node"><${Icon} name="server" /> ${t('node.title')}</a>
+ <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}"
+ href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a>
</div>
`}
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.my_groups')}</div>
${groups.length === 0
? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>`
- : groups.map(g => {
+ : [...groups].sort((a, b) =>
+ (b.last_activity_at || b.created_at || '').localeCompare(
+ a.last_activity_at || a.created_at || '')).map(g => {
// Three states, each backed by something. `node_online` comes from
// the hub's signaling registry and rides on the group list itself,
// so there is no poll and no timer; a connection this browser tried
@@ -984,7 +986,9 @@ function ExplorePage({ token, myGroupIds }) {
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<h2 style="margin:0">${t('explore.title')}</h2>
- <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a>
+ ${platform.node.available && html`
+ <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a>
+ `}
</div>
<div class="file-toolbar" style="margin-bottom:16px">
<input type="text" class="admin-search" placeholder="${t('explore.search')}"
@@ -1028,7 +1032,12 @@ function ExplorePage({ token, myGroupIds }) {
// ── Create Group Page ────────────────────────────────────────────────────────
-function CreateGroupPage({ token, onCreated }) {
+function CreateGroupPage(props) {
+ if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`;
+ return html`<${CreateGroupFormSimple} ...${props} />`;
+}
+
+function CreateGroupFormSimple({ token, onCreated }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [joinPolicy, setJoinPolicy] = useState('invite');
@@ -1041,8 +1050,6 @@ function CreateGroupPage({ token, onCreated }) {
setLoading(true);
setError('');
try {
- // Derived, not asked: "open" is what makes a group listed, and there is
- // no third combination the server would accept.
const body = { name: name.trim(), join_policy: joinPolicy,
visibility: joinPolicy === 'open' ? 'public' : 'private' };
if (description.trim()) body.description = description.trim().slice(0, 512);
@@ -1083,15 +1090,6 @@ function CreateGroupPage({ token, onCreated }) {
</div>
</div>
- ${/* One question, not two. Visibility and admission were separate
- selectors that could only ever be set together: a public group
- admits everyone by definition, and a private one that anyone may
- join is a directory listing nobody can find. The server already
- refused public+invite with a 422 — the form could build a request
- that could not succeed. Now the answer to "who can join" settles
- both, and the descriptions say what each one means for who can
- *find* the group, which is the part the visibility box was there
- to state and no longer needs to. */ html`
<div class="settings-section">
<h3 class="settings-heading">${t('create_group.join_policy')}</h3>
<div class="choice-list">
@@ -1115,7 +1113,6 @@ function CreateGroupPage({ token, onCreated }) {
</label>
</div>
</div>
- `}
<button class="btn-primary" type="submit" disabled=${loading}>
${loading ? t('create_group.creating') : t('create_group.submit')}
@@ -1125,6 +1122,300 @@ function CreateGroupPage({ token, onCreated }) {
`;
}
+// ── Create Group Wizard (Electron-only) ─────────────────────────────────────
+
+function CreateGroupWizard({ token, onCreated }) {
+ const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done
+ const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result
+ const [error, setError] = useState('');
+
+ // Step 1 fields
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [joinPolicy, setJoinPolicy] = useState('invite');
+ const [roots, setRoots] = useState([]);
+ const [uploadIdx, setUploadIdx] = useState(0);
+
+ // Step 2 progress
+ const [setupSteps, setSetupSteps] = useState([]);
+ const [setupError, setSetupError] = useState('');
+ const [groupId, setGroupId] = useState('');
+
+ // Step 0: detect node
+ const detectNode = useCallback(async () => {
+ setNodeStatus(null);
+ setError('');
+ try {
+ const result = await platform.node.detect();
+ setNodeStatus(result);
+ if (result.detected) {
+ // Auto-link node key to hub if not already done
+ if (result.pk_node_ed25519) {
+ try {
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'PUT', token,
+ body: { pk_node_ed25519: result.pk_node_ed25519 },
+ });
+ } catch { /* already linked or same key */ }
+ }
+ setStep(1);
+ }
+ } catch (err) {
+ setError(err.message);
+ setNodeStatus({ detected: false });
+ }
+ }, [token]);
+
+ useEffect(() => { detectNode(); }, [detectNode]);
+
+ const addRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ if (roots.some(r => r.path === chosen.path)) return;
+ setRoots(prev => [...prev, chosen]);
+ }, [roots]);
+
+ const removeRoot = useCallback((idx) => {
+ setRoots(prev => {
+ const next = prev.filter((_, i) => i !== idx);
+ if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0);
+ return next;
+ });
+ }, [uploadIdx]);
+
+ const runSetup = useCallback(async () => {
+ setStep(2);
+ setSetupError('');
+ const steps = [
+ { label: t('wizard.step_create_hub'), status: 'pending' },
+ { label: t('wizard.step_attach'), status: 'pending' },
+ ];
+ if (roots.length > 1)
+ steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
+ steps.push({ label: t('wizard.step_gek'), status: 'pending' });
+ steps.push({ label: t('wizard.step_pair'), status: 'pending' });
+ setSetupSteps([...steps]);
+
+ let si = 0;
+ const update = (status) => {
+ steps[si].status = status;
+ setSetupSteps([...steps]);
+ };
+ const advance = () => { si++; };
+
+ try {
+ // 1. Create group on hub
+ update('running');
+ const body = { name: name.trim(), join_policy: joinPolicy,
+ visibility: joinPolicy === 'open' ? 'public' : 'private' };
+ if (description.trim()) body.description = description.trim().slice(0, 512);
+ const data = await hubFetch('/v1/groups', { method: 'POST', token, body });
+ const gid = data.group_id;
+ setGroupId(gid);
+ update('done');
+ advance();
+
+ // 2. Attach to node with first root
+ update('running');
+ const mainRoot = roots[uploadIdx] || roots[0];
+ const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
+ if (roots.length === 1 || uploadIdx === 0) {
+ attachBody.upload_dir = mainRoot.path;
+ }
+ await platform.node.call('POST', '/api/groups/attach', attachBody);
+ await platform.node.call('POST', '/api/reload');
+ update('done');
+ advance();
+
+ // 3. Add extra roots (if >1)
+ if (roots.length > 1) {
+ update('running');
+ for (let i = 0; i < roots.length; i++) {
+ if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
+ const r = roots[i];
+ await platform.node.call('POST', `/api/groups/${gid}/roots`, {
+ path: r.path, name: r.name,
+ upload: i === uploadIdx,
+ });
+ }
+ update('done');
+ advance();
+ }
+
+ // 4. GEK init
+ update('running');
+ await platform.node.call('POST', `/api/groups/${gid}/gek`);
+ update('done');
+ advance();
+
+ // 5. Generate pairing code
+ update('running');
+ const pairResult = await platform.node.call('POST', '/api/operator/pair');
+ if (pairResult && pairResult.code) {
+ await platform.node.setPairingCode(pairResult.code);
+ }
+ update('done');
+
+ // Reload node config so it picks up the new group
+ try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ }
+
+ setStep(3);
+ if (onCreated) onCreated();
+ } catch (err) {
+ update('error');
+ setSetupError(platform.bridgeMessage(err));
+ }
+ }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]);
+
+ // Step 0: Node detection
+ if (step === 0) {
+ if (nodeStatus === null) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.detecting')}</p>
+ </div>`;
+ }
+ if (!nodeStatus.detected) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.node_not_found')}</p>
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <div style="display:flex;gap:8px;margin-top:16px">
+ <button class="btn btn-primary" onClick=${detectNode}>
+ ${t('wizard.retry')}</button>
+ </div>
+ </div>`;
+ }
+ }
+
+ // Step 1: Group details + directories
+ if (step === 1) {
+ const canProceed = name.trim() && roots.length > 0;
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
+
+ <div class="settings-section">
+ <div class="form-field">
+ <label class="form-label">${t('create_group.name')}</label>
+ <input type="text" placeholder="${t('create_group.name_placeholder')}"
+ value=${name} onInput=${e => setName(e.target.value)} required autofocus />
+ </div>
+
+ <div class="form-field">
+ <label class="form-label">${t('create_group.description')}</label>
+ <textarea class="form-textarea" rows="3" maxlength="512"
+ placeholder="${t('create_group.description_hint')}"
+ value=${description}
+ onInput=${e => setDescription(e.target.value)} />
+ <div class="form-char-count">${description.length}/512</div>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
+ <div class="choice-list">
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
+ <${Icon} name="lock" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
+ <${Icon} name="globe" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
+ </span>
+ </label>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('wizard.directories')}</h3>
+ <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
+ ${t('wizard.directories_hint')}</p>
+ ${roots.map((r, i) => html`
+ <div class="wizard-root" key=${r.path}>
+ <div class="wizard-root-info">
+ <${Icon} name="folder" />
+ <span class="wizard-root-name">${r.name}</span>
+ <span class="wizard-root-path">${r.path}</span>
+ ${i === uploadIdx && html`
+ <span class="node-root-badge">${t('wizard.upload_target')}</span>`}
+ </div>
+ <div class="wizard-root-actions">
+ ${roots.length > 1 && i !== uploadIdx && html`
+ <button class="btn btn-small btn-secondary"
+ onClick=${() => setUploadIdx(i)}>
+ ${t('wizard.set_upload')}</button>`}
+ <button class="btn btn-small btn-danger"
+ onClick=${() => removeRoot(i)}>
+ ${t('wizard.remove')}</button>
+ </div>
+ </div>
+ `)}
+ <button class="btn btn-secondary" style="margin-top:8px"
+ onClick=${addRoot}>
+ <${Icon} name="folder-plus" /> ${t('wizard.add_directory')}
+ </button>
+ </div>
+
+ <div style="display:flex;gap:8px;margin-top:16px">
+ <button class="btn btn-primary" disabled=${!canProceed}
+ onClick=${runSetup}>
+ ${t('wizard.create_and_setup')}</button>
+ </div>
+ </div>`;
+ }
+
+ // Step 2: Automatic setup progress
+ if (step === 2) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.setting_up')}</p>
+ <div class="wizard-progress">
+ ${setupSteps.map((s, i) => html`
+ <div class="wizard-step wizard-step-${s.status}" key=${i}>
+ <span class="wizard-step-icon">
+ ${s.status === 'done' ? '✓' :
+ s.status === 'running' ? '●' :
+ s.status === 'error' ? '✗' : '○'}
+ </span>
+ <span>${s.label}</span>
+ </div>
+ `)}
+ </div>
+ ${setupError && html`
+ <div class="error-msg" style="margin-top:16px">${setupError}</div>
+ <div style="display:flex;gap:8px;margin-top:8px">
+ <button class="btn btn-primary" onClick=${runSetup}>
+ ${t('wizard.retry')}</button>
+ <button class="btn btn-secondary" onClick=${() => {
+ if (onCreated) onCreated();
+ navigate('/');
+ }}>
+ ${t('wizard.finish_later')}</button>
+ </div>
+ `}
+ </div>`;
+ }
+
+ // Step 3: Done
+ return html`<div class="page-content">
+ <h2>${t('wizard.done_title')}</h2>
+ <p class="page-message">${t('wizard.done_message')}</p>
+ <button class="btn btn-primary" style="margin-top:16px"
+ onClick=${() => navigate(`/groups/${groupId}`)}>
+ ${t('wizard.go_to_group')}</button>
+ </div>`;
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────────
const FILE_ICONS = {
@@ -1322,8 +1613,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [previewEntry, setPreviewEntry] = useState(null);
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);
@@ -1477,6 +1779,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
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.
if (onPresence) onPresence(groupId, 'online');
@@ -2170,7 +2473,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
${tab === 'chat' && status === 'connected' && html`
<${ChatPanel} transportRef=${transportRef} username=${username}
entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex}
- mayUpload=${mayUpload}
+ mayUpload=${mayUpload} onActivity=${touchActivity}
onPreview=${(entry) => {
if (entry.type === 'video') setVideoEntry(entry);
else setPreviewEntry(entry);
@@ -2194,7 +2497,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onPaired=${() => setOperatorPaired(true)} />
`}
`}
- ${status === 'offline' && html`
+ ${status === 'offline' && !group && html`
<p class="page-message">
${t('group.offline_title')}
${' '}${t('group.offline_hint')}
@@ -2381,6 +2684,31 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
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('');
+
+ 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('');
@@ -2498,7 +2826,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
setError('');
setRemoving(member.user_id);
try {
- if (transport && transport.connected && operatorPaired) {
+ if (platform.node.available) {
+ try {
+ await platform.node.call('POST',
+ `/api/members/${member.user_id}/revoke?group_id=${groupId}`);
+ } catch { /* best effort — node may not host this group */ }
+ try {
+ await platform.node.call('POST',
+ `/api/members/${member.user_id}/unpin`);
+ } catch { /* best effort */ }
+ } else if (transport && transport.connected && operatorPaired) {
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
@@ -2585,7 +2922,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${/* Inviting needs the node: it is the node that wraps the group key and
- issues the code, not the hub. */ isAdmin && html`
+ issues the code, not the hub. Public groups admit anyone — no invite. */
+ isAdmin && group?.join_policy !== 'open' && html`
<div class="settings-section">
<h3 class="settings-heading">${t('members.invite_title')}</h3>
${!connected && html`
@@ -2696,9 +3034,96 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- ${/* Before the roster, not after it: this is what someone came here to
- do, and a list of two hundred names is a long way to scroll for
- it. */ html`
+ ${/* Roots management (Electron-only, when node is local) */
+ nodeDetected && nodeRoots.length > 0 && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.roots')}</h3>
+ ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
+ <div class="node-roots">
+ ${nodeRoots.map(r => html`
+ <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
+ key=${r.name}>
+ <div class="node-root-info">
+ <span class="node-root-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.upload && html`
+ <span class="node-root-badge">${t('node.upload_root')}</span>`}
+ ${!r.available && html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('node.unavailable')}</span>`}
+ </div>
+ ${nodeRoots.length > 1 && !r.upload && html`
+ <button class="btn btn-small btn-danger"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('DELETE',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_removed'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${t('node.remove_root')}</button>`}
+ </div>
+ `)}
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${nodeBusy}
+ onClick=${async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ await platform.node.call('POST',
+ '/api/groups/' + groupId + '/roots',
+ { path: chosen.path, name: chosen.name });
+ await platform.node.call('POST', '/api/reload');
+ setNodeMsg(t('node.root_added'));
+ await loadNodeInfo();
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ </div>
+ </div>
+ `}
+
+ ${/* Upload toggle via loopback when MNP not connected */
+ nodeDetected && !connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">
+ ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
+ </span>
+ <button class="admin-btn" disabled=${nodeBusy}
+ onClick=${async () => {
+ setNodeBusy(true); setNodeMsg('');
+ try {
+ const newVal = !memberUpload;
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/member-upload',
+ { allowed: newVal });
+ if (onMemberUpload) onMemberUpload(newVal);
+ } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
+ finally { setNodeBusy(false); }
+ }}>
+ ${memberUpload ? t('members.uploads_disable')
+ : t('members.uploads_enable')}
+ </button>
+ </div>
+ <p class="settings-hint">${t('members.uploads_hint')}</p>
+ </div>
+ `}
+
+ ${/* Delete/leave — node detach first (reversible), then hub delete
+ (irreversible). */ html`
<div class="settings-section">
<h3 class="settings-heading">
${isOwner ? t('group.delete_group') : t('group.leave')}
@@ -2713,6 +3138,15 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<button class="admin-btn danger" onClick=${async () => {
if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
try {
+ // Node detach first (reversible), then hub delete (irreversible)
+ if (nodeDetected && nodeGroupName) {
+ try {
+ await platform.node.call('POST', '/api/groups/detach',
+ { name: nodeGroupName });
+ } catch (detachErr) {
+ if (!confirm(t('settings_node.detach_failed_continue'))) return;
+ }
+ }
await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
navigate('/');
window.location.reload();
@@ -2725,9 +3159,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
try {
await hubFetch('/v1/groups/' + groupId + '/leave',
{ method: 'POST', token });
- // Dropped from the list here rather than reloading: a
- // reload would tear down the WebRTC connections other
- // groups hold.
if (onLeft) onLeft(groupId);
} catch (err) { setError(err.message); }
}}>${t('group.leave')}</button>
@@ -2899,7 +3330,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
}
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true }) {
+ onPreview, mayUpload = true, onActivity }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -3072,6 +3503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
thread_id: null,
}]);
jumpToBottom();
+ if (onActivity) onActivity();
} catch {
setInput(text);
} finally {
@@ -5248,7 +5680,7 @@ function NodePage({ token, username, userId, groups }) {
if (status === 'idle' || status === 'connecting') {
return html`<div class="page-content">
- <p class="page-message">${t('status.connecting')}</p>
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
</div>`;
}
if (status === 'no_groups') {
@@ -5306,9 +5738,8 @@ function NodePage({ token, username, userId, groups }) {
${!r.available && html`
<span class="node-root-badge node-root-badge-warn">
${t('node.unavailable')}</span>`}
- <span class="node-root-kind">${r.kind}</span>
</div>
- ${(g.roots || []).length > 1 && html`
+ ${(g.roots || []).length > 1 && !r.upload && html`
<button class="btn btn-small btn-danger"
disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}>
${t('node.remove_root')}</button>`}
@@ -5322,11 +5753,11 @@ function NodePage({ token, username, userId, groups }) {
<div class="node-section">
<span class="settings-heading">${t('node.gek')}</span>
- ${g.has_gek ? html`
+ ${g.has_gek ? (g.visibility !== 'public' ? html`
<button class="btn btn-small btn-secondary" disabled=${busy}
onClick=${() => rotateGek(g.id)}>
${t('node.gek_rotate')}</button>
- ` : html`
+ ` : html`<p class="node-hint">${t('node.gek_public_hint')}</p>`) : html`
<p class="node-hint">${t('node.gek_init_hint')}</p>
`}
</div>
@@ -5433,7 +5864,9 @@ function NodePage({ token, username, userId, groups }) {
`}
</div>
- ${(() => {
+ ${/* In Electron, the Create Group wizard handles attaching. Keep for
+ browser users who manage nodes via MNP. */
+ !platform.node.available && (() => {
const hostedIds = new Set(nodeGroups.map(g => g.id));
const unhosted = (groups || []).filter(g => !hostedIds.has(g.id));
if (!unhosted.length) return null;