From 449d0c740ebff01105f459c3601ee5678458e99f Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 25 Oct 2026 02:07:23 -0400 Subject: [PATCH 1/3] feat(settings): split General into pages; collapse the big pickers (#459, #460) #459 General held 76 of ~110 settings (11 screens). Its sections now live on pages named for what people come to do: Account, Appearance, Messages (incl. editor, date and time), Notifications, Calls & audio, Privacy & security (privacy options + the former Devices page), Data & storage (storage, sync, your data), Desktop (desktop app only), Emojis & stickers, Advanced (the former Developer Tools + "show hidden events" / "legacy username colour"), About. The longest page is now ~3 screens (Appearance). Settings opens on Account. Deep links keep working: "devices" lands on Privacy & security, the old "general" on Appearance, the update notice on Desktop. Text that pointed at the old page names is updated. The push-rule editor gets one line explaining the Matrix terms. Remaining Title Case labels on Account and Devices are sentence case. #460 Chat background, seasonal theme and avatar decoration grids sit behind one row: the current choice and a Change button (the first two close after a choice; decorations keep their Save step). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../settings-nav.aria.yml | 29 ++- e2e/key-upload-recovery.spec.ts | 2 +- e2e/security-nudge.spec.ts | 4 +- e2e/your-data.spec.ts | 2 +- src/app/components/CloseBehaviorPrompt.tsx | 4 +- .../common-settings/general/RoomRetention.tsx | 2 +- src/app/features/settings/Settings.tsx | 95 +++---- src/app/features/settings/account/Profile.tsx | 4 +- .../settings/account/ProfileDecoration.tsx | 144 ++++++----- .../settings/developer-tools/DevelopTools.tsx | 11 +- .../features/settings/devices/DeviceTile.tsx | 2 +- src/app/features/settings/devices/Devices.tsx | 11 +- .../features/settings/devices/LocalBackup.tsx | 4 +- src/app/features/settings/general/General.tsx | 234 +++++++++++++----- .../settings/notifications/PushRuleEditor.tsx | 5 + src/app/features/settings/pages.ts | 10 +- src/app/hooks/useSettingsSync.ts | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 6 +- src/app/pages/client/ClientRoot.tsx | 2 +- src/app/pages/client/KeyUploadBanner.tsx | 4 +- src/app/pages/client/sidebar/SettingsTab.tsx | 5 +- src/app/state/settingsRequest.ts | 8 +- src/app/utils/securityNudge.ts | 2 +- 23 files changed, 392 insertions(+), 200 deletions(-) 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 && ( + + } + /> + {open && ( + + {children} + + )} + + ); +} + function SeasonalBgGrid({ value, onChange, @@ -2663,11 +2726,7 @@ function SelectMessageSpacing() { ); } -function Messages() { - const [legacyUsernameColor, setLegacyUsernameColor] = useSetting( - settingsAtom, - 'legacyUsernameColor', - ); +export function Messages() { const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, 'hideMembershipEvents', @@ -2680,7 +2739,6 @@ function Messages() { 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 +2765,6 @@ function Messages() { } /> - - - } - /> - )} - - - } - /> - @@ -2952,7 +2990,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 +3069,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 +3136,7 @@ export function General({ requestClose }: GeneralProps) { - - - - - - - - - - - + {children} @@ -3070,3 +3144,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/notifications/PushRuleEditor.tsx b/src/app/features/settings/notifications/PushRuleEditor.tsx index 6e92096ab..628b3e56f 100644 --- a/src/app/features/settings/notifications/PushRuleEditor.tsx +++ b/src/app/features/settings/notifications/PushRuleEditor.tsx @@ -334,6 +334,11 @@ export function PushRuleEditor() { return ( Advanced push rules + {/* [Gitea #459] Matrix terms kept, with one line on what they are. */} + + The raw Matrix notification rules behind the options above, checked in this order: override, + room, sender, then underride. You rarely need to change these. + { - if (viaPackage) requestSettings('general'); + if (viaPackage) requestSettings('desktop'); else installFromToast(); }, sticky: true, diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1c2b7c379..fd444594a 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -61,7 +61,7 @@ import { AutoDiscovery } from './AutoDiscovery'; // Capture-only E2EE diagnostics ring buffer (KE-1→4 signatures) — installed at // module load so it sees crypto warnings from the very first sync. Idempotent; -// report download lives in Settings → Developer Tools → Crypto Diagnostics. +// report download lives in Settings → Advanced → Crypto diagnostics. installCryptoDiagLog(); function ClientRootLoading() { diff --git a/src/app/pages/client/KeyUploadBanner.tsx b/src/app/pages/client/KeyUploadBanner.tsx index 7fd2e5d98..7f71d4708 100644 --- a/src/app/pages/client/KeyUploadBanner.tsx +++ b/src/app/pages/client/KeyUploadBanner.tsx @@ -72,8 +72,8 @@ export function KeyUploadBanner() { rejects every new upload and other people can't start fresh encrypted sessions with you. This usually happens after the browser cleared this site's storage while you stayed signed in. Before signing out, make sure this device is verified or - key backup is on (Settings → Devices), so your message history comes back when you - sign in again. + key backup is on (Settings → Privacy & security), so your message history comes back + when you sign in again. {lastError && ( diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 081d87c95..f12457db3 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -180,7 +180,10 @@ export function SettingsTab() { useEffect(() => { if (!settingsRequest) return; const page = { - general: SettingsPages.GeneralPage, + // [Gitea #459] General is gone; its old deep links land on Appearance. + general: SettingsPages.AppearancePage, + appearance: SettingsPages.AppearancePage, + desktop: SettingsPages.DesktopPage, account: SettingsPages.AccountPage, notifications: SettingsPages.NotificationPage, devices: SettingsPages.DevicesPage, diff --git a/src/app/state/settingsRequest.ts b/src/app/state/settingsRequest.ts index 5e2ce23b6..3c68adced 100644 --- a/src/app/state/settingsRequest.ts +++ b/src/app/state/settingsRequest.ts @@ -1,6 +1,12 @@ import { atom } from 'jotai'; -export type SettingsRequestPage = 'general' | 'account' | 'notifications' | 'devices'; +export type SettingsRequestPage = + | 'general' + | 'account' + | 'notifications' + | 'devices' + | 'desktop' + | 'appearance'; /** * Ask the sidebar to open User Settings at a page from anywhere in the app diff --git a/src/app/utils/securityNudge.ts b/src/app/utils/securityNudge.ts index 38b82182a..04a2c995d 100644 --- a/src/app/utils/securityNudge.ts +++ b/src/app/utils/securityNudge.ts @@ -10,7 +10,7 @@ * cross-signing: the setup flow does both). * * - stale-sessions (#113, lowest priority): sessions of this account not used - * for 90+ days; Review opens Settings → Devices with them ticked to sign out. + * for 90+ days; Review opens Settings → Privacy & security with them ticked to sign out. * * Anything not yet known (crypto still loading) → no nudge. Timing: not in * the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three From 1af491cabb97e900aebd221af5553c1014777ad0 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sun, 25 Oct 2026 11:51:02 -0400 Subject: [PATCH 2/3] feat(settings): privacy choice and link previews; grouped call settings (#461, #462) #461 Both link-preview switches move into Privacy. The two overlapping receipt switches become one choice that maps exactly onto the existing settings (no migration, every old combination behaves the same): Everyone / Private read receipts / Hide my activity (also hides typing and other people's receipts, as "Hide typing and read receipts" did). #462 Calls & audio is grouped, the same in every mode: Joining, Microphone and noise, Push to talk and hotkeys, Sounds, Quality, Soundboard. No setting changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/general/General.tsx | 1197 +++++++++-------- 1 file changed, 638 insertions(+), 559 deletions(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 9a03578da..6e75be3ae 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1593,7 +1593,19 @@ export function Privacy() { settingsAtom, 'warnOnUnverifiedDevices', ); + const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); + // [Gitea #461] One choice instead of two overlapping switches. Maps onto the + // existing settings, so every old combination keeps its behaviour: + // hideActivity also hides typing and other people's receipts from you. + let activity: 'everyone' | 'private' | 'hidden' = 'everyone'; + if (hideActivity) activity = 'hidden'; + else if (privateReadReceipts) activity = 'private'; + const chooseActivity = (next: typeof activity) => { + setHideActivity(next === 'hidden'); + setPrivateReadReceipts(next !== 'everyone'); + }; const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata'); return ( @@ -1615,24 +1627,87 @@ export function Privacy() { } + title="Link previews" + description="Your homeserver fetches and caches link previews. It will see the URLs of all links you preview." + after={} /> + title="Link previews in encrypted rooms" + description={ + + + 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={} /> + + + + {( + [ + ['everyone', 'Everyone', "Others see what you've read and when you're typing."], + [ + 'private', + 'Private read receipts', + "Others don't see what you've read (only you and your server do). Typing still shows.", + ], + [ + 'hidden', + 'Hide my activity', + "No typing indicator and private read receipts. You also won't see other people's read receipts.", + ], + ] as const + ).map(([value, label, help]) => ( + + ))} + + m.id === callDenoiseModel); return ( - - Calls - - } - /> - - - - {/* [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. - - - {!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, - }, - ]} - /> - } - /> - - {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. - - - -
- )} -
- - - value={callRejoin} - onChange={setCallRejoin} - options={[ - { value: 'ask', label: 'Ask' }, - { value: 'auto', label: 'Rejoin automatically' }, - { value: 'off', label: 'Do nothing' }, - ]} - /> - } - /> - - } - /> - {hapticsSupported() && ( + // [Gitea #462] Fixed groups in every mode (it was one flat list). + + + Joining + } + title="Join with camera on" + description="Enable camera automatically when joining a voice or video call. Camera is off by default." + after={} /> - )} - } - /> - {pttMode && ( + + - {pttBind.listening ? 'Press a key…' : keyLabel(pttKey)} - - } - /> - )} - - - {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}% + + + + )} + + ); } @@ -2736,8 +2848,6 @@ export 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 [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting( settingsAtom, @@ -2801,37 +2911,6 @@ export 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={} - /> - Date: Sun, 25 Oct 2026 12:16:14 -0400 Subject: [PATCH 3/3] fix(settings): visible radio buttons for the privacy and import choices The app CSS hides native radio inputs, so the read-receipt choice and the "keep mine / use the file's" import choice showed no selection. Use the folds RadioButton. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/settings/general/General.tsx | 8 +++++--- src/app/features/settings/general/YourData.tsx | 16 +++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 6e75be3ae..7b4d33c69 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -33,6 +33,7 @@ import { Switch, Text, toRem, + RadioButton, } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { HexColorPicker } from 'react-colorful'; @@ -1692,11 +1693,12 @@ export function Privacy() { key={value} style={{ display: 'flex', gap: config.space.S200, cursor: 'pointer' }} > - chooseActivity(value)} + onClick={() => chooseActivity(value)} /> {label} 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: