diff --git a/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml b/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml index e5085f090..7be39b2db 100644 --- a/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml +++ b/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml @@ -1,22 +1,31 @@ - navigation "Settings sections": - - button "General" [pressed]: - - img - - paragraph: General - - button "Account": + - button "Account" [pressed]: - img - paragraph: Account + - button "Appearance": + - img + - paragraph: Appearance + - button "Messages": + - img + - paragraph: Messages - button "Notifications": - img - paragraph: Notifications - - button "Devices": + - button "Calls & audio": - img - - paragraph: Devices - - button "Emojis & Stickers": + - paragraph: Calls & audio + - button "Privacy & security": - img - - paragraph: Emojis & Stickers - - button "Developer Tools": + - paragraph: Privacy & security + - button "Data & storage": - img - - paragraph: Developer Tools + - paragraph: Data & storage + - button "Emojis & stickers": + - img + - paragraph: Emojis & stickers + - button "Advanced": + - img + - paragraph: Advanced - button "About": - img - paragraph: About diff --git a/e2e/key-upload-recovery.spec.ts b/e2e/key-upload-recovery.spec.ts index e2a646eaa..bea1c6d62 100644 --- a/e2e/key-upload-recovery.spec.ts +++ b/e2e/key-upload-recovery.spec.ts @@ -31,7 +31,7 @@ test.describe('key upload recovery (#201)', () => { expect(rejected).toBeGreaterThanOrEqual(3); await banner.getByRole('button', { name: 'Details' }).click(); - await expect(banner).toContainText('Settings → Devices'); + await expect(banner).toContainText('Settings → Privacy & security'); await expect(banner).toContainText('already exists'); await banner.getByRole('button', { name: /Fix: sign out/ }).click(); diff --git a/e2e/security-nudge.spec.ts b/e2e/security-nudge.spec.ts index d90d05d3c..a19c1df9b 100644 --- a/e2e/security-nudge.spec.ts +++ b/e2e/security-nudge.spec.ts @@ -75,7 +75,9 @@ test.describe('security nudge (#110, #123)', () => { await expect(strip(page)).toHaveCount(0); }); - test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => { + test('no key backup: "Set up key backup" opens Settings → Privacy & security', async ({ + page, + }) => { test.setTimeout(150_000); await loginUI(page, user); await pastGrace(page); diff --git a/e2e/your-data.spec.ts b/e2e/your-data.spec.ts index 379ae5e37..76f12c18b 100644 --- a/e2e/your-data.spec.ts +++ b/e2e/your-data.spec.ts @@ -39,7 +39,7 @@ test.describe('your data (#114)', () => { .catch(() => undefined); const openGeneral = async () => { await page.getByRole('button', { name: 'User Settings' }).click(); - await page.getByRole('dialog').getByText('General', { exact: true }).first().click(); + await page.getByRole('dialog').getByText('Data & storage', { exact: true }).first().click(); await page .getByRole('dialog') .getByText('Your data', { exact: true }) diff --git a/src/app/components/CloseBehaviorPrompt.tsx b/src/app/components/CloseBehaviorPrompt.tsx index 6983b2e06..977ce1600 100644 --- a/src/app/components/CloseBehaviorPrompt.tsx +++ b/src/app/components/CloseBehaviorPrompt.tsx @@ -19,7 +19,7 @@ import { useModalStyle } from '../hooks/useModalStyle'; * cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat * should keep running in the tray (calls, messages, notifications) or quit — * and, in the same moment, whether it should start when you sign in. Asked - * once; changeable in Settings → General. The native side sends + * once; changeable in Settings → Desktop. The native side sends * `lotus-close-requested` only while the choice is still "ask", and never * during a call (a call always goes to the tray). */ @@ -149,7 +149,7 @@ export function CloseBehaviorPrompt() { )} - You can change this in Settings → General. + You can change this in Settings → Desktop. diff --git a/src/app/features/common-settings/general/RoomRetention.tsx b/src/app/features/common-settings/general/RoomRetention.tsx index ee8ee5870..cf573ca8a 100644 --- a/src/app/features/common-settings/general/RoomRetention.tsx +++ b/src/app/features/common-settings/general/RoomRetention.tsx @@ -48,7 +48,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) { > {RETENTION_PRESETS.map((preset) => { diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 751e54e0e..07b90d317 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -15,7 +15,16 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { General } from './general'; +import { + AdvancedDisplayOptions, + AppearancePage, + CallsPage, + DataStoragePage, + DesktopPage, + MessagesPage, + Privacy, +} from './general'; +import { isTauri as isTauriEnv } from '../../hooks/useTauri'; import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { Account } from './account'; @@ -45,43 +54,23 @@ type SettingsMenuItem = { const useSettingsMenuItems = (): SettingsMenuItem[] => useMemo( - () => [ - { - page: SettingsPages.GeneralPage, - name: 'General', - icon: Icons.Setting, - }, - { - page: SettingsPages.AccountPage, - name: 'Account', - icon: Icons.User, - }, - { - page: SettingsPages.NotificationPage, - name: 'Notifications', - icon: Icons.Bell, - }, - { - page: SettingsPages.DevicesPage, - name: 'Devices', - icon: Icons.Monitor, - }, - { - page: SettingsPages.EmojisStickersPage, - name: 'Emojis & Stickers', - icon: Icons.Smile, - }, - { - page: SettingsPages.DeveloperToolsPage, - name: 'Developer Tools', - icon: Icons.Terminal, - }, - { - page: SettingsPages.AboutPage, - name: 'About', - icon: Icons.Info, - }, - ], + () => + [ + { page: SettingsPages.AccountPage, name: 'Account', icon: Icons.User }, + { page: SettingsPages.AppearancePage, name: 'Appearance', icon: Icons.Sun }, + { page: SettingsPages.MessagesPage, name: 'Messages', icon: Icons.Message }, + { page: SettingsPages.NotificationPage, name: 'Notifications', icon: Icons.Bell }, + { page: SettingsPages.CallsPage, name: 'Calls & audio', icon: Icons.Phone }, + { page: SettingsPages.DevicesPage, name: 'Privacy & security', icon: Icons.ShieldUser }, + { page: SettingsPages.DataStoragePage, name: 'Data & storage', icon: Icons.Download }, + // [Gitea #459] Desktop-only options: nothing to show in a browser. + ...(isTauriEnv() + ? [{ page: SettingsPages.DesktopPage, name: 'Desktop', icon: Icons.Monitor }] + : []), + { page: SettingsPages.EmojisStickersPage, name: 'Emojis & stickers', icon: Icons.Smile }, + { page: SettingsPages.DeveloperToolsPage, name: 'Advanced', icon: Icons.Terminal }, + { page: SettingsPages.AboutPage, name: 'About', icon: Icons.Info }, + ] as SettingsMenuItem[], [], ); @@ -102,7 +91,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { const screenSize = useScreenSizeContext(); const [activePage, setActivePage] = useState(() => { if (initialPage) return initialPage; - return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage; + return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage; }); const menuItems = useSettingsMenuItems(); @@ -207,8 +196,20 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { ) } > - {activePage === SettingsPages.GeneralPage && ( - + {activePage === SettingsPages.AppearancePage && ( + + )} + {activePage === SettingsPages.MessagesPage && ( + + )} + {activePage === SettingsPages.CallsPage && ( + + )} + {activePage === SettingsPages.DataStoragePage && ( + + )} + {activePage === SettingsPages.DesktopPage && ( + )} {activePage === SettingsPages.AccountPage && ( @@ -217,13 +218,21 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { )} {activePage === SettingsPages.DevicesPage && ( - + } + requestClose={handlePageRequestClose} + /> )} {activePage === SettingsPages.EmojisStickersPage && ( )} {activePage === SettingsPages.DeveloperToolsPage && ( - + } + requestClose={handlePageRequestClose} + /> )} {activePage === SettingsPages.AboutPage && } diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 44f1036f6..50270db27 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -313,7 +313,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { - Display Name + Display name } > @@ -576,7 +576,7 @@ function ProfileStatus() { - Status Message + Status message } description={ diff --git a/src/app/features/settings/account/ProfileDecoration.tsx b/src/app/features/settings/account/ProfileDecoration.tsx index 85095ec59..fde1f8497 100644 --- a/src/app/features/settings/account/ProfileDecoration.tsx +++ b/src/app/features/settings/account/ProfileDecoration.tsx @@ -102,6 +102,8 @@ export function ProfileDecoration() { const [current, setCurrent] = useState(null); const [selected, setSelected] = useState(null); + // [Gitea #460] The catalog stays closed until "Change"; it was always open. + const [browsing, setBrowsing] = useState(false); // Distinguish "confirmed no decoration" from "failed to load": a fetch // failure must not be shown as (and saved over) "None". const [loadError, setLoadError] = useState(false); @@ -199,7 +201,7 @@ export function ProfileDecoration() { - Avatar Decoration + Avatar decoration } description={ @@ -282,6 +284,20 @@ export function ProfileDecoration() { )} + {!loadError && ( + + )} {hasChanges && !loadError && ( - } - /> - )} - - - {deafenHotkey && ( - - )} - - } - /> - {isTauriEnv() && ( - - } - /> - )} - - - - } - /> - } - /> - {afkAutoMute && ( - setAfkTimeoutMinutes(Number(v))} + + value={callRejoin} + onChange={setCallRejoin} options={[ - { value: '1', label: '1 minute' }, - { value: '5', label: '5 minutes' }, - { value: '10', label: '10 minutes' }, - { value: '20', label: '20 minutes' }, - { value: '30', label: '30 minutes' }, + { value: 'ask', label: 'Ask' }, + { value: 'auto', label: 'Rejoin automatically' }, + { value: 'off', label: 'Do nothing' }, ]} /> } /> - )} - - - handleRingtoneChange(v as RingtoneId)} - options={RINGTONE_OPTIONS} - /> - } - /> - - - - - setRingtoneVolume(parseInt(e.target.value, 10))} - aria-label="Ringtone volume" - style={{ flex: 1, accentColor: color.Primary.Main }} + + } /> - - {ringtoneVolume}% - - - - - handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')} - options={[ - { value: 'off', label: 'Off' }, - { value: 'chime', label: 'Chime' }, - { value: 'soft', label: 'Soft' }, - { value: 'retro', label: 'Retro' }, - ]} - /> - } - /> - + + + + Microphone and noise + + + {/* [Gitea #271] T200 like every other setting description. */} + + Filter background noise from your mic during calls. Browser-native uses the + built-in WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger + removal. + - - - value={callAudioBitrate} - onChange={setCallAudioBitrate} - options={AUDIO_BITRATE_OPTIONS} - /> - } - /> - - - - - value={screenshareBitrate} - onChange={setScreenshareBitrate} - options={SCREENSHARE_BITRATE_OPTIONS} - /> - } - /> - - - - - value={screenshareFramerate} - onChange={setScreenshareFramerate} - options={SCREENSHARE_FRAMERATE_OPTIONS} - /> - } - /> - - - - - } - /> - {soundboardEnabled && ( - <> - - - setSoundboardVolume(parseInt(e.target.value, 10))} - style={{ flexGrow: 1 }} - aria-label="Soundboard volume" + {!mlSupported && ( + + + ML options are not supported in this browser. + + + {ML_DENOISE_REQUIREMENTS.map((req) => ( + + {req} + + ))} + + + )} + + } + after={ + + value={callNoiseSuppression} + onChange={setCallNoiseSuppression} + options={[ + { value: 'off', label: 'Off' }, + { value: 'browser', label: 'Browser-native' }, + { + value: 'ml', + label: 'ML (Advanced)', + disabled: !mlSupported, + }, + ]} /> - - {soundboardVolume}% - + } + /> + + {callNoiseSuppression === 'ml' && ( + + {/* ── Model selection ───────────────────────────────────────── */} + + Model + + value={callDenoiseModel} + onChange={setCallDenoiseModel} + options={DENOISE_MODELS.map((m) => ({ value: m.id, label: m.name }))} + /> + } + /> + {selectedDenoiseModel && ( + + {selectedDenoiseModel.name} + + {selectedDenoiseModel.description} + + + {( + [ + { label: 'CPU', value: selectedDenoiseModel.cpuUsage }, + { label: 'Voice quality', value: selectedDenoiseModel.voiceQuality }, + { label: 'Transients', value: selectedDenoiseModel.transients }, + { label: 'Download', value: selectedDenoiseModel.binarySize }, + ] as const + ).map((stat) => ( + + + {stat.label} + + {stat.value} + + ))} + + + )} + +
+ + + Compare all models + + + + + + + Model + + + + + CPU + + + + + Quality + + + + + Transients + + + + {DENOISE_MODELS.map((model) => ( + + + {model.name} + + + {model.cpuUsage} + + + {model.voiceQuality} + + + {model.transients} + + + ))} + +
+ + + Note: Applying changes requires rejoining the call. + +
+ + {/* ── Enhancement toggles ───────────────────────────────────── */} + + Enhancements + + } + /> + + + } + /> + + {callDenoiseGate && ( + + + Gate Threshold + {callDenoiseGateThreshold} dB + + setCallDenoiseGateThreshold(parseInt(e.target.value, 10))} + style={{ width: '100%', accentColor: color.Primary.Main }} + /> + + )} + + + {/* ── Test & calibrate ──────────────────────────────────────── */} + + Test and calibrate + + Audition the selected model and tune the gate without joining a call. Changes to + the settings above apply the next time you start a monitor or play a clip. + + +
- - )} -
+ )} +
+ + + } + /> + } + /> + {afkAutoMute && ( + setAfkTimeoutMinutes(Number(v))} + options={[ + { value: '1', label: '1 minute' }, + { value: '5', label: '5 minutes' }, + { value: '10', label: '10 minutes' }, + { value: '20', label: '20 minutes' }, + { value: '30', label: '30 minutes' }, + ]} + /> + } + /> + )} + +
+ + Push to talk and hotkeys + + } + /> + {pttMode && ( + + {pttBind.listening ? 'Press a key…' : keyLabel(pttKey)} + + } + /> + )} + + + {deafenHotkey && ( + + )} + + } + /> + {isTauriEnv() && ( + + } + /> + )} + {hapticsSupported() && ( + } + /> + )} + + + + Sounds + + handleRingtoneChange(v as RingtoneId)} + options={RINGTONE_OPTIONS} + /> + } + /> + + + + + setRingtoneVolume(parseInt(e.target.value, 10))} + aria-label="Ringtone volume" + style={{ flex: 1, accentColor: color.Primary.Main }} + /> + + {ringtoneVolume}% + + + + + + handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro') + } + options={[ + { value: 'off', label: 'Off' }, + { value: 'chime', label: 'Chime' }, + { value: 'soft', label: 'Soft' }, + { value: 'retro', label: 'Retro' }, + ]} + /> + } + /> + + + + Quality + + + value={callAudioBitrate} + onChange={setCallAudioBitrate} + options={AUDIO_BITRATE_OPTIONS} + /> + } + /> + + + + value={screenshareBitrate} + onChange={setScreenshareBitrate} + options={SCREENSHARE_BITRATE_OPTIONS} + /> + } + /> + + + + value={screenshareFramerate} + onChange={setScreenshareFramerate} + options={SCREENSHARE_FRAMERATE_OPTIONS} + /> + } + /> + + + + Soundboard + + + } + /> + {soundboardEnabled && ( + <> + + + setSoundboardVolume(parseInt(e.target.value, 10))} + style={{ flexGrow: 1 }} + aria-label="Soundboard volume" + /> + + {soundboardVolume}% + + + + )} + + ); } @@ -2404,6 +2522,65 @@ const SEASONAL_OPTIONS: { { value: 'deepspace', label: 'Deep Space', emoji: '🚀' }, ]; +/** + * [Gitea #460] A picker grid behind one row: the current choice and a Change + * button. The big grids (27 backgrounds, 13 seasonal themes) used to be always + * open and made Appearance about 2.5 screens long. Closes after a choice. + */ +export function CollapsiblePicker({ + title, + description, + current, + children, +}: { + title: string; + description?: ReactNode; + current: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const [openedWith, setOpenedWith] = useState(current); + useEffect(() => { + if (open && current !== openedWith) setOpen(false); + }, [open, current, openedWith]); + const id = `picker-${title.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`; + return ( + <> + + + {current} + + + + } + /> + {open && ( + + {children} + + )} + + ); +} + function SeasonalBgGrid({ value, onChange, @@ -2663,11 +2840,7 @@ function SelectMessageSpacing() { ); } -function Messages() { - const [legacyUsernameColor, setLegacyUsernameColor] = useSetting( - settingsAtom, - 'legacyUsernameColor', - ); +export function Messages() { const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, 'hideMembershipEvents', @@ -2677,10 +2850,7 @@ function Messages() { 'hideNickAvatarEvents', ); const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); - const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); - const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [inlineMediaEmbeds, setInlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds'); - const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting( settingsAtom, 'enforceRetentionLocally', @@ -2707,18 +2877,6 @@ function Messages() { } /> - - - } - /> - - - } - /> - - - - - Your homeserver fetches link previews on your behalf. It cannot decrypt your - messages, but will see every URL you preview in encrypted rooms, potentially - revealing conversation topics. - - - Privacy risk — enabled by default - - - } - after={} - /> - )} - - - } - /> - @@ -2952,7 +3071,7 @@ function DesktopSettings() { ); } -function AppUpdates() { +export function AppUpdates() { const { isTauri, status, check, install, installKind } = useTauriUpdater(); if (!isTauri) return null; const packageHelp = packageUpdateHelp(installKind); @@ -3031,14 +3150,60 @@ function AppUpdates() { type GeneralProps = { requestClose: () => void; }; -export function General({ requestClose }: GeneralProps) { + +/** [Gitea #459] Display options for people who want to see the plumbing. */ +export function AdvancedDisplayOptions() { + const [legacyUsernameColor, setLegacyUsernameColor] = useSetting( + settingsAtom, + 'legacyUsernameColor', + ); + const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + + return ( + + Display + + + } + /> + + + + } + /> + + + ); +} + +/** + * [Gitea #459] General used to hold 76 of the ~110 settings (11 screens). Its + * sections now live on pages named for what people come to do; Privacy goes + * to Privacy & security (devices/Devices.tsx) and the display plumbing to + * Advanced (developer-tools). + */ +function SectionPage({ + title, + requestClose, + children, +}: GeneralProps & { title: string; children: ReactNode }) { return ( - General + {title} @@ -3052,17 +3217,7 @@ export function General({ requestClose }: GeneralProps) { - - - - - - - - - - - + {children} @@ -3070,3 +3225,49 @@ export function General({ requestClose }: GeneralProps) { ); } + +export function AppearancePage({ requestClose }: GeneralProps) { + return ( + + + + ); +} + +export function MessagesPage({ requestClose }: GeneralProps) { + return ( + + + + + + ); +} + +export function CallsPage({ requestClose }: GeneralProps) { + return ( + + + + ); +} + +export function DataStoragePage({ requestClose }: GeneralProps) { + return ( + + + + + + ); +} + +/** Desktop app only (the menu hides it elsewhere). */ +export function DesktopPage({ requestClose }: GeneralProps) { + return ( + + + + + ); +} diff --git a/src/app/features/settings/general/YourData.tsx b/src/app/features/settings/general/YourData.tsx index 1a9ea2e40..833a497e4 100644 --- a/src/app/features/settings/general/YourData.tsx +++ b/src/app/features/settings/general/YourData.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { useAtom } from 'jotai'; import { Method } from 'matrix-js-sdk'; -import { Box, Button, Text, color } from 'folds'; +import { Box, Button, RadioButton, Text, color } from 'folds'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -224,22 +224,24 @@ export function YourDataSection() { here: