aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-19 16:36:29 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-19 16:36:29 +0200
commit90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (patch)
tree779f5ee3557d5111e0a7f1fdc211a2845bc3bc69 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a (diff)
downloadmeshbay-90c3c6a01f46c9b29c5d92702d7383f32e8951d7.tar.gz
feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploads
Hub/UI: - Icon-only group tabs (chat, files, settings) with per-group default tab - Transfer widget: filename becomes a clickable link to open completed downloads - Pulse animation on transfer icon (pale→dark green) while active - Download button feedback in FilePreview (spinner, auto-reset) - Group mute toggle persists across navigation - Login page autofocus, chat refocus after send - Theme toggle closes menu, status badge and duplicate connecting removed - Create-folder restricted to operators, download-path note removed - User preferences API (CRUD) with Alembic migration - Profile: email display/edit via PATCH /v1/users/me - Settings: "Defaults" section for default tab selector - All 10 locale files updated Node: - upload_dir in node.toml: separate filesystem path for uploads - Root.direct flag: uploads land at root path, no subdirectory - CLI --upload-dir flag on `group add` - Admin UI accepts upload_dir Client (Electron): - shell.openPath bridge for opening completed downloads - platform.js passes open callback from native save Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js291
1 files changed, 235 insertions, 56 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 2e3c422..816c0ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -398,6 +398,11 @@ const ICON_PATHS = {
check: ['M4.5 12.5l5 5 10-11'],
chevron: ['M6 9.5l6 6 6-6'],
close: ['M6 6l12 12M18 6L6 18'],
+ chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
+ folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'],
+ 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9',
+ 'M10.3 20a2 2 0 0 0 3.4 0',
+ 'M4 4l16 16'],
};
// The M of the wordmark is a picture; the rest is text. Resolved from this
@@ -472,6 +477,7 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) {
</button>
<button class="user-menu-item" onClick=${() => {
onThemeChange(resolved === 'dark' ? 'light' : 'dark');
+ setOpen(false);
}}>
<${Icon} name=${resolved === 'dark' ? 'sun' : 'moon'} cls="umi-icon" />
${' '}${resolved === 'dark' ? t('usermenu.theme_light') : t('usermenu.theme_dark')}
@@ -532,7 +538,10 @@ function TransferWidget() {
<span class="transfer-kind">
<${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
</span>
- <span class="transfer-name" title=${it.name}>${it.name}</span>
+ ${it.canOpen
+ ? html`<a class="transfer-name" href="#" title=${it.name}
+ onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>`
+ : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`}
${it.status === 'running' && html`
<button class="transfer-cancel" title=${t('transfers.cancel')}
onClick=${() => transfers.cancel(it.id)}>
@@ -738,7 +747,7 @@ function LoginPage() {
<form onSubmit=${onSubmit}>
<input type="text" placeholder="${t('login.username')}" value=${username}
onInput=${e => setUsername(e.target.value)}
- autocomplete="username" required />
+ autocomplete="username" required autofocus />
<input type="password" placeholder="${t('login.password')}" value=${password}
onInput=${e => setPassword(e.target.value)}
autocomplete="current-password" required />
@@ -1284,8 +1293,8 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
return results;
}
-function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
- onJoined, onGroupUpdated, onPresence, onLeft }) {
+function GroupPage({ groupId, group, token, username, userId, userPrefs,
+ onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
@@ -1301,7 +1310,23 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [currentPath, setCurrentPath] = useState('');
const [videoEntry, setVideoEntry] = useState(null);
const [previewEntry, setPreviewEntry] = useState(null);
- const [tab, setTab] = useState('chat');
+ const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat';
+ const [tab, setTab] = useState(defaultTab);
+ const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted));
+
+ 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([]);
@@ -1520,9 +1545,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const target = await _openDownloadTarget(entry.name, entry.size);
if (target === false) return; // the picker was dismissed
+ const openRef = { url: null };
transfers.start({
kind: 'download', name: (target && target.name) || entry.name,
- total: entry.size, transport, open: target && target.open,
+ total: entry.size, transport,
+ open: target
+ ? (target.open || null)
+ : () => { if (openRef.url) window.open(openRef.url, '_blank'); },
run: async ({ signal, onProgress }) => {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let done = 0;
@@ -1540,7 +1569,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
} else {
const chunks = await pipelinedDownload(
transport, gek, entry.id, totalChunks, onChunk, null, signal);
- _saveBlob(new Blob(chunks), entry.name);
+ const blob = new Blob(chunks);
+ _saveBlob(blob, entry.name);
+ openRef.url = URL.createObjectURL(blob);
}
},
});
@@ -1642,10 +1673,14 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
return;
}
const gek = gekRef.current;
+ const zipOpenRef = { url: null };
transfers.start({
kind: 'download', name: (target && target.name) || suggested,
- total: totalBytes, transport, open: target && target.open,
+ total: totalBytes, transport,
+ open: target
+ ? (target.open || null)
+ : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); },
run: async ({ signal, onProgress }) => {
const writable = target ? target.writable : null;
const parts = writable ? null : [];
@@ -1671,7 +1706,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}
await zip.finish();
if (writable) await writable.close();
- else _saveBlob(new Blob(parts, { type: 'application/zip' }), suggested);
+ else {
+ const blob = new Blob(parts, { type: 'application/zip' });
+ _saveBlob(blob, suggested);
+ zipOpenRef.url = URL.createObjectURL(blob);
+ }
} catch (err) {
if (writable) await writable.abort().catch(() => {});
throw err;
@@ -1773,7 +1812,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
// error nobody can act on.
- const canCreateDir = Boolean(currentPath);
+ const canCreateDir = Boolean(currentPath) && isNodeAdmin;
const baseLabel = {
idle: t('status.idle'),
@@ -1786,9 +1825,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}[status] || status;
const statusLabel = baseLabel;
- const statusClass = status === 'connected' ? 'status-ok'
- : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
-
const breadcrumbs = currentPath ? currentPath.split('/') : [];
// Selection is keyed globally — file ids, and 'dir:' plus a full path — so
@@ -1923,11 +1959,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
`}
`}
</div>
- <span class="status-badge ${statusClass}">
- ${(status === 'discovering' || status === 'connecting' || status === 'fetching')
- && html`<span class="spinner"></span>${' '}`}
- ${statusLabel}
- </span>
+ ${group && html`
+ <button class="group-mute-btn" onClick=${toggleGroupMute}
+ title=${groupMuted ? t('group.unmute') : t('group.mute')}>
+ <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} />
+ </button>
+ `}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${needsDevice && html`
@@ -1969,15 +2006,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
chat still need the node and say so. */ group && html`
<div class="group-tabs">
<button class="group-tab ${tab === 'chat' ? 'active' : ''}"
- onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
+ onClick=${() => setTab('chat')} title=${t('group.tab_chat')}>
+ <${Icon} name="chat" cls="tab-icon" /></button>
<button class="group-tab ${tab === 'files' ? 'active' : ''}"
- onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
+ onClick=${() => setTab('files')} title=${t('group.tab_files')}>
+ <${Icon} name="folder" cls="tab-icon" /></button>
<button class="group-tab ${tab === 'settings' ? 'active' : ''}"
- onClick=${() => setTab('settings')}>${t('group.tab_settings')}</button>
+ onClick=${() => setTab('settings')} title=${t('group.tab_settings')}>
+ <${Icon} name="gear" cls="tab-icon" /></button>
</div>
${tab === 'files' && status !== 'connected' && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
`}
${tab === 'files' && status === 'connected' && html`
@@ -2114,7 +2154,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}} />
`}
${tab === 'chat' && status !== 'connected' && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
`}
${tab === 'settings' && html`
@@ -2134,8 +2174,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
${' '}${t('group.offline_hint')}
</p>
`}
- ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
- <p class="page-message">${statusLabel}</p>
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
`}
${previewEntry && html`
<${FilePreview}
@@ -2167,6 +2207,7 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
const [progress, setProgress] = useState(0);
const [content, setContent] = useState(null);
const [error, setError] = useState('');
+ const [downloading, setDownloading] = useState(false);
const blobUrlRef = useRef(null);
useEffect(() => {
@@ -2239,9 +2280,18 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
${onDownload && html`
- <button class="video-close" onClick=${onDownload}
- title="${t('group.download')}">
- <${Icon} name="download" /></button>
+ <button class="video-close ${downloading ? 'dl-active' : ''}"
+ onClick=${() => {
+ if (!downloading) {
+ setDownloading(true);
+ onDownload();
+ setTimeout(() => setDownloading(false), 1500);
+ }
+ }}
+ title="${t('group.download')}" disabled=${downloading}>
+ ${downloading
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
`}
<button class="video-close" onClick=${onClose} title="${t('video.close')}">
<${Icon} name="close" /></button>
@@ -2297,7 +2347,8 @@ function _b64ToU8(b64) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload, onPaired, onLeft }) {
+ memberUpload, onMemberUpload,
+ onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
@@ -2833,6 +2884,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const [attaching, setAttaching] = useState(false);
const listRef = useRef(null);
const panelRef = useRef(null);
+ const inputRef = useRef(null);
const loadedRef = useRef(false);
// Set just before older messages are prepended; read once, after the DOM has
// them but before the browser paints.
@@ -2998,6 +3050,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
setInput(text);
} finally {
setSending(false);
+ setTimeout(() => { if (inputRef.current) inputRef.current.focus(); });
}
}, [input, username, jumpToBottom]);
@@ -3126,7 +3179,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
<input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
</label>
`}
- <textarea class="chat-input" rows="1"
+ <textarea class="chat-input" rows="1" ref=${inputRef}
placeholder="${t('chat.placeholder')}"
value=${input}
onInput=${e => setInput(e.target.value)}
@@ -3224,6 +3277,7 @@ function purgeUnscopedResumePositions() {
purgeUnscopedResumePositions();
function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
+ const [dlBusy, setDlBusy] = useState(false);
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
const videoRef = useRef(null);
@@ -3855,9 +3909,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
${onDownload && html`
- <button class="video-close" onClick=${onDownload}
- title="${t('group.download')}">
- <${Icon} name="download" /></button>
+ <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
+ onClick=${() => {
+ if (!dlBusy) {
+ setDlBusy(true);
+ onDownload();
+ setTimeout(() => setDlBusy(false), 1500);
+ }
+ }}
+ title="${t('group.download')}">
+ ${dlBusy
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
`}
<button class="video-close" onClick=${onClose} title="${t('video.close')}">
<${Icon} name="close" /></button>
@@ -4019,6 +4082,11 @@ function ProfilePage({ user, onLogout }) {
const [currentNodeKey, setCurrentNodeKey] = useState(null);
const [nodeKeyStatus, setNodeKeyStatus] = useState('');
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
+ const [email, setEmail] = useState('');
+ const [emailDraft, setEmailDraft] = useState('');
+ const [emailEditing, setEmailEditing] = useState(false);
+ const [emailSaving, setEmailSaving] = useState(false);
+ const [emailStatus, setEmailStatus] = useState('');
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
const [delOpen, setDelOpen] = useState(false);
@@ -4031,8 +4099,6 @@ function ProfilePage({ user, onLogout }) {
setDeleting(true);
setDelError('');
try {
- // The passphrase is re-checked by the hub, not merely by this form: an
- // open session is not consent to something irreversible.
const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username);
await hubFetch('/v1/users/me', {
method: 'DELETE', token: user.token, body: { auth_key: authKey },
@@ -4045,14 +4111,17 @@ function ProfilePage({ user, onLogout }) {
}
}, [delPass, user]);
- // 11.5.8: node identity pins are refused strictly on change, so users need a
- // deliberate way to accept a legitimate rotation (operator reinstalled a node).
const clearPins = useCallback(() => {
window.MeshBayTransport?.clearNodePin?.();
setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0);
}, []);
useEffect(() => {
+ hubFetch('/v1/users/me', { token: user.token })
+ .then(data => {
+ if (data.email) { setEmail(data.email); setEmailDraft(data.email); }
+ })
+ .catch(() => {});
hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
.then(data => {
if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519);
@@ -4060,6 +4129,26 @@ function ProfilePage({ user, onLogout }) {
.catch(() => {});
}, [user.username, user.token]);
+ const saveEmail = useCallback(async () => {
+ const val = emailDraft.trim();
+ if (!val || val === email) { setEmailEditing(false); return; }
+ setEmailSaving(true);
+ setEmailStatus('');
+ try {
+ await hubFetch('/v1/users/me', {
+ method: 'PATCH', token: user.token, body: { email: val },
+ });
+ setEmail(val);
+ setEmailEditing(false);
+ setEmailStatus(t('settings.email_saved'));
+ setTimeout(() => setEmailStatus(''), 3000);
+ } catch (e) {
+ setEmailStatus(e.message);
+ } finally {
+ setEmailSaving(false);
+ }
+ }, [emailDraft, email, user.token]);
+
const submitNodeKey = useCallback(async () => {
const key = nodeKey.trim();
if (!key) return;
@@ -4091,9 +4180,27 @@ function ProfilePage({ user, onLogout }) {
<span class="settings-value">${user.username}</span>
</div>
<div class="settings-row">
- <span class="settings-label">${t('settings.role')}</span>
- <span class="settings-value">${user.role || 'user'}</span>
+ <span class="settings-label">${t('settings.email')}</span>
+ ${emailEditing
+ ? html`<span style="display:flex;gap:8px;align-items:center">
+ <input type="email" value=${emailDraft}
+ onInput=${e => setEmailDraft(e.target.value)}
+ onKeyDown=${e => e.key === 'Enter' && saveEmail()}
+ style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
+ <button class="admin-btn" onClick=${saveEmail}
+ disabled=${emailSaving}>${t('settings.email_save')}</button>
+ <button class="btn-secondary" onClick=${() => {
+ setEmailEditing(false); setEmailDraft(email);
+ }}>${t('settings.cancel')}</button>
+ </span>`
+ : html`<span style="display:flex;gap:8px;align-items:center">
+ <span class="settings-value">${email || '—'}</span>
+ <button class="link-btn" onClick=${() => setEmailEditing(true)}>
+ <${Icon} name="pencil" /></button>
+ </span>`
+ }
</div>
+ ${emailStatus && html`<p class="settings-hint" style="margin-top:4px">${emailStatus}</p>`}
</div>
<div class="settings-section">
@@ -4164,11 +4271,13 @@ function ProfilePage({ user, onLogout }) {
`;
}
-function SettingsPage({ user, theme, onThemeChange, groups }) {
+function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
const [locale, setLoc] = useState(getLocale);
- // Comes from the hub with the group list, so it is the same on every device.
const [muted, setMuted] = useState(
() => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
+ const [globalMute, setGlobalMute] = useState(false);
+ const [defaultTab, setDefaultTab] = useState('chat');
+
const onLocaleChange = useCallback((e) => {
const code = e.target.value;
setLocale(code);
@@ -4180,10 +4289,30 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
onThemeChange(e.target.value);
}, [onThemeChange]);
+ useEffect(() => {
+ hubFetch('/v1/users/me/preferences', { token: user.token })
+ .then(prefs => {
+ if (prefs.notifications_disabled === 'true') setGlobalMute(true);
+ if (prefs.default_tab) setDefaultTab(prefs.default_tab);
+ })
+ .catch(() => {});
+ }, [user.token]);
+
+ const toggleGlobalMute = useCallback(async () => {
+ const next = !globalMute;
+ setGlobalMute(next);
+ try {
+ await hubFetch('/v1/users/me/preferences/notifications_disabled', {
+ method: 'PUT', token: user.token,
+ body: { value: next ? 'true' : 'false' },
+ });
+ if (onPrefsChange) onPrefsChange({ notifications_disabled: next });
+ } catch (err) {
+ setGlobalMute(!next);
+ }
+ }, [globalMute, user.token, onPrefsChange]);
+
const toggleMute = useCallback(async (gid) => {
- // Server-side: this used to write to localStorage, which nothing read, so
- // muting a group had no effect on anything. The hub now declines to create
- // the notification at all.
const next = !muted[gid];
setMuted(prev => ({ ...prev, [gid]: next }));
try {
@@ -4195,6 +4324,18 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
}
}, [muted, user.token]);
+ const changeDefaultTab = useCallback(async (e) => {
+ const val = e.target.value;
+ setDefaultTab(val);
+ try {
+ await hubFetch('/v1/users/me/preferences/default_tab', {
+ method: 'PUT', token: user.token,
+ body: { value: val },
+ });
+ if (onPrefsChange) onPrefsChange({ default_tab: val });
+ } catch { setDefaultTab(defaultTab); }
+ }, [defaultTab, user.token, onPrefsChange]);
+
const [dlMode, setDlMode] = useState(() => downloads.getMode());
const [dlDir, setDlDir] = useState(null);
const [dlError, setDlError] = useState('');
@@ -4250,9 +4391,7 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
<div class="settings-section">
<h3 class="settings-heading">${t('settings.downloads')}</h3>
- ${!(downloads.SUPPORTED || platform.folder.available)
- ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
- : html`
+ ${(downloads.SUPPORTED || platform.folder.available) && html`
<label class="settings-choice">
<input type="radio" name="dlmode" checked=${dlMode === 'auto'}
onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
@@ -4289,8 +4428,7 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</span>
</div>
${dlError && html`<p class="error-msg">${dlError}</p>`}
- <p class="settings-hint">${t('settings.dl_path_note')}</p>
- `}
+ `}
</div>
<div class="settings-section">
@@ -4313,9 +4451,17 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</div>
</div>
- ${groups.length > 0 && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings.groups')}</h3>
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.groups')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.notif_global_disable')}</span>
+ <label class="settings-value" style="cursor:pointer">
+ <input type="checkbox" checked=${globalMute}
+ onChange=${toggleGlobalMute} />
+ </label>
+ </div>
+ ${!globalMute && html`
+ <p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p>
${groups.map(g => html`
<div class="settings-row" key=${g.id}>
<span class="settings-label">${g.name}</span>
@@ -4326,8 +4472,22 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</label>
</div>
`)}
+ `}
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.defaults')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.default_tab')}</span>
+ <select class="settings-select" value=${defaultTab}
+ onChange=${changeDefaultTab}>
+ <option value="chat">${t('group.tab_chat')}</option>
+ <option value="files">${t('group.tab_files')}</option>
+ <option value="settings">${t('group.tab_settings')}</option>
+ </select>
</div>
- `}
+ <p class="settings-hint">${t('settings.default_tab_hint')}</p>
+ </div>
${platform.isNative && html`
<div class="settings-section">
@@ -4792,6 +4952,8 @@ function App() {
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
+ const [notifDisabled, setNotifDisabled] = useState(false);
+ const [userPrefs, setUserPrefs] = useState({});
const resolved = resolveTheme(theme);
@@ -4828,20 +4990,28 @@ function App() {
}, [theme, resolved]);
const fetchNotifications = useCallback(() => {
- if (!user) return;
+ if (!user || notifDisabled) {
+ setNotifications([]); setUnreadCount(0); return;
+ }
hubFetch('/v1/notifications?limit=20', { token: user.token })
.then(data => {
setNotifications(data.notifications || []);
setUnreadCount(data.unread_count || 0);
})
.catch(() => {});
- }, [user]);
+ }, [user, notifDisabled]);
useEffect(() => {
if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
+ hubFetch('/v1/users/me/preferences', { token: user.token })
+ .then(prefs => {
+ setUserPrefs(prefs || {});
+ if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
+ })
+ .catch(() => {});
fetchNotifications();
}, [user]);
@@ -5062,6 +5232,7 @@ function App() {
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} userId=${user.userId}
+ userPrefs=${userPrefs}
onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications}
onGroupUpdated=${updateGroup} onPresence=${notePresence}
onLeft=${handleLeftGroup} />`;
@@ -5072,7 +5243,15 @@ function App() {
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
- onThemeChange=${setTheme} groups=${groups} />`;
+ onThemeChange=${setTheme} groups=${groups}
+ onPrefsChange=${(p) => {
+ if ('notifications_disabled' in p) {
+ setNotifDisabled(p.notifications_disabled);
+ if (p.notifications_disabled) { setNotifications([]); setUnreadCount(0); }
+ else fetchNotifications();
+ }
+ setUserPrefs(prev => ({ ...prev, ...p }));
+ }} />`;
} else if (route === '/profile') {
page = html`<${ProfilePage} user=${user} onLogout=${authCtx.logout} />`;
} else {