aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/settings-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-page.js316
1 files changed, 316 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
new file mode 100644
index 0000000..c6a73da
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js
@@ -0,0 +1,316 @@
+import {
+ html, useState, useEffect, useCallback,
+} from './vendor/htm-preact.js';
+import { t, getLocale, setLocale, LOCALES } from './i18n.js';
+import * as downloads from './downloads.js';
+import * as platform from './platform.js';
+import { hubFetch } from './hub-client.js';
+import { APPS } from './apps.js';
+
+export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
+ const [locale, setLoc] = useState(getLocale);
+ const [muted, setMuted] = useState(
+ () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
+ const [globalMute, setGlobalMute] = useState(false);
+ const [defaultTab, setDefaultTab] = useState('chat');
+ // Off by default (musicbay.md §2.2): the ordinary expectation, matching
+ // Spotify/Deezer, is that the phone locks on its own idle timer while
+ // listening. This is for whoever would rather trade battery for it —
+ // e.g. to ride out the WebRTC screen-lock reconnect gap without waiting
+ // on the automatic recovery at all.
+ const [keepScreenOnAudio, setKeepScreenOnAudio] = useState(false);
+
+ const onLocaleChange = useCallback((e) => {
+ const code = e.target.value;
+ setLocale(code);
+ setLoc(code);
+ window.location.reload();
+ }, []);
+
+ const onThemeSelect = useCallback((e) => {
+ 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);
+ if (prefs.music_keep_screen_on === 'true') setKeepScreenOnAudio(true);
+ })
+ .catch(() => {});
+ }, [user.token]);
+
+ const toggleKeepScreenOnAudio = useCallback(async () => {
+ const next = !keepScreenOnAudio;
+ setKeepScreenOnAudio(next);
+ try {
+ await hubFetch('/v1/users/me/preferences/music_keep_screen_on', {
+ method: 'PUT', token: user.token,
+ body: { value: next ? 'true' : 'false' },
+ });
+ // A string, matching what a fresh page load reads from the hub
+ // (prefs.music_keep_screen_on === 'true' above) — music-player.js
+ // compares against that same string, and userPrefs is one shared bag
+ // fed from both this immediate update and that load.
+ if (onPrefsChange) onPrefsChange({ music_keep_screen_on: next ? 'true' : 'false' });
+ } catch (err) {
+ setKeepScreenOnAudio(!next);
+ }
+ }, [keepScreenOnAudio, user.token, onPrefsChange]);
+
+ 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) => {
+ const next = !muted[gid];
+ setMuted(prev => ({ ...prev, [gid]: next }));
+ try {
+ await hubFetch(`/v1/groups/${gid}/mute`, {
+ method: 'POST', token: user.token, body: { muted: next },
+ });
+ } catch (err) {
+ setMuted(prev => ({ ...prev, [gid]: !next }));
+ }
+ }, [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('');
+ // Read from the hub rather than written here: the two constants that used to
+ // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on.
+ const [hubInfo, setHubInfo] = useState(null);
+ // On a desktop build, whether the OS is really holding the keys. Electron's
+ // safeStorage falls back to a fixed key when no keyring is running — a
+ // headless session, a minimal desktop — and does it silently. Somebody who
+ // believes the OS is protecting their keys deserves to be told when it is not.
+ const [keyBackend, setKeyBackend] = useState('');
+ // Changing the hub after the first run. Without this a typo on the first
+ // screen was permanent: the prompt only appears when no hub is set, so a
+ // wrong one left editing a JSON file by hand as the only way out.
+ const [hubInput, setHubInput] = useState('');
+ const [hubError, setHubError] = useState('');
+ useEffect(() => {
+ if (!platform.secrets.available) return;
+ platform.secrets.backend().then(setKeyBackend).catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ // The desktop build remembers a path; the browser remembers a handle. Both
+ // answer "where do downloads go", and the row below renders either.
+ if (platform.folder.available) platform.folder.get().then(setDlDir);
+ else downloads.savedDirectory().then(setDlDir);
+ }, []);
+
+ const pickFolder = useCallback(async () => {
+ try {
+ if (platform.folder.available) {
+ const dir = await platform.folder.choose();
+ if (dir) setDlDir(dir);
+ return;
+ }
+ const handle = await downloads.chooseDirectory();
+ setDlDir(handle);
+ } catch (err) {
+ // Reported where the folder controls are. This used to be written into
+ // the node-key status, two sections away, where nobody was looking.
+ if (err.name !== 'AbortError') setDlError(err.message);
+ }
+ }, []);
+
+
+ return html`
+ <div>
+ <h2>${t('settings.title')}</h2>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.downloads')}</h3>
+ ${(downloads.SUPPORTED || platform.folder.available) && html`
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'auto'}
+ onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
+ <span>
+ <strong>${t('settings.dl_auto')}</strong>
+ <span class="settings-hint">${t('settings.dl_auto_hint')}</span>
+ </span>
+ </label>
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'ask'}
+ onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} />
+ <span>
+ <strong>${t('settings.dl_ask')}</strong>
+ <span class="settings-hint">${t('settings.dl_ask_hint')}</span>
+ </span>
+ </label>
+ <div class="settings-row" style="margin-top:10px">
+ <span class="settings-label">
+ ${dlDir ? t('settings.dl_folder',
+ { name: dlDir.name || String(dlDir) })
+ : t('settings.dl_no_folder')}
+ </span>
+ <span>
+ <button class="admin-btn" onClick=${pickFolder}>
+ ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')}
+ </button>
+ ${dlDir && !dlDir.isDefault && html`
+ <button class="btn-secondary" onClick=${async () => {
+ if (platform.folder.available) await platform.folder.forget();
+ else await downloads.forgetDirectory();
+ setDlDir(null);
+ }}>${t('settings.dl_forget')}</button>
+ `}
+ </span>
+ </div>
+ ${dlError && html`<p class="error-msg">${dlError}</p>`}
+ `}
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.appearance')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.theme')}</span>
+ <select class="settings-select" value=${theme} onChange=${onThemeSelect}>
+ <option value="light">${t('settings.theme_light')}</option>
+ <option value="dark">${t('settings.theme_dark')}</option>
+ <option value="system">${t('settings.theme_system')}</option>
+ </select>
+ </div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.language')}</span>
+ <select class="settings-select" value=${locale} onChange=${onLocaleChange}>
+ ${LOCALES.map(l => html`
+ <option key=${l.code} value=${l.code}>${l.name}</option>
+ `)}
+ </select>
+ </div>
+ </div>
+
+ <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="toggle-switch">
+ <input type="checkbox" checked=${globalMute}
+ onChange=${toggleGlobalMute} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ </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>
+ <label class="toggle-switch">
+ <input type="checkbox" checked=${!muted[g.id]}
+ onChange=${() => toggleMute(g.id)} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ </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}>
+ ${APPS.map((a) => html`<option key=${a.key} value=${a.key}>${t(a.labelKey)}</option>`)}
+ <option value="settings">${t('group.tab_settings')}</option>
+ </select>
+ </div>
+ <p class="settings-hint">${t('settings.default_tab_hint')}</p>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.music_keep_screen_on')}</span>
+ <label class="toggle-switch">
+ <input type="checkbox" checked=${keepScreenOnAudio}
+ onChange=${toggleKeepScreenOnAudio} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ </label>
+ </div>
+ <p class="settings-hint">${t('settings.music_keep_screen_on_hint')}</p>
+ </div>
+
+ ${platform.isNative && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.hub_heading')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.hub_current')}</span>
+ <span class="settings-value">${platform.hubBase() || '—'}</span>
+ </div>
+ <p class="settings-hint">${t('settings.hub_hint')}</p>
+ <form onSubmit=${async (e) => {
+ e.preventDefault();
+ setHubError('');
+ try {
+ await window.meshbay.setHubBase(hubInput.trim());
+ } catch (err) { setHubError(platform.bridgeMessage(err)); }
+ }} style="display:flex;gap:8px">
+ <input type="text" placeholder=${platform.hubBase()}
+ value=${hubInput} onInput=${e => setHubInput(e.target.value)} />
+ <button class="admin-btn" type="submit">${t('settings.hub_change')}</button>
+ </form>
+ ${hubError && html`<p class="error-msg">${hubError}</p>`}
+ </div>
+ `}
+
+ ${keyBackend && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.keys_heading')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.keys_where')}</span>
+ <span class="settings-value">${keyBackend}</span>
+ </div>
+ ${keyBackend === 'unprotected_fallback' && html`
+ <p class="error-msg">${t('settings.keys_unprotected')}</p>
+ `}
+ ${keyBackend === 'unavailable' && html`
+ <p class="error-msg">${t('settings.keys_unavailable')}</p>
+ `}
+ </div>
+ `}
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.about')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.version')}</span>
+ <span class="settings-value">${hubInfo ? hubInfo.hub : '—'}</span>
+ </div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.protocol')}</span>
+ <span class="settings-value">
+ ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'}
+ </span>
+ </div>
+ </div>
+ </div>
+ `;
+}