diff options
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.js | 316 |
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> + `; +} |