diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-19 16:36:29 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-19 16:36:29 +0200 |
| commit | 90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (patch) | |
| tree | 779f5ee3557d5111e0a7f1fdc211a2845bc3bc69 /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | 9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a (diff) | |
| download | meshbay-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.js | 291 |
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 { |