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'; import { PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom, } from './pager.js'; const PAGE_SIZES = Array.from( { length: PAGE_SIZE_MAX / PAGE_SIZE_STEP }, (_, i) => (i + 1) * PAGE_SIZE_STEP); 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 (docs/MESHBAY_DESIGN.md §9.8): 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 [pageSize, setPageSize] = useState(PAGE_SIZE_DEFAULT); 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); setPageSize(pageSizeFrom(prefs)); }) .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 changePageSize = useCallback(async (e) => { const val = String(e.target.value); setPageSize(Number(val)); try { await hubFetch(`/v1/users/me/preferences/${PAGE_SIZE_PREF}`, { method: 'PUT', token: user.token, body: { value: val }, }); if (onPrefsChange) onPrefsChange({ [PAGE_SIZE_PREF]: val }); } catch { setPageSize(pageSize); } }, [pageSize, 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`

${t('settings.title')}

${t('settings.downloads')}

${(downloads.SUPPORTED || platform.folder.available) && html`
${dlDir ? t('settings.dl_folder', { name: dlDir.name || String(dlDir) }) : t('settings.dl_no_folder')} ${dlDir && !dlDir.isDefault && html` `}
${dlError && html`

${dlError}

`} `}

${t('settings.appearance')}

${t('settings.theme')}
${t('settings.language')}

${t('settings.groups')}

${t('settings.notif_global_disable')}
${!globalMute && html`

${t('settings.notif_global_hint')}

${groups.map(g => html`
${g.name}
`)} `}

${t('settings.defaults')}

${t('settings.default_tab')}

${t('settings.default_tab_hint')}

${t('settings.media_page_size')}

${t('settings.media_page_size_hint')}

${t('settings.music_keep_screen_on')}

${t('settings.music_keep_screen_on_hint')}

${platform.isNative && html`

${t('settings.hub_heading')}

${t('settings.hub_current')} ${platform.hubBase() || '—'}

${t('settings.hub_hint')}

{ e.preventDefault(); setHubError(''); try { await window.meshbay.setHubBase(hubInput.trim()); } catch (err) { setHubError(platform.bridgeMessage(err)); } }} style="display:flex;gap:8px"> setHubInput(e.target.value)} />
${hubError && html`

${hubError}

`}
`} ${keyBackend && html`

${t('settings.keys_heading')}

${t('settings.keys_where')} ${keyBackend}
${keyBackend === 'unprotected_fallback' && html`

${t('settings.keys_unprotected')}

`} ${keyBackend === 'unavailable' && html`

${t('settings.keys_unavailable')}

`}
`}

${t('settings.about')}

${t('settings.version')} ${hubInfo ? hubInfo.hub : '—'}
${t('settings.protocol')} ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'}
`; }