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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-25 11:56:24 -04:00
committed by jared
co-authored by Claude Opus 5.5
parent 299da879fb
commit 449d0c740e
23 changed files with 392 additions and 200 deletions
@@ -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
+1 -1
View File
@@ -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();
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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 })
+2 -2
View File
@@ -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() {
)}
</Box>
<Text size="T200" priority="300">
You can change this in Settings → General.
You can change this in Settings → Desktop.
</Text>
</Box>
</Dialog>
@@ -48,7 +48,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
>
<SettingTile
title="Message Retention"
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → Messages; full server-side deletion also requires homeserver retention to be configured."
>
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
{RETENTION_PRESETS.map((preset) => {
+52 -43
View File
@@ -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<SettingsPages | undefined>(() => {
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 && (
<General requestClose={handlePageRequestClose} />
{activePage === SettingsPages.AppearancePage && (
<AppearancePage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.MessagesPage && (
<MessagesPage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.CallsPage && (
<CallsPage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DataStoragePage && (
<DataStoragePage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DesktopPage && (
<DesktopPage requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AccountPage && (
<Account requestClose={handlePageRequestClose} />
@@ -217,13 +218,21 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
<Notifications requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
<Devices
title="Privacy & security"
before={<Privacy />}
requestClose={handlePageRequestClose}
/>
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
<DeveloperTools
title="Advanced"
before={<AdvancedDisplayOptions />}
requestClose={handlePageRequestClose}
/>
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
@@ -313,7 +313,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
<SettingTile
title={
<Text as="span" size="L400">
Display Name
Display name
</Text>
}
>
@@ -576,7 +576,7 @@ function ProfileStatus() {
<SettingTile
title={
<Text as="span" size="L400">
Status Message
Status message
</Text>
}
description={
@@ -102,6 +102,8 @@ export function ProfileDecoration() {
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(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() {
<SettingTile
title={
<Text as="span" size="L400">
Avatar Decoration
Avatar decoration
</Text>
}
description={
@@ -282,6 +284,20 @@ export function ProfileDecoration() {
</Button>
)}
</Box>
{!loadError && (
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
outlined
aria-expanded={browsing}
onClick={() => setBrowsing((b) => !b)}
>
<Text size="B300">{browsing ? 'Done' : 'Change'}</Text>
</Button>
)}
{hasChanges && !loadError && (
<Button
type="button"
@@ -324,73 +340,77 @@ export function ProfileDecoration() {
</Text>
)}
{/* Search + category tabs. Only one category (or the search results)
{browsing && (
<>
{/* Search + category tabs. Only one category (or the search results)
is mounted at a time so the picker never loads the whole catalog. */}
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
}}
>
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<Input
aria-label="Search decorations"
placeholder="Search decorations"
value={search}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
variant="Secondary"
size="400"
radii="300"
before={<Icon size="100" src={Icons.Search} />}
/>
{!query && (
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
{DECORATION_CATEGORIES.map((category) => (
<Chip
key={category.id}
type="button"
role="tab"
aria-selected={category.id === activeCategory}
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
fill="Soft"
outlined={category.id === activeCategory}
radii="Pill"
onClick={() => setActiveCategory(category.id)}
>
<Text as="span" size="B300">
{category.label}
</Text>
</Chip>
))}
</Box>
)}
<div
role={query ? undefined : 'tabpanel'}
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
maxHeight: 360,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
{visible.length === 0 ? (
<Text size="T300" priority="300">
No decorations match “{search.trim()}”.
</Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
padding: 2,
}}
>
{visible.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
)}
</div>
)}
</div>
</>
)}
</Box>
</SettingTile>
);
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, ReactNode } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
@@ -18,8 +18,12 @@ import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
type DeveloperToolsProps = {
requestClose: () => void;
/** [Gitea #459] Shown as the page title (the menu entry's name). */
title?: string;
/** [Gitea #459] Sections shown above this page's own. */
before?: ReactNode;
};
export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
export function DeveloperTools({ requestClose, title, before }: DeveloperToolsProps) {
const mx = useMatrixClient();
const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [expand, setExpend] = useState(false);
@@ -49,7 +53,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Developer Tools
{title ?? 'Developer Tools'}
</Text>
</Box>
<Box shrink="No">
@@ -63,6 +67,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{before}
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<SequenceCard
@@ -137,7 +137,7 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="100">
<Text size="L400">Device Name</Text>
<Text size="L400">Device name</Text>
<Box gap="200">
<Box grow="Yes" direction="Column">
<Input
@@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
@@ -38,8 +38,12 @@ function DevicesPlaceholder() {
type DevicesProps = {
requestClose: () => void;
/** [Gitea #459] Shown as the page title (the menu entry's name). */
title?: string;
/** [Gitea #459] Sections shown above this page's own. */
before?: ReactNode;
};
export function Devices({ requestClose }: DevicesProps) {
export function Devices({ requestClose, title, before }: DevicesProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
const crossSigningActive = useCrossSigningActive();
@@ -70,7 +74,7 @@ export function Devices({ requestClose }: DevicesProps) {
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
Devices
{title ?? 'Devices'}
</Text>
</Box>
<Box shrink="No">
@@ -84,6 +88,7 @@ export function Devices({ requestClose }: DevicesProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{before}
<Box direction="Column" gap="100">
<Text size="L400">Security</Text>
<SequenceCard
@@ -67,7 +67,7 @@ function ExportKeys() {
{(match, doMatch, passRef, confPassRef) => (
<>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">New Password</Text>
<Text size="L400">New password</Text>
<PasswordInput
ref={passRef}
name="passwordInput"
@@ -82,7 +82,7 @@ function ExportKeys() {
/>
</Box>
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Confirm Password</Text>
<Text size="L400">Confirm password</Text>
<PasswordInput
ref={confPassRef}
style={{ color: match ? undefined : color.Critical.Main }}
+177 -57
View File
@@ -4,6 +4,7 @@ import React, {
FormEventHandler,
KeyboardEventHandler,
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useMemo,
@@ -543,7 +544,7 @@ function PageZoomInput() {
);
}
function Appearance() {
export function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const allThemes = useThemes();
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
@@ -595,7 +596,7 @@ function Appearance() {
settingsAtom,
'seasonalThemeOverride',
);
const [, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
return (
<Box direction="Column" gap="100">
@@ -672,13 +673,13 @@ function Appearance() {
direction="Column"
gap="200"
>
<SettingTile
<CollapsiblePicker
title="Chat background"
description="Pattern applied behind the message timeline."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
current={BG_OPTIONS.find((o) => o.value === chatBackground)?.label ?? 'None'}
>
<ChatBgGrid />
</Box>
</CollapsiblePicker>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
@@ -695,11 +696,14 @@ function Appearance() {
direction="Column"
gap="200"
>
<SettingTile
<CollapsiblePicker
title="Seasonal theme"
description="Decorative overlays for holidays and events. “Auto” follows the calendar — each theme below shows the dates it turns on. Click to select."
/>
<Box style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
description="Decorative overlays for holidays and events. Auto follows the calendar."
current={
SEASONAL_OPTIONS.find((o) => o.value === (seasonalThemeOverride ?? 'auto'))?.label ??
'Auto'
}
>
<SeasonalBgGrid
value={seasonalThemeOverride ?? 'auto'}
onChange={(v) => {
@@ -711,7 +715,7 @@ function Appearance() {
if (v !== 'off') setChatBackground('none');
}}
/>
</Box>
</CollapsiblePicker>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
@@ -1308,7 +1312,7 @@ function SelectDateFormat() {
);
}
function DateAndTime() {
export function DateAndTime() {
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
@@ -1487,7 +1491,7 @@ function ComposerToolbarReorder({
);
}
function Editor() {
export function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [offerCodePaste, setOfferCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
@@ -1578,7 +1582,7 @@ function Editor() {
);
}
function Privacy() {
export function Privacy() {
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
const [privateReadReceipts, setPrivateReadReceipts] = useSetting(
@@ -1655,7 +1659,7 @@ function Privacy() {
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() {
export function SettingsSyncSection() {
const isMobile = useScreenSize() === ScreenSize.Mobile;
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions();
@@ -1788,7 +1792,7 @@ function useKeyBind(setter: (code: string) => void, otherKey?: string) {
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
function Calls() {
export function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
settingsAtom,
@@ -2404,6 +2408,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 (
<>
<SettingTile
title={title}
description={description}
after={
<Box alignItems="Center" gap="200" shrink="No">
<Text size="T300" priority="300">
{current}
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
aria-expanded={open}
aria-controls={id}
onClick={() => {
setOpenedWith(current);
setOpen((o) => !o);
}}
>
<Text size="B300">{open ? 'Done' : 'Change'}</Text>
</Button>
</Box>
}
/>
{open && (
<Box id={id} style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}>
{children}
</Box>
)}
</>
);
}
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() {
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Message spacing" after={<SelectMessageSpacing />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Legacy username colour"
after={
<Switch
variant="Primary"
value={legacyUsernameColor}
onChange={setLegacyUsernameColor}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide joins and leaves"
@@ -2808,14 +2854,6 @@ function Messages() {
/>
</SequenceCard>
)}
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show hidden events"
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Enforce message retention"
@@ -2940,7 +2978,7 @@ function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUp
* inside Appearance, between Monochrome Mode and Twitter Emoji). Renders
* nothing outside the desktop app.
*/
function DesktopSettings() {
export function DesktopSettings() {
if (!isTauriEnv()) return null;
return (
<Box direction="Column" gap="100">
@@ -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 (
<Box direction="Column" gap="100">
<Text size="L400">Display</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Legacy username colour"
after={
<Switch
variant="Primary"
value={legacyUsernameColor}
onChange={setLegacyUsernameColor}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show hidden events"
after={
<Switch variant="Primary" value={showHiddenEvents} onChange={setShowHiddenEvents} />
}
/>
</SequenceCard>
</Box>
);
}
/**
* [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 (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
General
{title}
</Text>
</Box>
<Box shrink="No">
@@ -3052,17 +3136,7 @@ export function General({ requestClose }: GeneralProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Appearance />
<DateAndTime />
<Editor />
<Messages />
<Privacy />
<StorageUsage />
<SettingsSyncSection />
<YourDataSection />
<Calls />
<DesktopSettings />
<AppUpdates />
{children}
</Box>
</PageContent>
</Scroll>
@@ -3070,3 +3144,49 @@ export function General({ requestClose }: GeneralProps) {
</Page>
);
}
export function AppearancePage({ requestClose }: GeneralProps) {
return (
<SectionPage title="Appearance" requestClose={requestClose}>
<Appearance />
</SectionPage>
);
}
export function MessagesPage({ requestClose }: GeneralProps) {
return (
<SectionPage title="Messages" requestClose={requestClose}>
<Messages />
<Editor />
<DateAndTime />
</SectionPage>
);
}
export function CallsPage({ requestClose }: GeneralProps) {
return (
<SectionPage title="Calls & audio" requestClose={requestClose}>
<Calls />
</SectionPage>
);
}
export function DataStoragePage({ requestClose }: GeneralProps) {
return (
<SectionPage title="Data & storage" requestClose={requestClose}>
<StorageUsage />
<SettingsSyncSection />
<YourDataSection />
</SectionPage>
);
}
/** Desktop app only (the menu hides it elsewhere). */
export function DesktopPage({ requestClose }: GeneralProps) {
return (
<SectionPage title="Desktop" requestClose={requestClose}>
<DesktopSettings />
<AppUpdates />
</SectionPage>
);
}
@@ -334,6 +334,11 @@ export function PushRuleEditor() {
return (
<Box direction="Column" gap="100">
<Text size="L400">Advanced push rules</Text>
{/* [Gitea #459] Matrix terms kept, with one line on what they are. */}
<Text size="T200" priority="300">
The raw Matrix notification rules behind the options above, checked in this order: override,
room, sender, then underride. You rarely need to change these.
</Text>
<RuleSection title="Override rules" kind={PushRuleKind.Override} rules={overrideRules} />
<RuleSection
title="Room rules"
+9 -1
View File
@@ -1,11 +1,19 @@
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
* page without pulling the dialog into the entry chunk. */
export enum SettingsPages {
GeneralPage,
AccountPage,
// [Gitea #459] These replace the old catch-all General page.
AppearancePage,
MessagesPage,
NotificationPage,
CallsPage,
/** "Privacy & security": privacy options plus devices and encryption. */
DevicesPage,
DataStoragePage,
/** Desktop app only. */
DesktopPage,
EmojisStickersPage,
/** "Advanced": developer tools and display plumbing. */
DeveloperToolsPage,
AboutPage,
}
+1 -1
View File
@@ -140,7 +140,7 @@ export function useSettingsSync(): void {
}
/**
* Manual actions for Settings → General → Sync. `pushNow` stamps a fresh
* Manual actions for Settings → Data & storage → Sync. `pushNow` stamps a fresh
* snapshot of THIS device so it wins on every other device; `clearRemote`
* blanks the account-data event (other devices ignore the invalid content and
* keep their local settings until someone pushes again).
+3 -3
View File
@@ -1015,13 +1015,13 @@ function TauriUpdateFeature() {
dismissToast(UPDATE_PROGRESS_TOAST);
if (!failure) return;
// Short on purpose; the full explanation and a Download installer button
// are in Settings → General → App updates.
// are in Settings → Desktop → App updates.
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
setToast({
id: UPDATE_FAILED_TOAST,
iconSrc: Icons.Warning,
displayName: 'Update didn’t install',
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App updates.`,
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → Desktop → App updates.`,
roomName: 'System',
roomId: '',
sticky: true,
@@ -1062,7 +1062,7 @@ function TauriUpdateFeature() {
roomName: 'System',
roomId: '',
onClick: () => {
if (viaPackage) requestSettings('general');
if (viaPackage) requestSettings('desktop');
else installFromToast();
},
sticky: true,
+1 -1
View File
@@ -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() {
+2 -2
View File
@@ -72,8 +72,8 @@ export function KeyUploadBanner() {
rejects every new upload and other people can&apos;t start fresh encrypted sessions
with you. This usually happens after the browser cleared this site&apos;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.
</Text>
{lastError && (
<Text size="T200" align="Center" priority="300" style={{ wordBreak: 'break-word' }}>
+4 -1
View File
@@ -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,
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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