From a6376d77e789a09ff9a6d74647e74bbb680f23c2 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 21:06:01 +0100 Subject: [PATCH 01/18] feat: add settings route helpers --- src/app/features/settings/index.ts | 1 + src/app/features/settings/routes.ts | 34 +++++++++++++++++++++++++++++ src/app/pages/pathUtils.test.ts | 15 +++++++++++++ src/app/pages/pathUtils.ts | 12 ++++++++++ src/app/pages/paths.ts | 6 +++++ 5 files changed, 68 insertions(+) create mode 100644 src/app/features/settings/routes.ts create mode 100644 src/app/pages/pathUtils.test.ts diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts index 90e269730..2db86201e 100644 --- a/src/app/features/settings/index.ts +++ b/src/app/features/settings/index.ts @@ -1 +1,2 @@ export * from './Settings'; +export * from './routes'; diff --git a/src/app/features/settings/routes.ts b/src/app/features/settings/routes.ts new file mode 100644 index 000000000..823112b17 --- /dev/null +++ b/src/app/features/settings/routes.ts @@ -0,0 +1,34 @@ +export type SettingsSectionId = + | 'general' + | 'account' + | 'persona' + | 'appearance' + | 'notifications' + | 'devices' + | 'emojis' + | 'developer-tools' + | 'experimental' + | 'about' + | 'keyboard-shortcuts'; + +export type SettingsSection = { + id: SettingsSectionId; + label: string; +}; + +export const settingsSections = [ + { id: 'general', label: 'General' }, + { id: 'account', label: 'Account' }, + { id: 'persona', label: 'Persona' }, + { id: 'appearance', label: 'Appearance' }, + { id: 'notifications', label: 'Notifications' }, + { id: 'devices', label: 'Devices' }, + { id: 'emojis', label: 'Emojis & Stickers' }, + { id: 'developer-tools', label: 'Developer Tools' }, + { id: 'experimental', label: 'Experimental' }, + { id: 'about', label: 'About' }, + { id: 'keyboard-shortcuts', label: 'Keyboard Shortcuts' }, +] as const satisfies readonly SettingsSection[]; + +export const isSettingsSectionId = (value?: string): value is SettingsSectionId => + settingsSections.some((section) => section.id === value); diff --git a/src/app/pages/pathUtils.test.ts b/src/app/pages/pathUtils.test.ts new file mode 100644 index 000000000..813006d80 --- /dev/null +++ b/src/app/pages/pathUtils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { getSettingsPath } from './pathUtils'; + +describe('getSettingsPath', () => { + it('returns the settings root path', () => { + expect(getSettingsPath()).toBe('/settings/'); + }); + + it('returns a section path with an optional focus query', () => { + expect(getSettingsPath('devices')).toBe('/settings/devices/'); + expect(getSettingsPath('appearance', 'message-link-preview')).toBe( + '/settings/appearance/?focus=message-link-preview' + ); + }); +}); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 3872c9290..388aa87d4 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -1,6 +1,7 @@ import { generatePath, Path } from 'react-router-dom'; import { trimLeadingSlash, trimTrailingSlash } from '$utils/common'; import { HashRouterConfig } from '$hooks/useClientConfig'; +import { SettingsSectionId } from '$features/settings/routes'; import { DIRECT_CREATE_PATH, DIRECT_PATH, @@ -20,11 +21,13 @@ import { REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH, + SETTINGS_PATH, SPACE_LOBBY_PATH, SPACE_PATH, SPACE_ROOM_PATH, SPACE_SEARCH_PATH, CREATE_PATH, + SettingsPathSearchParams, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -158,3 +161,12 @@ export const getCreatePath = (): string => CREATE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; + +export const getSettingsPath = (section?: SettingsSectionId, focus?: string): string => { + const basePath = generatePath(SETTINGS_PATH, { section: section ?? null }); + const path = basePath.endsWith('/') ? basePath : `${basePath}/`; + if (!focus) return path; + + const params: SettingsPathSearchParams = { focus }; + return `${path}?${new URLSearchParams(params).toString()}`; +}; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 82f8c6dd2..1ac57b756 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -19,6 +19,10 @@ export type ResetPasswordPathSearchParams = { }; export const RESET_PASSWORD_PATH = '/reset-password/:server?/'; +export type SettingsPathSearchParams = { + focus?: string; +}; + export const CREATE_PATH_SEGMENT = 'create/'; export const JOIN_PATH_SEGMENT = 'join/'; export const LOBBY_PATH_SEGMENT = 'lobby/'; @@ -94,3 +98,5 @@ export const TO_ROOM_EVENT_PATH = `${TO_PATH}/:user_id/:room_id/:event_id?`; export const SPACE_SETTINGS_PATH = '/space-settings/'; export const ROOM_SETTINGS_PATH = '/room-settings/'; + +export const SETTINGS_PATH = '/settings/:section?/'; From 144ff90522d49ea85f94a3676c3951ebef994fbe Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 21:10:11 +0100 Subject: [PATCH 02/18] refactor: remove settings route type inversion --- src/app/pages/pathUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 388aa87d4..0061b0a54 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -1,7 +1,6 @@ import { generatePath, Path } from 'react-router-dom'; import { trimLeadingSlash, trimTrailingSlash } from '$utils/common'; import { HashRouterConfig } from '$hooks/useClientConfig'; -import { SettingsSectionId } from '$features/settings/routes'; import { DIRECT_CREATE_PATH, DIRECT_PATH, @@ -162,7 +161,7 @@ export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; -export const getSettingsPath = (section?: SettingsSectionId, focus?: string): string => { +export const getSettingsPath = (section?: string, focus?: string): string => { const basePath = generatePath(SETTINGS_PATH, { section: section ?? null }); const path = basePath.endsWith('/') ? basePath : `${basePath}/`; if (!focus) return path; From 0ec294405c15bc3f6f731d10c3159de6d6c2c670 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 21:40:36 +0100 Subject: [PATCH 03/18] refactor: share settings section layout and focus handling --- .../components/setting-tile/SettingTile.tsx | 20 ++++- .../settings/Persona/ProfilesPage.tsx | 23 ++--- .../features/settings/SettingsRoute.test.tsx | 86 +++++++++++++++++++ .../features/settings/SettingsSectionPage.tsx | 44 ++++++++++ src/app/features/settings/about/About.tsx | 23 ++--- src/app/features/settings/account/Account.tsx | 23 ++--- .../features/settings/cosmetics/Cosmetics.tsx | 22 +---- .../settings/developer-tools/DevelopTools.tsx | 23 ++--- src/app/features/settings/devices/Devices.tsx | 23 ++--- .../emojis-stickers/EmojisStickers.tsx | 23 ++--- .../settings/experimental/Experimental.tsx | 23 ++--- src/app/features/settings/general/General.tsx | 21 +---- .../keyboard-shortcuts/KeyboardShortcuts.tsx | 32 +++---- .../settings/notifications/Notifications.tsx | 23 ++--- src/app/features/settings/styles.css.ts | 26 +++++- src/app/features/settings/useSettingsFocus.ts | 61 +++++++++++++ 16 files changed, 291 insertions(+), 205 deletions(-) create mode 100644 src/app/features/settings/SettingsRoute.test.tsx create mode 100644 src/app/features/settings/SettingsSectionPage.tsx create mode 100644 src/app/features/settings/useSettingsFocus.ts diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index e57dccd55..67548ece4 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -3,15 +3,31 @@ import { Box, Text } from 'folds'; import { BreakWord } from '$styles/Text.css'; type SettingTileProps = { + focusId?: string; + className?: string; title?: ReactNode; description?: ReactNode; before?: ReactNode; after?: ReactNode; children?: ReactNode; }; -export function SettingTile({ title, description, before, after, children }: SettingTileProps) { +export function SettingTile({ + focusId, + className, + title, + description, + before, + after, + children, +}: SettingTileProps) { return ( - + {before && {before}} {title && ( diff --git a/src/app/features/settings/Persona/ProfilesPage.tsx b/src/app/features/settings/Persona/ProfilesPage.tsx index 2fe56f51c..2ed5dfa83 100644 --- a/src/app/features/settings/Persona/ProfilesPage.tsx +++ b/src/app/features/settings/Persona/ProfilesPage.tsx @@ -1,5 +1,6 @@ -import { Page, PageHeader, PageNavContent } from '$components/page'; -import { Box, IconButton, Icon, Icons, Text } from 'folds'; +import { PageNavContent } from '$components/page'; +import { Box } from 'folds'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { PerMessageProfileOverview } from './PerMessageProfileOverview'; type PerMessageProfilePageProps = { @@ -8,21 +9,7 @@ type PerMessageProfilePageProps = { export function PerMessageProfilePage({ requestClose }: PerMessageProfilePageProps) { return ( - - - - - - Persona - - - - - - - - - + - + ); } diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx new file mode 100644 index 000000000..99ab01e6c --- /dev/null +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -0,0 +1,86 @@ +import { act, render, screen } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { SettingTile } from '$components/setting-tile'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { SettingsSectionPage } from './SettingsSectionPage'; +import { focusedSettingTile } from './styles.css'; +import { useSettingsFocus } from './useSettingsFocus'; + +function FocusFixture() { + useSettingsFocus(); + + return ( +
+ focus target +
+ ); +} + +function LocationProbe() { + const location = useLocation(); + return
{location.search}
; +} + +describe('SettingsSectionPage', () => { + it('shows a back affordance on mobile section pages', () => { + render( + + + + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('supports custom title semantics and close label', () => { + render( + + + + ); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Keyboard Shortcuts'); + expect(screen.getByRole('button', { name: 'Close keyboard shortcuts' })).toBeInTheDocument(); + }); +}); + +describe('useSettingsFocus', () => { + it('highlights a focus target from the query string', async () => { + vi.useFakeTimers(); + + try { + render( + + + + + + + ); + + const target = document.querySelector('[data-settings-focus="message-link-preview"]'); + expect(target).toHaveClass(focusedSettingTile); + expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-link-preview'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2999); + }); + expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-link-preview'); + expect(target).toHaveClass(focusedSettingTile); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(screen.getByTestId('location-probe')).toHaveTextContent(''); + expect(target).not.toHaveClass(focusedSettingTile); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/features/settings/SettingsSectionPage.tsx b/src/app/features/settings/SettingsSectionPage.tsx new file mode 100644 index 000000000..a7f6ff191 --- /dev/null +++ b/src/app/features/settings/SettingsSectionPage.tsx @@ -0,0 +1,44 @@ +import { ReactNode } from 'react'; +import { Box, Icon, IconButton, Icons, Text } from 'folds'; +import { Page, PageHeader } from '$components/page'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; + +type SettingsSectionPageProps = { + title: ReactNode; + requestClose: () => void; + titleAs?: 'h1' | 'h2' | 'h3' | 'span' | 'div'; + actionLabel?: string; + children?: ReactNode; +}; + +export function SettingsSectionPage({ + title, + requestClose, + titleAs, + actionLabel, + children, +}: SettingsSectionPageProps) { + const screenSize = useScreenSizeContext(); + const isMobile = screenSize === ScreenSize.Mobile; + const closeLabel = isMobile ? 'Back' : (actionLabel ?? 'Close'); + + return ( + + + + + + {title} + + + + + + + + + + {children} + + ); +} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 44f42c8f6..7abb66efb 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem, Spinner } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Text, Icon, Icons, Scroll, Button, config, toRem, Spinner } from 'folds'; +import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import CinnySVG from '$public/res/svg/cinny-logo.svg'; @@ -9,6 +9,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { Method } from '$types/matrix-sdk'; import { useOpenBugReportModal } from '$state/hooks/bugReportModal'; +import { SettingsSectionPage } from '../SettingsSectionPage'; export function HomeserverInfo() { const mx = useMatrixClient(); @@ -151,21 +152,7 @@ export function About({ requestClose }: Readonly) { const openBugReport = useOpenBugReportModal(); return ( - - - - - - About - - - - - - - - - + @@ -428,6 +415,6 @@ export function About({ requestClose }: Readonly) { - + ); } diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index c6c3aa8f7..bbd39dd3e 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -1,5 +1,6 @@ -import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Scroll } from 'folds'; +import { PageContent } from '$components/page'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { MatrixId } from './MatrixId'; import { Profile } from './Profile'; import { ContactInformation } from './ContactInfo'; @@ -10,21 +11,7 @@ type AccountProps = { }; export function Account({ requestClose }: AccountProps) { return ( - - - - - - Account - - - - - - - - - + @@ -37,6 +24,6 @@ export function Account({ requestClose }: AccountProps) { - + ); } diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx index 7e44bf480..e000075fd 100644 --- a/src/app/features/settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/settings/cosmetics/Cosmetics.tsx @@ -4,7 +4,6 @@ import { Button, config, Icon, - IconButton, Icons, Menu, MenuItem, @@ -15,13 +14,14 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { useSetting } from '$state/hooks/settings'; import { JumboEmojiSize, settingsAtom } from '$state/settings'; import { SettingTile } from '$components/setting-tile'; import { stopPropagation } from '$utils/keyboard'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { Appearance } from './Themes'; import { LanguageSpecificPronouns } from './LanguageSpecificPronouns'; @@ -247,21 +247,7 @@ type CosmeticsProps = { export function Cosmetics({ requestClose }: CosmeticsProps) { return ( - - - - - - Appearance - - - - - - - - - + @@ -275,6 +261,6 @@ export function Cosmetics({ requestClose }: CosmeticsProps) { - + ); } diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index b717f2261..423f11a78 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Text, Scroll, Switch, Button } from 'folds'; +import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { useSetting } from '$state/hooks/settings'; @@ -9,6 +9,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; @@ -48,21 +49,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { } return ( - - - - - - Developer Tools - - - - - - - - - + @@ -136,6 +123,6 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + ); } diff --git a/src/app/features/settings/devices/Devices.tsx b/src/app/features/settings/devices/Devices.tsx index 03e3ed665..2e18d4b86 100644 --- a/src/app/features/settings/devices/Devices.tsx +++ b/src/app/features/settings/devices/Devices.tsx @@ -1,5 +1,5 @@ -import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Text, Scroll } from 'folds'; +import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '$hooks/useDeviceList'; @@ -13,6 +13,7 @@ import { useSecretStorageDefaultKeyId, useSecretStorageKeyContent } from '$hooks import { useCrossSigningActive } from '$hooks/useCrossSigning'; import { BackupRestoreTile } from '$components/BackupRestore'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { LocalBackup } from './LocalBackup'; import { DeviceLogoutBtn, DeviceKeyDetails, DeviceTile, DeviceTilePlaceholder } from './DeviceTile'; import { OtherDevices } from './OtherDevices'; @@ -61,21 +62,7 @@ export function Devices({ requestClose }: DevicesProps) { ); return ( - - - - - - Devices - - - - - - - - - + @@ -156,6 +143,6 @@ export function Devices({ requestClose }: DevicesProps) { - + ); } diff --git a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx index d8708907c..74f7649ee 100644 --- a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Scroll } from 'folds'; +import { PageContent } from '$components/page'; import { ImagePack } from '$plugins/custom-emoji'; import { ImagePackView } from '$components/image-pack-view'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { GlobalPacks } from './GlobalPacks'; import { UserPack } from './UserPack'; @@ -21,21 +22,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { } return ( - - - - - - Emojis & Stickers - - - - - - - - - + @@ -46,6 +33,6 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + ); } diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 9c3a1c40f..134563446 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -1,5 +1,5 @@ -import { Box, Text, IconButton, Icon, Icons, Scroll, Switch } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Text, Icon, Icons, Scroll, Switch } from 'folds'; +import { PageContent } from '$components/page'; import { InfoCard } from '$components/info-card'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; @@ -7,6 +7,7 @@ import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { SettingTile } from '$components/setting-tile'; import { SequenceCard } from '$components/sequence-card'; import { Sync } from '../general'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; @@ -37,21 +38,7 @@ type ExperimentalProps = { }; export function Experimental({ requestClose }: Readonly) { return ( - - - - - - Experimental - - - - - - - - - + @@ -77,6 +64,6 @@ export function Experimental({ requestClose }: Readonly) { - + ); } diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index f7b8a6531..22a0c46be 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -27,7 +27,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { useSetting } from '$state/hooks/settings'; import { @@ -53,6 +53,7 @@ import { resolveSlidingEnabled } from '$client/initMatrix'; import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; +import { SettingsSectionPage } from '../SettingsSectionPage'; type DateHintProps = { hasChanges: boolean; @@ -1342,21 +1343,7 @@ function DiagnosticsAndPrivacy() { export function General({ requestClose }: Readonly) { return ( - - - - - - General - - - - - - - - - + @@ -1372,6 +1359,6 @@ export function General({ requestClose }: Readonly) { - + ); } diff --git a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx index 0ce94a9c3..2900f78c7 100644 --- a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -4,8 +4,9 @@ * Lists all keyboard shortcuts available in Sable in a semantic, * screen-reader-friendly dl/dt/dd structure. */ -import { Box, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Scroll, Text, config } from 'folds'; +import { PageContent } from '$components/page'; +import { SettingsSectionPage } from '../SettingsSectionPage'; type ShortcutEntry = { keys: string; @@ -110,25 +111,12 @@ type KeyboardShortcutsProps = { }; export function KeyboardShortcuts({ requestClose }: KeyboardShortcutsProps) { return ( - - - - - - Keyboard Shortcuts - - - - - - - - - + @@ -154,6 +142,6 @@ export function KeyboardShortcuts({ requestClose }: KeyboardShortcutsProps) { - + ); } diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index f8935b782..729859a75 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -1,5 +1,6 @@ -import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; -import { Page, PageContent, PageHeader } from '$components/page'; +import { Box, Scroll } from 'folds'; +import { PageContent } from '$components/page'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { SystemNotification } from './SystemNotification'; import { AllMessagesNotifications } from './AllMessages'; import { SpecialMessagesNotifications } from './SpecialMessages'; @@ -10,21 +11,7 @@ type NotificationsProps = { }; export function Notifications({ requestClose }: NotificationsProps) { return ( - - - - - - Notifications - - - - - - - - - + @@ -37,6 +24,6 @@ export function Notifications({ requestClose }: NotificationsProps) { - + ); } diff --git a/src/app/features/settings/styles.css.ts b/src/app/features/settings/styles.css.ts index ce89c16ee..84d0a317d 100644 --- a/src/app/features/settings/styles.css.ts +++ b/src/app/features/settings/styles.css.ts @@ -1,6 +1,28 @@ -import { style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { keyframes, style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; export const SequenceCardStyle = style({ padding: config.space.S300, }); + +const focusPulse = keyframes({ + '0%': { + backgroundColor: 'transparent', + boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`, + }, + '20%': { + backgroundColor: `color-mix(in srgb, ${color.Primary.Container} 20%, transparent)`, + }, + '50%': { + backgroundColor: `color-mix(in srgb, ${color.Primary.Container} 8%, transparent)`, + }, + '100%': { + backgroundColor: 'transparent', + boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`, + }, +}); + +export const focusedSettingTile = style({ + borderRadius: config.radii.R400, + animation: `${focusPulse} 3s ease-in-out 1`, +}); diff --git a/src/app/features/settings/useSettingsFocus.ts b/src/app/features/settings/useSettingsFocus.ts new file mode 100644 index 000000000..b301d2097 --- /dev/null +++ b/src/app/features/settings/useSettingsFocus.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { focusedSettingTile } from './styles.css'; + +export function useSettingsFocus() { + const [searchParams, setSearchParams] = useSearchParams(); + const focusId = searchParams.get('focus'); + const activeTargetRef = useRef(null); + const timeoutRef = useRef(undefined); + + useEffect( + () => () => { + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + } + activeTargetRef.current?.classList.remove(focusedSettingTile); + activeTargetRef.current = null; + }, + [] + ); + + useEffect(() => { + if (focusId) { + const target = + document.getElementById(focusId) ?? + document.querySelector(`[data-settings-focus="${focusId}"]`); + + if (target) { + if (activeTargetRef.current && activeTargetRef.current !== target) { + activeTargetRef.current.classList.remove(focusedSettingTile); + } + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + } + + target.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + target.classList.add(focusedSettingTile); + activeTargetRef.current = target; + + timeoutRef.current = window.setTimeout(() => { + target.classList.remove(focusedSettingTile); + if (activeTargetRef.current === target) { + activeTargetRef.current = null; + } + timeoutRef.current = undefined; + + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete('focus'); + return next; + }, + { replace: true } + ); + }, 3000); + } + } + }, [focusId, searchParams, setSearchParams]); +} From f786860947afda1a8876a5bebfef49003e22ca97 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 21:49:00 +0100 Subject: [PATCH 04/18] feat: make settings route-driven --- src/app/features/settings/Settings.tsx | 265 ++++++++++-------- .../features/settings/SettingsRoute.test.tsx | 172 +++++++++++- src/app/features/settings/SettingsRoute.tsx | 68 +++++ src/app/features/settings/index.ts | 1 + 4 files changed, 386 insertions(+), 120 deletions(-) create mode 100644 src/app/features/settings/SettingsRoute.tsx diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 2dd1e4ae4..72ed29257 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -28,17 +28,19 @@ import { stopPropagation } from '$utils/keyboard'; import { LogoutDialog } from '$components/LogoutDialog'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { Notifications } from './notifications'; -import { Devices } from './devices'; -import { EmojisStickers } from './emojis-stickers'; -import { DeveloperTools } from './developer-tools'; import { About } from './about'; import { Account } from './account'; -import { General } from './general'; import { Cosmetics } from './cosmetics/Cosmetics'; +import { DeveloperTools } from './developer-tools'; +import { Devices } from './devices'; +import { EmojisStickers } from './emojis-stickers'; import { Experimental } from './experimental/Experimental'; +import { General } from './general'; import { KeyboardShortcuts } from './keyboard-shortcuts'; +import { Notifications } from './notifications'; import { PerMessageProfilePage } from './Persona/ProfilesPage'; +import { settingsSections, type SettingsSectionId } from './routes'; +import { useSettingsFocus } from './useSettingsFocus'; export enum SettingsPages { GeneralPage, @@ -55,84 +57,99 @@ export enum SettingsPages { } type SettingsMenuItem = { - page: SettingsPages; + id: SettingsSectionId; name: string; icon: IconSrc; activeIcon?: IconSrc; }; -const useSettingsMenuItems = (showPersona: boolean): SettingsMenuItem[] => - useMemo(() => { - const items: SettingsMenuItem[] = [ - { - page: SettingsPages.GeneralPage, - name: 'General', - icon: Icons.Setting, - }, - { - page: SettingsPages.AccountPage, - name: 'Account', - icon: Icons.User, - }, - { - page: SettingsPages.CosmeticsPage, - name: 'Appearance', - icon: Icons.Alphabet, - activeIcon: Icons.AlphabetUnderline, - }, - { - 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.ExperimentalPage, - name: 'Experimental', - icon: Icons.Funnel, - }, - { - page: SettingsPages.AboutPage, - name: 'About', - icon: Icons.Info, - }, - { - page: SettingsPages.KeyboardShortcutsPage, - name: 'Keyboard Shortcuts', - icon: Icons.BlockCode, - }, - ]; +const settingsMenuIcons: Record< + SettingsSectionId, + Pick +> = { + general: { icon: Icons.Setting }, + account: { icon: Icons.User }, + persona: { icon: Icons.User }, + appearance: { icon: Icons.Alphabet, activeIcon: Icons.AlphabetUnderline }, + notifications: { icon: Icons.Bell }, + devices: { icon: Icons.Monitor }, + emojis: { icon: Icons.Smile }, + 'developer-tools': { icon: Icons.Terminal }, + experimental: { icon: Icons.Funnel }, + about: { icon: Icons.Info }, + 'keyboard-shortcuts': { icon: Icons.BlockCode }, +}; - if (showPersona) { - items.splice(2, 0, { - page: SettingsPages.PerMessageProfilesPage, - name: 'Persona', - icon: Icons.User, - }); - } +const settingsPageToSectionId: Record = { + [SettingsPages.GeneralPage]: 'general', + [SettingsPages.AccountPage]: 'account', + [SettingsPages.PerMessageProfilesPage]: 'persona', + [SettingsPages.NotificationPage]: 'notifications', + [SettingsPages.DevicesPage]: 'devices', + [SettingsPages.EmojisStickersPage]: 'emojis', + [SettingsPages.CosmeticsPage]: 'appearance', + [SettingsPages.DeveloperToolsPage]: 'developer-tools', + [SettingsPages.ExperimentalPage]: 'experimental', + [SettingsPages.AboutPage]: 'about', + [SettingsPages.KeyboardShortcutsPage]: 'keyboard-shortcuts', +}; - return items; - }, [showPersona]); +const settingsSectionIdToPage: Record = { + general: SettingsPages.GeneralPage, + account: SettingsPages.AccountPage, + persona: SettingsPages.PerMessageProfilesPage, + appearance: SettingsPages.CosmeticsPage, + notifications: SettingsPages.NotificationPage, + devices: SettingsPages.DevicesPage, + emojis: SettingsPages.EmojisStickersPage, + 'developer-tools': SettingsPages.DeveloperToolsPage, + experimental: SettingsPages.ExperimentalPage, + about: SettingsPages.AboutPage, + 'keyboard-shortcuts': SettingsPages.KeyboardShortcutsPage, +}; -type SettingsProps = { - initialPage?: SettingsPages; +const settingsSectionComponents: Record< + SettingsSectionId, + (props: { requestClose: () => void }) => JSX.Element +> = { + general: General, + account: Account, + persona: PerMessageProfilePage, + appearance: Cosmetics, + notifications: Notifications, + devices: Devices, + emojis: EmojisStickers, + 'developer-tools': DeveloperTools, + experimental: Experimental, + about: About, + 'keyboard-shortcuts': KeyboardShortcuts, +}; + +type ControlledSettingsProps = { + activeSection?: SettingsSectionId | null; + onSelectSection?: (section: SettingsSectionId) => void; requestClose: () => void; + initialPage?: SettingsPages; }; -export function Settings({ initialPage, requestClose }: SettingsProps) { + +function SettingsSectionViewport({ + section, + requestClose, +}: { + section: SettingsSectionId; + requestClose: () => void; +}) { + useSettingsFocus(); + const Section = settingsSectionComponents[section]; + return
; +} + +export function Settings({ + activeSection, + onSelectSection, + requestClose, + initialPage, +}: ControlledSettingsProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const userId = mx.getUserId()!; @@ -143,9 +160,10 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { : undefined; const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); - const screenSize = useScreenSizeContext(); - const [activePage, setActivePage] = useState(() => { + const isControlled = activeSection !== undefined; + + const [legacyActivePage, setLegacyActivePage] = useState(() => { if (initialPage === SettingsPages.PerMessageProfilesPage && !showPersona) { return SettingsPages.GeneralPage; } @@ -153,20 +171,59 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage; }); - const menuItems = useSettingsMenuItems(showPersona); + const visibleSection = useMemo(() => { + if (isControlled) return activeSection; + + if (legacyActivePage === undefined) { + return null; + } + + const section = settingsPageToSectionId[legacyActivePage]; + if (section === 'persona' && !showPersona) { + return 'general'; + } + return section; + }, [activeSection, isControlled, legacyActivePage, showPersona]); + + const menuItems = useMemo( + () => + settingsSections + .filter((section) => showPersona || section.id !== 'persona') + .map((section) => ({ + id: section.id, + name: section.label, + ...settingsMenuIcons[section.id], + })), + [showPersona] + ); + + const handleSelectSection = (section: SettingsSectionId) => { + if (isControlled) { + onSelectSection?.(section); + return; + } + + setLegacyActivePage(settingsSectionIdToPage[section]); + }; + + const handleRequestClose = () => { + if (isControlled) { + requestClose(); + return; + } - const handlePageRequestClose = () => { if (screenSize === ScreenSize.Mobile) { - setActivePage(undefined); + setLegacyActivePage(undefined); return; } + requestClose(); }; return ( @@ -183,7 +240,11 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {screenSize === ScreenSize.Mobile && ( - + )} @@ -194,23 +255,23 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{menuItems.map((item) => { const currentIcon = - activePage === item.page && item.activeIcon ? item.activeIcon : item.icon; + visibleSection === item.id && item.activeIcon ? item.activeIcon : item.icon; return ( + } - onClick={() => setActivePage(item.page)} + onClick={() => handleSelectSection(item.id)} > - {activePage === SettingsPages.GeneralPage && ( - - )} - {activePage === SettingsPages.AccountPage && ( - - )} - {activePage === SettingsPages.PerMessageProfilesPage && showPersona && ( - - )} - {activePage === SettingsPages.CosmeticsPage && ( - - )} - {activePage === SettingsPages.NotificationPage && ( - - )} - {activePage === SettingsPages.DevicesPage && ( - - )} - {activePage === SettingsPages.EmojisStickersPage && ( - - )} - {activePage === SettingsPages.DeveloperToolsPage && ( - - )} - {activePage === SettingsPages.ExperimentalPage && ( - - )} - {activePage === SettingsPages.AboutPage && } - {activePage === SettingsPages.KeyboardShortcutsPage && ( - + {visibleSection && ( + )} ); diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 99ab01e6c..04d80bda4 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -1,12 +1,98 @@ -import { act, render, screen } from '@testing-library/react'; -import { MemoryRouter, useLocation } from 'react-router-dom'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; import { SettingTile } from '$components/setting-tile'; import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { getSettingsPath } from '$pages/pathUtils'; +import { SettingsRoute } from './SettingsRoute'; import { SettingsSectionPage } from './SettingsSectionPage'; import { focusedSettingTile } from './styles.css'; import { useSettingsFocus } from './useSettingsFocus'; +const { mockMatrixClient, mockProfile, mockUseSetting, createSectionMock } = vi.hoisted(() => { + const mockSettingsHook = vi.fn(() => [true, vi.fn()] as const); + + const createMockSection = (title: string) => + function MockSection({ requestClose }: { requestClose: () => void }) { + return ( +
+

{title}

+ +
+ ); + }; + + return { + mockMatrixClient: { getUserId: () => '@alice:server' }, + mockProfile: { displayName: 'Alice', avatarUrl: undefined }, + mockUseSetting: mockSettingsHook, + createSectionMock: createMockSection, + }; +}); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMatrixClient, +})); + +vi.mock('$hooks/useUserProfile', () => ({ + useUserProfile: () => mockProfile, +})); + +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: mockUseSetting, +})); + +vi.mock('./general', () => ({ + General: createSectionMock('General section'), +})); + +vi.mock('./account', () => ({ + Account: createSectionMock('Account section'), +})); + +vi.mock('./cosmetics/Cosmetics', () => ({ + Cosmetics: createSectionMock('Appearance section'), +})); + +vi.mock('./notifications', () => ({ + Notifications: createSectionMock('Notifications section'), +})); + +vi.mock('./devices', () => ({ + Devices: createSectionMock('Devices section'), +})); + +vi.mock('./emojis-stickers', () => ({ + EmojisStickers: createSectionMock('Emojis & Stickers section'), +})); + +vi.mock('./developer-tools/DevelopTools', () => ({ + DeveloperTools: createSectionMock('Developer Tools section'), +})); + +vi.mock('./experimental/Experimental', () => ({ + Experimental: createSectionMock('Experimental section'), +})); + +vi.mock('./about', () => ({ + About: createSectionMock('About section'), +})); + +vi.mock('./keyboard-shortcuts', () => ({ + KeyboardShortcuts: createSectionMock('Keyboard Shortcuts section'), +})); + +vi.mock('./Persona/ProfilesPage', () => ({ + PerMessageProfilePage: createSectionMock('Persona section'), +})); + function FocusFixture() { useSettingsFocus(); @@ -19,7 +105,25 @@ function FocusFixture() { function LocationProbe() { const location = useLocation(); - return
{location.search}
; + return ( +
+ {location.pathname} + {location.search} +
+ ); +} + +function renderSettingsRoute(path: string, screenSize: ScreenSize) { + return render( + + + + + } /> + + + + ); } describe('SettingsSectionPage', () => { @@ -50,6 +154,66 @@ describe('SettingsSectionPage', () => { }); }); +describe('SettingsRoute', () => { + it('renders the menu index on mobile /settings', () => { + renderSettingsRoute('/settings', ScreenSize.Mobile); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'General section' })).not.toBeInTheDocument(); + }); + + it('shows the general section by default on desktop /settings without mutating the URL', () => { + renderSettingsRoute('/settings', ScreenSize.Desktop); + + expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); + expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings'); + }); + + it('renders the requested section at /settings/devices', () => { + renderSettingsRoute('/settings/devices', ScreenSize.Mobile); + + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + }); + + it('redirects invalid sections back to /settings', async () => { + renderSettingsRoute('/settings/not-a-real-section', ScreenSize.Mobile); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath()) + ); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('navigates when a menu item is clicked', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings', ScreenSize.Mobile); + + await user.click(screen.getByRole('button', { name: 'Notifications' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent( + getSettingsPath('notifications') + ) + ); + expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); + }); + + it('returns to /settings when a section back button is clicked', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings/devices', ScreenSize.Mobile); + + await user.click(screen.getByRole('button', { name: 'Back' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath()) + ); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); +}); + describe('useSettingsFocus', () => { it('highlights a focus target from the query string', async () => { vi.useFakeTimers(); @@ -77,7 +241,7 @@ describe('useSettingsFocus', () => { await act(async () => { await vi.advanceTimersByTimeAsync(1); }); - expect(screen.getByTestId('location-probe')).toHaveTextContent(''); + expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/appearance'); expect(target).not.toHaveClass(focusedSettingTile); } finally { vi.useRealTimers(); diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx new file mode 100644 index 000000000..37a5235b7 --- /dev/null +++ b/src/app/features/settings/SettingsRoute.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { getSettingsPath } from '$pages/pathUtils'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Settings } from './Settings'; +import { isSettingsSectionId, type SettingsSectionId } from './routes'; + +function resolveSettingsSection( + section: string | undefined, + screenSize: ScreenSize, + showPersona: boolean +): SettingsSectionId | null { + if (section === undefined) { + return screenSize === ScreenSize.Mobile ? null : 'general'; + } + + if (!isSettingsSectionId(section)) { + return null; + } + + if (section === 'persona' && !showPersona) { + return null; + } + + return section; +} + +export function SettingsRoute() { + const { section } = useParams<{ section?: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const screenSize = useScreenSizeContext(); + const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); + + const activeSection = resolveSettingsSection(section, screenSize, showPersona); + const shouldRedirectToIndex = section !== undefined && activeSection === null; + + useEffect(() => { + if (!shouldRedirectToIndex) return; + + navigate(getSettingsPath(), { replace: true, state: location.state }); + }, [location.state, navigate, shouldRedirectToIndex]); + + if (shouldRedirectToIndex) return null; + + const requestClose = () => { + if (activeSection === null) { + navigate(-1); + return; + } + + navigate(getSettingsPath(), { replace: true }); + }; + + const handleSelectSection = (nextSection: SettingsSectionId) => { + navigate(getSettingsPath(nextSection)); + }; + + return ( + + ); +} diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts index 2db86201e..0c42aca54 100644 --- a/src/app/features/settings/index.ts +++ b/src/app/features/settings/index.ts @@ -1,2 +1,3 @@ export * from './Settings'; +export * from './SettingsRoute'; export * from './routes'; From 7aad1ac68dedd5e96a9a57c5251ed13d9cda5894 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 21:56:13 +0100 Subject: [PATCH 05/18] fix: align settings mobile back behavior --- .../features/settings/SettingsRoute.test.tsx | 24 ++++++++++++------- src/app/features/settings/SettingsRoute.tsx | 7 +----- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 04d80bda4..96851376c 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation, useNavigationType } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; import { SettingTile } from '$components/setting-tile'; import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; @@ -105,17 +105,24 @@ function FocusFixture() { function LocationProbe() { const location = useLocation(); + const navigationType = useNavigationType(); return (
{location.pathname} {location.search} + {navigationType}
); } -function renderSettingsRoute(path: string, screenSize: ScreenSize) { +function renderSettingsRoute( + path: string, + screenSize: ScreenSize, + options?: { initialEntries?: string[]; initialIndex?: number } +) { + const initialEntries = options?.initialEntries ?? [path]; return render( - + @@ -200,16 +207,17 @@ describe('SettingsRoute', () => { expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); }); - it('returns to /settings when a section back button is clicked', async () => { + it('uses history back semantics when a section back button is clicked', async () => { const user = userEvent.setup(); - renderSettingsRoute('/settings/devices', ScreenSize.Mobile); + renderSettingsRoute('/settings/devices', ScreenSize.Mobile, { + initialEntries: ['/settings/', '/settings/devices/'], + initialIndex: 1, + }); await user.click(screen.getByRole('button', { name: 'Back' })); - await waitFor(() => - expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath()) - ); + await waitFor(() => expect(screen.getByTestId('location-probe')).toHaveTextContent('POP')); expect(screen.getByText('Settings')).toBeInTheDocument(); }); }); diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index 37a5235b7..8b64cc17c 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -46,12 +46,7 @@ export function SettingsRoute() { if (shouldRedirectToIndex) return null; const requestClose = () => { - if (activeSection === null) { - navigate(-1); - return; - } - - navigate(getSettingsPath(), { replace: true }); + navigate(-1); }; const handleSelectSection = (nextSection: SettingsSectionId) => { From 3f03a1a1dd2ce3a220fb28eddeee7445c9a487a8 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 22:03:08 +0100 Subject: [PATCH 06/18] fix: harden settings route navigation --- .../features/settings/SettingsRoute.test.tsx | 38 ++++++++++++++++++- src/app/features/settings/SettingsRoute.tsx | 14 ++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 96851376c..55c04d37b 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter, Route, Routes, useLocation, useNavigationType } from 'rea import { describe, expect, it, vi } from 'vitest'; import { SettingTile } from '$components/setting-tile'; import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; -import { getSettingsPath } from '$pages/pathUtils'; +import { getHomePath, getSettingsPath } from '$pages/pathUtils'; import { SettingsRoute } from './SettingsRoute'; import { SettingsSectionPage } from './SettingsSectionPage'; import { focusedSettingTile } from './styles.css'; @@ -192,6 +192,31 @@ describe('SettingsRoute', () => { expect(screen.getByText('Settings')).toBeInTheDocument(); }); + it('falls back to /settings when a direct section entry is closed', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings/devices', ScreenSize.Mobile); + + await user.click(screen.getByRole('button', { name: 'Back' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath()) + ); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('falls back to /home when the root settings page is closed from a direct entry', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings', ScreenSize.Mobile); + + await user.click(screen.getByRole('button', { name: 'Close settings' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()) + ); + }); + it('navigates when a menu item is clicked', async () => { const user = userEvent.setup(); @@ -207,6 +232,17 @@ describe('SettingsRoute', () => { expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); }); + it('does not push history when the active section is reselected', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings/notifications', ScreenSize.Desktop); + + await user.click(screen.getByRole('button', { name: 'Notifications' })); + + expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/notifications'); + expect(screen.getByTestId('location-probe')).not.toHaveTextContent('PUSH'); + }); + it('uses history back semantics when a section back button is clicked', async () => { const user = userEvent.setup(); diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index 8b64cc17c..d1a6c5b15 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { getSettingsPath } from '$pages/pathUtils'; +import { getHomePath, getSettingsPath } from '$pages/pathUtils'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { Settings } from './Settings'; @@ -46,10 +46,22 @@ export function SettingsRoute() { if (shouldRedirectToIndex) return null; const requestClose = () => { + if (section !== undefined && location.key === 'default') { + navigate(getSettingsPath(), { replace: true, state: location.state }); + return; + } + + if (section === undefined && location.key === 'default') { + navigate(getHomePath(), { replace: true }); + return; + } + navigate(-1); }; const handleSelectSection = (nextSection: SettingsSectionId) => { + if (nextSection === activeSection) return; + navigate(getSettingsPath(nextSection)); }; From bb47bd037a9ee9ad842e24d5b28706cae3797b3e Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 22:09:07 +0100 Subject: [PATCH 07/18] feat: render settings as a shallow desktop modal --- .../features/settings/SettingsRoute.test.tsx | 94 ++++++++++++++++++- .../settings/SettingsShallowRouteRenderer.tsx | 22 +++++ src/app/pages/Router.tsx | 9 +- src/app/pages/client/ClientRouteOutlet.tsx | 35 +++++++ src/app/pages/client/index.ts | 1 + 5 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 src/app/features/settings/SettingsShallowRouteRenderer.tsx create mode 100644 src/app/pages/client/ClientRouteOutlet.tsx diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 55c04d37b..2dba6df00 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -1,11 +1,22 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, Route, Routes, useLocation, useNavigationType } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, + useNavigationType, +} from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; import { SettingTile } from '$components/setting-tile'; +import { ClientRouteOutlet } from '$pages/client/ClientRouteOutlet'; import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; import { getHomePath, getSettingsPath } from '$pages/pathUtils'; +import { SETTINGS_PATH } from '$pages/paths'; import { SettingsRoute } from './SettingsRoute'; +import { SettingsShallowRouteRenderer } from './SettingsShallowRouteRenderer'; import { SettingsSectionPage } from './SettingsSectionPage'; import { focusedSettingTile } from './styles.css'; import { useSettingsFocus } from './useSettingsFocus'; @@ -49,6 +60,10 @@ vi.mock('$state/hooks/settings', () => ({ useSetting: mockUseSetting, })); +vi.mock('$components/Modal500', () => ({ + Modal500: ({ children }: { children: ReactNode }) =>
{children}
, +})); + vi.mock('./general', () => ({ General: createSectionMock('General section'), })); @@ -115,6 +130,48 @@ function LocationProbe() { ); } +function HomePage() { + const navigate = useNavigate(); + const location = useLocation(); + + return ( +
+

Home route

+ +
+ ); +} + +function renderClientShell( + screenSize: ScreenSize, + options?: { initialEntries?: string[]; initialIndex?: number } +) { + const initialEntries = options?.initialEntries ?? [getHomePath()]; + return render( + + + + + }> + } /> + } /> + + + + + + ); +} + function renderSettingsRoute( path: string, screenSize: ScreenSize, @@ -126,7 +183,7 @@ function renderSettingsRoute( - } /> + } />
@@ -258,6 +315,39 @@ describe('SettingsRoute', () => { }); }); +describe('Settings shallow route shell', () => { + it('keeps the desktop background route mounted when settings opens shallow', async () => { + const user = userEvent.setup(); + + renderClientShell(ScreenSize.Desktop); + + await user.click(screen.getByRole('button', { name: 'Open settings' })); + + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); + }); + + it('renders mobile settings as a full page without retaining the background outlet', async () => { + const user = userEvent.setup(); + + renderClientShell(ScreenSize.Mobile); + + await user.click(screen.getByRole('button', { name: 'Open settings' })); + + expect(screen.queryByRole('heading', { name: 'Home route' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); + }); + + it('renders desktop direct entry settings as a full page without retaining the background outlet', () => { + renderClientShell(ScreenSize.Desktop, { + initialEntries: [getSettingsPath('devices')], + }); + + expect(screen.queryByRole('heading', { name: 'Home route' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + }); +}); + describe('useSettingsFocus', () => { it('highlights a focus target from the query string', async () => { vi.useFakeTimers(); diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx new file mode 100644 index 000000000..92256fa82 --- /dev/null +++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx @@ -0,0 +1,22 @@ +import { Route, Routes, useNavigate, useLocation } from 'react-router-dom'; +import { useScreenSizeContext } from '$hooks/useScreenSize'; +import { Modal500 } from '$components/Modal500'; +import { isShallowSettingsRoute } from '$pages/client/ClientRouteOutlet'; +import { SETTINGS_PATH } from '$pages/paths'; +import { SettingsRoute } from './SettingsRoute'; + +export function SettingsShallowRouteRenderer() { + const navigate = useNavigate(); + const location = useLocation(); + const screenSize = useScreenSizeContext(); + + if (!isShallowSettingsRoute(location.pathname, location.state, screenSize)) return null; + + return ( + navigate(-1)}> + + } /> + + + ); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index d81890da1..28f8c7efb 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -10,6 +10,8 @@ import * as Sentry from '@sentry/react'; import { ClientConfig } from '$hooks/useClientConfig'; import { ErrorPage } from '$components/DefaultErrorPage'; +import { SettingsRoute } from '$features/settings'; +import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; @@ -49,6 +51,7 @@ import { SERVER_PATH_SEGMENT, CREATE_PATH, TO_ROOM_EVENT_PATH, + SETTINGS_PATH, } from './paths'; import { getAppPathFromHref, @@ -59,7 +62,7 @@ import { getOriginBaseUrl, getSpaceLobbyPath, } from './pathUtils'; -import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; +import { ClientBindAtoms, ClientLayout, ClientRoot, ClientRouteOutlet } from './client'; import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; @@ -182,7 +185,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } > - + @@ -191,6 +194,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + @@ -340,6 +344,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> + } /> { + if (screenSize === ScreenSize.Mobile) return false; + if (!matchPath(SETTINGS_PATH, pathname)) return false; + + const backgroundLocation = (state as BackgroundLocationState | null)?.backgroundLocation; + return !!backgroundLocation; +}; + +export function ClientRouteOutlet() { + const outlet = useOutlet(); + const location = useLocation(); + const screenSize = useScreenSizeContext(); + const cachedOutletRef = useRef(outlet); + const shallowSettings = isShallowSettingsRoute(location.pathname, location.state, screenSize); + + if (!shallowSettings) { + cachedOutletRef.current = outlet; + return outlet; + } + + return cachedOutletRef.current; +} diff --git a/src/app/pages/client/index.ts b/src/app/pages/client/index.ts index 5668e81de..7c654fb33 100644 --- a/src/app/pages/client/index.ts +++ b/src/app/pages/client/index.ts @@ -1,3 +1,4 @@ export * from './ClientRoot'; export * from './ClientBindAtoms'; export * from './ClientLayout'; +export * from './ClientRouteOutlet'; From fbde318180a90a92e440472291c5daa38234a98a Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 22:13:02 +0100 Subject: [PATCH 08/18] fix: preserve shallow settings background state --- src/app/features/settings/SettingsRoute.test.tsx | 12 ++++++++++++ src/app/features/settings/SettingsRoute.tsx | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 2dba6df00..90f3d6010 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -338,6 +338,18 @@ describe('Settings shallow route shell', () => { expect(screen.getByRole('heading', { name: 'Notifications section' })).toBeInTheDocument(); }); + it('keeps the desktop background route mounted while switching shallow settings sections', async () => { + const user = userEvent.setup(); + + renderClientShell(ScreenSize.Desktop); + + await user.click(screen.getByRole('button', { name: 'Open settings' })); + await user.click(screen.getByRole('button', { name: 'Devices' })); + + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + }); + it('renders desktop direct entry settings as a full page without retaining the background outlet', () => { renderClientShell(ScreenSize.Desktop, { initialEntries: [getSettingsPath('devices')], diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index d1a6c5b15..30b74eb25 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -62,7 +62,7 @@ export function SettingsRoute() { const handleSelectSection = (nextSection: SettingsSectionId) => { if (nextSection === activeSection) return; - navigate(getSettingsPath(nextSection)); + navigate(getSettingsPath(nextSection), { state: location.state }); }; return ( From 8a8791e12fdd142898e81ca440d5b898a5574662 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 22:19:36 +0100 Subject: [PATCH 09/18] fix: avoid shallow settings history churn --- .../features/settings/SettingsRoute.test.tsx | 17 +++++++++++++++++ src/app/features/settings/SettingsRoute.tsx | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 90f3d6010..9ddaf16c6 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -350,6 +350,23 @@ describe('Settings shallow route shell', () => { expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); }); + it('closes a desktop shallow settings flow in one step after switching sections', async () => { + const user = userEvent.setup(); + + renderClientShell(ScreenSize.Desktop); + + await user.click(screen.getByRole('button', { name: 'Open settings' })); + await user.click(screen.getByRole('button', { name: 'Devices' })); + await user.click(screen.getByRole('button', { name: 'Back' })); + + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Devices section' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'Notifications section' }) + ).not.toBeInTheDocument(); + expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()); + }); + it('renders desktop direct entry settings as a full page without retaining the background outlet', () => { renderClientShell(ScreenSize.Desktop, { initialEntries: [getSettingsPath('devices')], diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index 30b74eb25..deb857289 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -33,6 +33,9 @@ export function SettingsRoute() { const location = useLocation(); const screenSize = useScreenSizeContext(); const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); + const shallowBackgroundState = + screenSize !== ScreenSize.Mobile && + Boolean((location.state as { backgroundLocation?: unknown } | null)?.backgroundLocation); const activeSection = resolveSettingsSection(section, screenSize, showPersona); const shouldRedirectToIndex = section !== undefined && activeSection === null; @@ -62,7 +65,10 @@ export function SettingsRoute() { const handleSelectSection = (nextSection: SettingsSectionId) => { if (nextSection === activeSection) return; - navigate(getSettingsPath(nextSection), { state: location.state }); + navigate(getSettingsPath(nextSection), { + replace: shallowBackgroundState, + state: location.state, + }); }; return ( From e76c278dad3c819a3969e156009b4d038315dbdb Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 22:23:21 +0100 Subject: [PATCH 10/18] refactor: open settings via route navigation --- .../features/settings/SettingsRoute.test.tsx | 45 +++++++++++++++++++ src/app/features/settings/index.ts | 1 + src/app/features/settings/useOpenSettings.ts | 18 ++++++++ .../client/sidebar/AccountSwitcherTab.tsx | 16 +++---- .../pages/client/sidebar/UnverifiedTab.tsx | 17 ++----- 5 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 src/app/features/settings/useOpenSettings.ts diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 9ddaf16c6..96eaa8f19 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -19,6 +19,7 @@ import { SettingsRoute } from './SettingsRoute'; import { SettingsShallowRouteRenderer } from './SettingsShallowRouteRenderer'; import { SettingsSectionPage } from './SettingsSectionPage'; import { focusedSettingTile } from './styles.css'; +import { useOpenSettings } from './useOpenSettings'; import { useSettingsFocus } from './useSettingsFocus'; const { mockMatrixClient, mockProfile, mockUseSetting, createSectionMock } = vi.hoisted(() => { @@ -151,6 +152,19 @@ function HomePage() { ); } +function OpenSettingsHomePage() { + const openSettings = useOpenSettings(); + + return ( +
+

Home route

+ +
+ ); +} + function renderClientShell( screenSize: ScreenSize, options?: { initialEntries?: string[]; initialIndex?: number } @@ -172,6 +186,23 @@ function renderClientShell( ); } +function renderClientShellWithOpenSettings(screenSize: ScreenSize) { + return render( + + + + + }> + } /> + } /> + + + + + + ); +} + function renderSettingsRoute( path: string, screenSize: ScreenSize, @@ -316,6 +347,20 @@ describe('SettingsRoute', () => { }); describe('Settings shallow route shell', () => { + it('opens device settings through route navigation and keeps the desktop background mounted', async () => { + const user = userEvent.setup(); + + renderClientShellWithOpenSettings(ScreenSize.Desktop); + + await user.click(screen.getByRole('button', { name: 'Open devices settings' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('devices')) + ); + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + }); + it('keeps the desktop background route mounted when settings opens shallow', async () => { const user = userEvent.setup(); diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts index 0c42aca54..5b1e43423 100644 --- a/src/app/features/settings/index.ts +++ b/src/app/features/settings/index.ts @@ -1,3 +1,4 @@ export * from './Settings'; export * from './SettingsRoute'; export * from './routes'; +export * from './useOpenSettings'; diff --git a/src/app/features/settings/useOpenSettings.ts b/src/app/features/settings/useOpenSettings.ts new file mode 100644 index 000000000..90bd59f6d --- /dev/null +++ b/src/app/features/settings/useOpenSettings.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { getSettingsPath } from '$pages/pathUtils'; +import type { SettingsSectionId } from './routes'; + +export function useOpenSettings() { + const navigate = useNavigate(); + const location = useLocation(); + + return useCallback( + (section?: SettingsSectionId, focus?: string) => { + navigate(getSettingsPath(section, focus), { + state: { backgroundLocation: location }, + }); + }, + [location, navigate] + ); +} diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 089c7b84b..6e6ecc572 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -42,7 +42,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; -import { Settings } from '$features/settings'; +import { useOpenSettings } from '$features/settings'; import { Modal500 } from '$components/Modal500'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; @@ -152,6 +152,7 @@ export function AccountSwitcherTab() { const useAuthentication = useMediaAuthentication(); const backgroundUnreads = useAtomValue(backgroundUnreadCountsAtom); const setBackgroundUnreads = useSetAtom(backgroundUnreadCountsAtom); + const openSettings = useOpenSettings(); // Total unread count across all background sessions (for the sidebar badge). const totalBackgroundUnread = Object.entries(backgroundUnreads) @@ -164,7 +165,6 @@ export function AccountSwitcherTab() { const [menuAnchor, setMenuAnchor] = useState(); const [busyUserIds, setBusyUserIds] = useState>(new Set()); - const [settingsOpen, setSettingsOpen] = useState(false); const [confirmSignOutSession, setConfirmSignOutSession] = useState( undefined ); @@ -184,10 +184,9 @@ export function AccountSwitcherTab() { const handleToggle: MouseEventHandler = (evt) => { if (disableAccountSwitcher) { - setSettingsOpen(true); + openSettings(); return; } - const cords = evt.currentTarget.getBoundingClientRect(); setMenuAnchor((cur) => (cur ? undefined : cords)); }; @@ -258,7 +257,7 @@ export function AccountSwitcherTab() { userId: activeSession?.userId, }); setMenuAnchor(undefined); - setSettingsOpen(true); + openSettings(); }; const activeLocalPart = @@ -268,7 +267,7 @@ export function AccountSwitcherTab() { if (!activeSession) return null; return ( - + {(triggerRef) => ( - {settingsOpen && ( - setSettingsOpen(false)}> - setSettingsOpen(false)} /> - - )} {confirmSignOutSession && ( setConfirmSignOutSession(undefined)}> diff --git a/src/app/pages/client/sidebar/UnverifiedTab.tsx b/src/app/pages/client/sidebar/UnverifiedTab.tsx index 206b55db9..e66a4d928 100644 --- a/src/app/pages/client/sidebar/UnverifiedTab.tsx +++ b/src/app/pages/client/sidebar/UnverifiedTab.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { Badge, color, Icon, Icons, Text } from 'folds'; import { SidebarAvatar, @@ -14,12 +13,12 @@ import { VerificationStatus, } from '$hooks/useDeviceVerificationStatus'; import { useCrossSigningActive } from '$hooks/useCrossSigning'; -import { Modal500 } from '$components/Modal500'; -import { Settings, SettingsPages } from '$features/settings'; +import { useOpenSettings } from '$features/settings'; import * as css from './UnverifiedTab.css'; function UnverifiedIndicator() { const mx = useMatrixClient(); + const openSettings = useOpenSettings(); const crypto = mx.getCrypto(); const [devices] = useDeviceList(); @@ -40,15 +39,12 @@ function UnverifiedIndicator() { otherDevicesId ); - const [settings, setSettings] = useState(false); - const closeSettings = () => setSettings(false); - const hasUnverified = unverified || (unverifiedDeviceCount !== undefined && unverifiedDeviceCount > 0); return ( <> {hasUnverified && ( - + {(triggerRef) => ( setSettings(true)} + onClick={() => openSettings('devices')} > )} - {settings && ( - - - - )} ); } From 633bd2590402053cb0919a9cf609b9a49cf37f98 Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 22:30:06 +0100 Subject: [PATCH 11/18] fix: prevent nested settings openers --- .../features/settings/SettingsRoute.test.tsx | 37 ++++++++++++++++++- src/app/features/settings/useOpenSettings.ts | 9 ++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 96eaa8f19..2a5948c2e 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -186,11 +186,26 @@ function renderClientShell( ); } -function renderClientShellWithOpenSettings(screenSize: ScreenSize) { +function SidebarSettingsShortcut() { + const openSettings = useOpenSettings(); + + return ( + + ); +} + +function renderClientShellWithOpenSettings( + screenSize: ScreenSize, + options?: { initialEntries?: string[]; initialIndex?: number } +) { + const initialEntries = options?.initialEntries ?? [getHomePath()]; return render( - + + }> } /> @@ -361,6 +376,24 @@ describe('Settings shallow route shell', () => { expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); }); + it('does not create a nested shallow settings view when opened from full-page settings', async () => { + const user = userEvent.setup(); + + renderClientShellWithOpenSettings(ScreenSize.Desktop, { + initialEntries: [getSettingsPath('notifications')], + }); + + await user.click(screen.getByRole('button', { name: 'Sidebar devices shortcut' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('devices')) + ); + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'Notifications section' }) + ).not.toBeInTheDocument(); + }); + it('keeps the desktop background route mounted when settings opens shallow', async () => { const user = userEvent.setup(); diff --git a/src/app/features/settings/useOpenSettings.ts b/src/app/features/settings/useOpenSettings.ts index 90bd59f6d..9c38bb2a8 100644 --- a/src/app/features/settings/useOpenSettings.ts +++ b/src/app/features/settings/useOpenSettings.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { getSettingsPath } from '$pages/pathUtils'; +import { SETTINGS_PATH } from '$pages/paths'; import type { SettingsSectionId } from './routes'; export function useOpenSettings() { @@ -9,8 +10,12 @@ export function useOpenSettings() { return useCallback( (section?: SettingsSectionId, focus?: string) => { + const settingsState = matchPath(SETTINGS_PATH, location.pathname) + ? undefined + : { backgroundLocation: location }; + navigate(getSettingsPath(section, focus), { - state: { backgroundLocation: location }, + state: settingsState, }); }, [location, navigate] From 8d1c6e1d06e1b8f0a669e260dfa2225f305de67c Mon Sep 17 00:00:00 2001 From: hazre Date: Thu, 26 Mar 2026 23:52:07 +0100 Subject: [PATCH 12/18] fix: redirect desktop settings root --- .../features/settings/SettingsRoute.test.tsx | 22 +++++++++++-- src/app/features/settings/SettingsRoute.tsx | 31 +++++++++++++++---- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 2a5948c2e..97cd518f4 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -273,11 +273,29 @@ describe('SettingsRoute', () => { expect(screen.queryByRole('heading', { name: 'General section' })).not.toBeInTheDocument(); }); - it('shows the general section by default on desktop /settings without mutating the URL', () => { + it('redirects desktop /settings to /settings/general', async () => { renderSettingsRoute('/settings', ScreenSize.Desktop); + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('general')) + ); expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); - expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings'); + }); + + it('falls back to /home when the redirected desktop general page is closed from a direct root entry', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings', ScreenSize.Desktop); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('general')) + ); + + await user.click(screen.getByRole('button', { name: 'Back' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()) + ); }); it('renders the requested section at /settings/devices', () => { diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index deb857289..81d266c97 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -7,6 +7,11 @@ import { settingsAtom } from '$state/settings'; import { Settings } from './Settings'; import { isSettingsSectionId, type SettingsSectionId } from './routes'; +type SettingsRouteState = { + backgroundLocation?: unknown; + redirectedFromDesktopRoot?: boolean; +}; + function resolveSettingsSection( section: string | undefined, screenSize: ScreenSize, @@ -33,24 +38,38 @@ export function SettingsRoute() { const location = useLocation(); const screenSize = useScreenSizeContext(); const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); + const routeState = location.state as SettingsRouteState | null; const shallowBackgroundState = - screenSize !== ScreenSize.Mobile && - Boolean((location.state as { backgroundLocation?: unknown } | null)?.backgroundLocation); + screenSize !== ScreenSize.Mobile && Boolean(routeState?.backgroundLocation); const activeSection = resolveSettingsSection(section, screenSize, showPersona); + const shouldRedirectToGeneral = section === undefined && screenSize !== ScreenSize.Mobile; const shouldRedirectToIndex = section !== undefined && activeSection === null; useEffect(() => { + if (shouldRedirectToGeneral) { + navigate(getSettingsPath('general'), { + replace: true, + state: routeState?.backgroundLocation ? routeState : { redirectedFromDesktopRoot: true }, + }); + return; + } + if (!shouldRedirectToIndex) return; - navigate(getSettingsPath(), { replace: true, state: location.state }); - }, [location.state, navigate, shouldRedirectToIndex]); + navigate(getSettingsPath(), { replace: true, state: routeState }); + }, [navigate, routeState, shouldRedirectToGeneral, shouldRedirectToIndex]); - if (shouldRedirectToIndex) return null; + if (shouldRedirectToGeneral || shouldRedirectToIndex) return null; const requestClose = () => { + if (section !== undefined && routeState?.redirectedFromDesktopRoot) { + navigate(getHomePath(), { replace: true }); + return; + } + if (section !== undefined && location.key === 'default') { - navigate(getSettingsPath(), { replace: true, state: location.state }); + navigate(getSettingsPath(), { replace: true, state: routeState }); return; } From 95335de49a831dac2ef50a02b3363709f2d74e07 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 13:03:29 +0100 Subject: [PATCH 13/18] feat: route settings and add permalinks --- config.json | 2 + src/app/components/Modal500.test.tsx | 15 + src/app/components/Modal500.tsx | 7 +- .../components/RenderMessageContent.test.tsx | 48 +++ src/app/components/RenderMessageContent.tsx | 9 +- .../components/event-history/EventHistory.tsx | 5 +- src/app/components/message/Reply.tsx | 24 +- .../components/message/layout/layout.css.ts | 11 +- .../components/sequence-card/SequenceCard.tsx | 1 + .../setting-tile/SettingTile.css.ts | 54 +++ .../setting-tile/SettingTile.test.tsx | 97 +++++ .../components/setting-tile/SettingTile.tsx | 89 ++++- .../upload-card/UploadCardRenderer.tsx | 5 +- .../user-profile/UserRoomProfile.tsx | 26 +- .../message-search/SearchResultGroup.tsx | 25 +- src/app/features/room/RoomTimeline.tsx | 25 +- src/app/features/room/ThreadBrowser.tsx | 35 +- src/app/features/room/ThreadDrawer.tsx | 35 +- .../features/room/message/MessageEditor.tsx | 5 +- .../room/room-pin-menu/RoomPinMenu.tsx | 25 +- .../settings/Persona/ProfilesPage.tsx | 5 +- src/app/features/settings/Settings.test.tsx | 190 ++++++++++ src/app/features/settings/Settings.tsx | 52 ++- .../settings/SettingsPermalinkContext.tsx | 16 + .../features/settings/SettingsRoute.test.tsx | 346 +++++++++++++++--- src/app/features/settings/SettingsRoute.tsx | 53 ++- .../features/settings/SettingsSectionPage.tsx | 21 +- .../settings/SettingsShallowRouteRenderer.tsx | 9 +- src/app/features/settings/about/About.tsx | 33 +- src/app/features/settings/account/Account.tsx | 5 +- .../settings/account/AnimalCosmetics.tsx | 3 + .../features/settings/account/BioEditor.tsx | 2 +- .../features/settings/account/ContactInfo.tsx | 6 +- .../settings/account/IgnoredUserList.tsx | 1 + .../features/settings/account/MatrixId.tsx | 1 + .../settings/account/NameColorEditor.tsx | 2 +- src/app/features/settings/account/Profile.tsx | 7 +- .../settings/account/PronounEditor.tsx | 1 + .../settings/account/StatusEditor.tsx | 2 +- .../settings/account/TimezoneEditor.tsx | 1 + .../features/settings/cosmetics/Cosmetics.tsx | 16 +- .../cosmetics/LanguageSpecificPronouns.tsx | 2 + .../features/settings/cosmetics/Themes.tsx | 15 +- .../settings/developer-tools/AccountData.tsx | 1 + .../settings/developer-tools/DevelopTools.tsx | 11 +- .../developer-tools/SentrySettings.tsx | 7 + .../features/settings/devices/DeviceTile.tsx | 2 + src/app/features/settings/devices/Devices.tsx | 6 +- .../features/settings/devices/LocalBackup.tsx | 6 +- .../settings/devices/OtherDevices.tsx | 1 + .../emojis-stickers/EmojisStickers.tsx | 9 +- .../settings/emojis-stickers/GlobalPacks.tsx | 4 + .../settings/emojis-stickers/UserPack.tsx | 1 + .../experimental/BandwithSavingEmojis.tsx | 1 + .../settings/experimental/Experimental.tsx | 6 +- .../experimental/MSC4268HistoryShare.tsx | 1 + src/app/features/settings/general/General.tsx | 63 +++- .../SettingsLinkBaseUrlSetting.test.tsx | 71 ++++ .../general/SettingsLinkBaseUrlSetting.tsx | 108 ++++++ src/app/features/settings/index.ts | 1 + .../keyboard-shortcuts/KeyboardShortcuts.tsx | 4 +- src/app/features/settings/navigation.ts | 34 ++ .../settings/notifications/AllMessages.tsx | 4 + .../DeregisterPushNotifications.tsx | 1 + .../notifications/KeywordMessages.tsx | 5 + .../settings/notifications/Notifications.tsx | 9 +- .../notifications/SpecialMessages.tsx | 4 + .../notifications/SystemNotification.tsx | 13 + .../settings/settingTileFocusCoverage.test.ts | 31 ++ .../features/settings/settingsLink.test.ts | 72 ++++ src/app/features/settings/settingsLink.ts | 86 +++++ src/app/features/settings/styles.css.ts | 28 +- src/app/features/settings/useSettingsFocus.ts | 20 +- .../settings/useSettingsLinkBaseUrl.ts | 15 + src/app/hooks/useClientConfig.ts | 1 + src/app/hooks/useMentionClickHandler.test.tsx | 63 ++++ src/app/hooks/useMentionClickHandler.ts | 12 +- src/app/pages/App.tsx | 15 +- src/app/pages/client/ClientLayout.tsx | 12 +- src/app/pages/client/ClientNonUIFeatures.tsx | 4 + src/app/pages/client/inbox/Notifications.tsx | 38 +- src/app/pages/reactQueryDevtoolsGate.test.ts | 31 ++ src/app/pages/reactQueryDevtoolsGate.ts | 5 + .../plugins/react-custom-html-parser.test.tsx | 98 +++++ src/app/plugins/react-custom-html-parser.tsx | 149 +++++++- src/app/state/settings.ts | 2 + src/app/styles/CustomHtml.css.ts | 12 + src/app/utils/dom.ts | 41 ++- src/app/utils/settingsSync.test.ts | 1 + src/app/utils/settingsSync.ts | 1 + 90 files changed, 2166 insertions(+), 276 deletions(-) create mode 100644 src/app/components/Modal500.test.tsx create mode 100644 src/app/components/RenderMessageContent.test.tsx create mode 100644 src/app/components/setting-tile/SettingTile.css.ts create mode 100644 src/app/components/setting-tile/SettingTile.test.tsx create mode 100644 src/app/features/settings/Settings.test.tsx create mode 100644 src/app/features/settings/SettingsPermalinkContext.tsx create mode 100644 src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx create mode 100644 src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx create mode 100644 src/app/features/settings/navigation.ts create mode 100644 src/app/features/settings/settingTileFocusCoverage.test.ts create mode 100644 src/app/features/settings/settingsLink.test.ts create mode 100644 src/app/features/settings/settingsLink.ts create mode 100644 src/app/features/settings/useSettingsLinkBaseUrl.ts create mode 100644 src/app/hooks/useMentionClickHandler.test.tsx create mode 100644 src/app/pages/reactQueryDevtoolsGate.test.ts create mode 100644 src/app/pages/reactQueryDevtoolsGate.ts create mode 100644 src/app/plugins/react-custom-html-parser.test.tsx diff --git a/config.json b/config.json index 1bdffb675..f0c3c8b61 100644 --- a/config.json +++ b/config.json @@ -13,6 +13,8 @@ "webPushAppID": "moe.sable.app.sygnal" }, + "settingsLinkBaseUrl": "https://app.sable.moe", + "slidingSync": { "enabled": true }, diff --git a/src/app/components/Modal500.test.tsx b/src/app/components/Modal500.test.tsx new file mode 100644 index 000000000..2a6386ffc --- /dev/null +++ b/src/app/components/Modal500.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Modal500 } from './Modal500'; + +describe('Modal500', () => { + it('does not throw when rendered without tabbable children', () => { + expect(() => + render( + +
Empty modal content
+
+ ) + ).not.toThrow(); + }); +}); diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index 260baa6d8..fc75b8a13 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, useRef } from 'react'; import FocusTrap from 'focus-trap-react'; import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds'; import { stopPropagation } from '$utils/keyboard'; @@ -8,18 +8,21 @@ type Modal500Props = { children: ReactNode; }; export function Modal500({ requestClose, children }: Modal500Props) { + const modalRef = useRef(null); + return ( }> modalRef.current ?? document.body, clickOutsideDeactivates: true, onDeactivate: requestClose, escapeDeactivates: stopPropagation, }} > - + {children} diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx new file mode 100644 index 000000000..ffacb90a3 --- /dev/null +++ b/src/app/components/RenderMessageContent.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { MsgType } from '$types/matrix-sdk'; +import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { RenderMessageContent } from './RenderMessageContent'; + +vi.mock('./url-preview', () => ({ + UrlPreviewHolder: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + UrlPreviewCard: ({ url }: { url: string }) =>
{url}
, + ClientPreview: ({ url }: { url: string }) =>
{url}
, + youtubeUrl: () => false, +})); + +function renderMessage(body: string, settingsLinkBaseUrl = 'https://app.sable.moe') { + return render( + + ({ body }) as never} + urlPreview + clientUrlPreview + htmlReactParserOptions={{}} + linkifyOpts={{}} + /> + + ); +} + +describe('RenderMessageContent', () => { + it('does not render url previews for settings permalinks', () => { + renderMessage('https://app.sable.moe/settings/account/?focus=status'); + + expect(screen.queryByTestId('url-preview-holder')).not.toBeInTheDocument(); + expect(screen.queryByTestId('url-preview-card')).not.toBeInTheDocument(); + expect(screen.queryByTestId('client-preview')).not.toBeInTheDocument(); + }); + + it('still renders url previews for non-settings links', () => { + renderMessage('https://example.com'); + + expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com'); + }); +}); diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 6f3617858..245a7e3fd 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,5 +1,7 @@ import { memo, useMemo, useCallback } from 'react'; import { MsgType } from '$types/matrix-sdk'; +import { parseSettingsPermalink } from '$features/settings/settingsLink'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { testMatrixTo } from '$plugins/matrix-to'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom, CaptionPosition } from '$state/settings'; @@ -80,6 +82,7 @@ function RenderMessageContentInternal({ const [autoplayGifs] = useSetting(settingsAtom, 'autoplayGifs'); const [captionPosition] = useSetting(settingsAtom, 'captionPosition'); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const captionPositionMap = { [CaptionPosition.Above]: 'column-reverse', [CaptionPosition.Below]: 'column', @@ -101,7 +104,9 @@ function RenderMessageContentInternal({ const renderUrlsPreview = useCallback( (urls: string[]) => { - const filteredUrls = urls.filter((url) => !testMatrixTo(url)); + const filteredUrls = urls.filter( + (url) => !testMatrixTo(url) && !parseSettingsPermalink(settingsLinkBaseUrl, url) + ); if (filteredUrls.length === 0) return undefined; const analyzed = filteredUrls.map((url) => ({ @@ -129,7 +134,7 @@ function RenderMessageContentInternal({ ); }, - [ts, clientUrlPreview, urlPreview] + [ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview] ); const renderCaption = () => { diff --git a/src/app/components/event-history/EventHistory.tsx b/src/app/components/event-history/EventHistory.tsx index 955a2846f..f08fd6c0e 100644 --- a/src/app/components/event-history/EventHistory.tsx +++ b/src/app/components/event-history/EventHistory.tsx @@ -39,6 +39,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { MessageEvent } from '$types/matrix/room'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import * as css from './EventHistory.css'; export type EventHistoryProps = { @@ -50,6 +51,7 @@ export const EventHistory = as<'div', EventHistoryProps>( ({ className, room, mEvents, requestClose, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const openProfile = useOpenUserRoomProfile(); const space = useSpaceOptionally(); const nicknames = useAtomValue(nicknamesAtom); @@ -72,11 +74,12 @@ export const EventHistory = as<'div', EventHistoryProps>( const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, mEvents[0].getRoomId(), { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, }), - [linkifyOpts, mEvents, mx, spoilerClickHandler, useAuthentication] + [linkifyOpts, mEvents, mx, settingsLinkBaseUrl, spoilerClickHandler, useAuthentication] ); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 3342b0d52..23844cbb7 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -27,6 +27,7 @@ import { StateEvent, MessageEvent } from '$types/matrix/room'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useTranslation } from 'react-i18next'; import * as customHtmlCss from '$styles/CustomHtml.css'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { MessageBadEncryptedContent, MessageBlockedContent, @@ -120,6 +121,7 @@ export const Reply = as<'div', ReplyProps>( const { color: usernameColor, font: usernameFont } = useSableCosmetics(sender ?? '', room); const nicknames = useAtomValue(nicknamesAtom); const useAuthentication = useMediaAuthentication(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const fallbackBody = isRedacted ? : ; @@ -144,17 +146,20 @@ export const Reply = as<'div', ReplyProps>( const replyLinkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room.roomId, mentionClickHandler, nicknames] + [mx, room.roomId, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); if (format === 'org.matrix.custom.html' && formattedBody) { @@ -166,6 +171,7 @@ export const Reply = as<'div', ReplyProps>( .replaceAll(/<\/?(ul|ol|li|blockquote|h[1-6]|pre|div)[^>]*>/gi, '') .replaceAll(/(?:\r\n|\r|\n)/g, ' '); const parserOpts = getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts: replyLinkifyOpts, useAuthentication, nicknames, diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 9928f15e6..ee423ca2c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -58,11 +58,14 @@ const highlightAnime = keyframes({ backgroundColor: color.Primary.Container, }, }); + +export const messageJumpHighlight = style({ + animation: `${highlightAnime} 2000ms ease-in-out`, + animationIterationCount: 'infinite', +}); + const HighlightVariant = styleVariants({ - true: { - animation: `${highlightAnime} 2000ms ease-in-out`, - animationIterationCount: 'infinite', - }, + true: [messageJumpHighlight], }); const NotifyHighlightVariant = styleVariants({ diff --git a/src/app/components/sequence-card/SequenceCard.tsx b/src/app/components/sequence-card/SequenceCard.tsx index 7738d93fb..810afd3da 100644 --- a/src/app/components/sequence-card/SequenceCard.tsx +++ b/src/app/components/sequence-card/SequenceCard.tsx @@ -29,6 +29,7 @@ export const SequenceCard = as< ContainerColor({ variant }), className )} + data-sequence-card="true" data-first-child={firstChild} data-last-child={lastChild} {...props} diff --git a/src/app/components/setting-tile/SettingTile.css.ts b/src/app/components/setting-tile/SettingTile.css.ts new file mode 100644 index 000000000..34f707900 --- /dev/null +++ b/src/app/components/setting-tile/SettingTile.css.ts @@ -0,0 +1,54 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const settingTileRoot = style({ + minWidth: 0, +}); + +export const settingTileTitleRow = style({ + minWidth: 0, +}); + +const permalinkActionBase = style({ + transition: + 'opacity 150ms ease, transform 150ms ease, color 150ms ease, background-color 150ms ease', +}); + +export const settingTilePermalinkAction = style([ + permalinkActionBase, + { + minWidth: 0, + minHeight: 0, + width: 'auto', + height: 'auto', + padding: 0, + }, +]); + +export const settingTilePermalinkActionDesktopHidden = style([ + permalinkActionBase, + { + opacity: 0, + pointerEvents: 'none', + transform: `translateX(${config.space.S100})`, + selectors: { + [`${settingTileRoot}:hover &`]: { + opacity: 1, + transform: 'translateX(0)', + pointerEvents: 'auto', + }, + [`${settingTileRoot}:focus-within &`]: { + opacity: 1, + transform: 'translateX(0)', + pointerEvents: 'auto', + }, + }, + }, +]); + +export const settingTilePermalinkActionMobileVisible = style([ + permalinkActionBase, + { + opacity: 1, + }, +]); diff --git a/src/app/components/setting-tile/SettingTile.test.tsx b/src/app/components/setting-tile/SettingTile.test.tsx new file mode 100644 index 000000000..a21a59f72 --- /dev/null +++ b/src/app/components/setting-tile/SettingTile.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import { SettingsPermalinkProvider } from '$features/settings/SettingsPermalinkContext'; +import { SettingTile } from './SettingTile'; +import { + settingTilePermalinkActionDesktopHidden, + settingTilePermalinkActionMobileVisible, +} from './SettingTile.css'; + +const writeText = vi.fn(); + +function renderTile(screenSize: ScreenSize, focusId?: string) { + return render( + + + + + + + + ); +} + +beforeEach(() => { + writeText.mockReset(); + vi.stubGlobal('navigator', { clipboard: { writeText } } as unknown as Navigator); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('SettingTile', () => { + it('copies the real settings permalink when a focus id is present', async () => { + writeText.mockResolvedValueOnce(undefined); + + renderTile(ScreenSize.Desktop, 'message-link-preview'); + + fireEvent.click(screen.getByRole('button', { name: /copy settings permalink/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'https://settings.example/settings/appearance/?focus=message-link-preview' + ); + }); + expect(screen.getByRole('button', { name: /copied settings permalink/i })).toBeInTheDocument(); + }); + + it('keeps the copy state unchanged when clipboard write fails', async () => { + writeText.mockRejectedValueOnce(new Error('denied')); + + renderTile(ScreenSize.Desktop, 'message-link-preview'); + + fireEvent.click(screen.getByRole('button', { name: /copy settings permalink/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'https://settings.example/settings/appearance/?focus=message-link-preview' + ); + }); + expect(screen.getByRole('button', { name: /copy settings permalink/i })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /copied settings permalink/i }) + ).not.toBeInTheDocument(); + }); + + it('does not render a copy button without a focus id', () => { + renderTile(ScreenSize.Desktop); + + expect( + screen.queryByRole('button', { name: /copy settings permalink/i }) + ).not.toBeInTheDocument(); + }); + + it('uses the desktop hidden-until-hover class for the permalink action', () => { + renderTile(ScreenSize.Desktop, 'message-link-preview'); + + expect(screen.getByText('Appearance').parentElement).toContainElement( + screen.getByRole('button', { name: /copy settings permalink/i }) + ); + expect(screen.getByRole('button', { name: /copy settings permalink/i })).toHaveClass( + settingTilePermalinkActionDesktopHidden + ); + }); + + it('uses the mobile always-visible class for the permalink action', () => { + renderTile(ScreenSize.Mobile, 'message-link-preview'); + + expect(screen.getByRole('button', { name: /copy settings permalink/i })).toHaveClass( + settingTilePermalinkActionMobileVisible + ); + }); +}); diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index 67548ece4..33b6ce116 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -1,6 +1,19 @@ import { ReactNode } from 'react'; -import { Box, Text } from 'folds'; +import { Box, Icon, IconButton, Icons, Text } from 'folds'; import { BreakWord } from '$styles/Text.css'; +import { buildSettingsPermalink } from '$features/settings/settingsLink'; +import { copyToClipboard } from '$utils/dom'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { useSettingsPermalinkContext } from '$features/settings/SettingsPermalinkContext'; +import { type SettingsSectionId } from '$features/settings/routes'; +import { + settingTilePermalinkAction, + settingTilePermalinkActionDesktopHidden, + settingTilePermalinkActionMobileVisible, + settingTileRoot, + settingTileTitleRow, +} from './SettingTile.css'; type SettingTileProps = { focusId?: string; @@ -11,6 +24,42 @@ type SettingTileProps = { after?: ReactNode; children?: ReactNode; }; + +function SettingTilePermalinkAction({ + baseUrl, + section, + focusId, +}: { + baseUrl: string; + section: SettingsSectionId; + focusId: string; +}) { + const screenSize = useScreenSizeContext(); + const [copied, setCopied] = useTimeoutToggle(); + const copyPath = buildSettingsPermalink(baseUrl, section, focusId); + + return ( + { + if (await copyToClipboard(copyPath)) setCopied(); + }} + size="300" + variant="Surface" + fill="None" + radii="Inherit" + > + + + ); +} + export function SettingTile({ focusId, className, @@ -20,20 +69,48 @@ export function SettingTile({ after, children, }: SettingTileProps) { + const settingsPermalink = useSettingsPermalinkContext(); + const copyAction = + settingsPermalink && focusId ? ( + + ) : null; + const titleAction = title ? copyAction : null; + const trailingCopyAction = title ? null : copyAction; + + const trailing = + after || trailingCopyAction ? ( + + {after} + {trailingCopyAction} + + ) : null; + return ( {before && {before}} {title && ( - - {title} - + + + {title} + + {titleAction} + )} {description && ( @@ -42,7 +119,7 @@ export function SettingTile({ )} {children} - {after && {after}} + {trailing} ); } diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index d9fa444f7..d97a29f54 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -27,6 +27,7 @@ import { bytesToSize, getFileTypeIcon } from '$utils/common'; import { roomUploadAtomFamily, TUploadItem, TUploadMetadata } from '$state/room/roomInputDrafts'; import { useObjectURL } from '$hooks/useObjectURL'; import { useMediaConfig } from '$hooks/useMediaConfig'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import * as css from './UploadCard.css'; import { DescriptionEditor } from './UploadDescriptionEditor'; @@ -383,14 +384,16 @@ export function UploadCardRenderer({ const spoilerClickHandler = useSpoilerClickHandler(); const useAuthentication = useMediaAuthentication(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, }), - [linkifyOpts, mx, roomId, spoilerClickHandler, useAuthentication] + [linkifyOpts, mx, roomId, settingsLinkBaseUrl, spoilerClickHandler, useAuthentication] ); return ( ) => { e.preventDefault(); }, []); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room, mentionClickHandler, nicknames] + [mx, room, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); const spoilerClickHandler = useSpoilerClickHandler(); @@ -337,11 +342,12 @@ export function UserRoomProfile({ userId, initialProfile }: Readonly( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, }), - [mx, room, linkifyOpts, useAuthentication, spoilerClickHandler] + [mx, room, linkifyOpts, settingsLinkBaseUrl, useAuthentication, spoilerClickHandler] ); return ( diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 7e4bcf533..c3450c6ec 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -52,6 +52,7 @@ import { } from '$hooks/useMemberPowerTag'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomCreatorsTag } from '$hooks/useRoomCreatorsTag'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { ResultItem } from './useMessageSearch'; type SearchResultGroupProps = { @@ -90,6 +91,7 @@ export function SearchResultGroup({ const theme = useTheme(); const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags); const nicknames = useAtomValue(nicknamesAtom); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); @@ -97,21 +99,25 @@ export function SearchResultGroup({ const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room, mentionClickHandler, nicknames] + [mx, room, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, highlightRegex, useAuthentication, @@ -128,6 +134,7 @@ export function SearchResultGroup({ spoilerClickHandler, useAuthentication, nicknames, + settingsLinkBaseUrl, ] ); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index dd655deec..e608f202a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -49,6 +49,7 @@ import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; import { useSpaceOptionally } from '$hooks/useSpace'; @@ -177,6 +178,7 @@ export function RoomTimeline({ const mediaAuthentication = useMediaAuthentication(); const spoilerClickHandler = useSpoilerClickHandler(); const mentionClickHandler = useMentionClickHandler(room.roomId); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const openUserRoomProfile = useOpenUserRoomProfile(); const optionalSpace = useSpaceOptionally(); const roomParents = useAtomValue(roomToParentsAtom); @@ -438,22 +440,26 @@ export function RoomTimeline({ const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room.roomId, mentionClickHandler, nicknames] + [mx, room.roomId, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication: mediaAuthentication, handleSpoilerClick: spoilerClickHandler, @@ -470,6 +476,7 @@ export function RoomTimeline({ nicknames, mediaAuthentication, spoilerClickHandler, + settingsLinkBaseUrl, ] ); diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 7c1e830f0..0d9240c38 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -26,6 +26,7 @@ import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { nicknamesAtom } from '$state/nicknames'; import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '$utils/room'; @@ -67,6 +68,7 @@ function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); const nicknames = useAtomValue(nicknamesAtom); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); @@ -77,29 +79,42 @@ function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href: string) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href: string) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room.roomId, nicknames, mentionClickHandler] + [mx, room.roomId, nicknames, mentionClickHandler, settingsLinkBaseUrl] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, useAuthentication, nicknames, }), - [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, nicknames] + [ + mx, + room, + linkifyOpts, + mentionClickHandler, + spoilerClickHandler, + useAuthentication, + nicknames, + settingsLinkBaseUrl, + ] ); const handleJumpClick: MouseEventHandler = useCallback( diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 163d40ff0..988d1ba8d 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -35,6 +35,7 @@ import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; import { minuteDifference } from '$utils/time'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { nicknamesAtom } from '$state/nicknames'; import { MessageLayout, MessageSpacing, settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; @@ -367,6 +368,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const pushProcessor = useMemo(() => new PushProcessor(mx), [mx]); const useAuthentication = useMediaAuthentication(); const mentionClickHandler = useMentionClickHandler(room.roomId); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const spoilerClickHandler = useSpoilerClickHandler(); // Settings @@ -381,29 +383,42 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room, mentionClickHandler, nicknames] + [mx, room, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, nicknames, }), - [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication, nicknames] + [ + mx, + room, + linkifyOpts, + spoilerClickHandler, + mentionClickHandler, + useAuthentication, + nicknames, + settingsLinkBaseUrl, + ] ); // Power levels & permissions diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 22c63ce26..f2e720bd8 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -66,6 +66,7 @@ import { mobileOrTablet } from '$utils/user-agent'; import { useComposingCheck } from '$hooks/useComposingCheck'; import { floatingEditor } from '$styles/overrides/Composer.css'; import { RenderMessageContent } from '$components/RenderMessageContent'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import { HTMLReactParserOptions } from 'html-react-parser'; @@ -348,16 +349,18 @@ export const MessageEditor = as<'div', MessageEditorProps>( }, [saveState, onCancel]); const useAuthentication = useMediaAuthentication(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const linkifyOpts = useMemo(() => ({ ...LINKIFY_OPTS }), []); const spoilerClickHandler = useSpoilerClickHandler(); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, mEvent.getRoomId(), { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, }), - [linkifyOpts, mEvent, mx, spoilerClickHandler, useAuthentication] + [linkifyOpts, mEvent, mx, settingsLinkBaseUrl, spoilerClickHandler, useAuthentication] ); const getContent = (() => mEvent.getContent()) as GetContentCallback; const msgType = mEvent.getContent().msgtype; diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9762f216a..dd34bb487 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -52,6 +52,7 @@ import { import { GetContentCallback, MessageEvent, StateEvent } from '$types/matrix/room'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, @@ -332,26 +333,31 @@ export const RoomPinMenu = forwardRef( }); const mentionClickHandler = useMentionClickHandler(room.roomId); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const spoilerClickHandler = useSpoilerClickHandler(); const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room, mentionClickHandler, nicknames] + [mx, room, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, @@ -366,6 +372,7 @@ export const RoomPinMenu = forwardRef( spoilerClickHandler, useAuthentication, nicknames, + settingsLinkBaseUrl, ] ); diff --git a/src/app/features/settings/Persona/ProfilesPage.tsx b/src/app/features/settings/Persona/ProfilesPage.tsx index 2ed5dfa83..841b0386a 100644 --- a/src/app/features/settings/Persona/ProfilesPage.tsx +++ b/src/app/features/settings/Persona/ProfilesPage.tsx @@ -4,12 +4,13 @@ import { SettingsSectionPage } from '../SettingsSectionPage'; import { PerMessageProfileOverview } from './PerMessageProfileOverview'; type PerMessageProfilePageProps = { + requestBack?: () => void; requestClose: () => void; }; -export function PerMessageProfilePage({ requestClose }: PerMessageProfilePageProps) { +export function PerMessageProfilePage({ requestBack, requestClose }: PerMessageProfilePageProps) { return ( - + ({ + mockMatrixClient: { getUserId: () => '@alice:server' }, + mockProfile: { displayName: 'Alice', avatarUrl: undefined }, +})); + +let settingsLinkBaseUrlOverride: string | undefined; + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMatrixClient, +})); + +vi.mock('$hooks/useUserProfile', () => ({ + useUserProfile: () => mockProfile, +})); + +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: string) => { + if (key === 'settingsLinkBaseUrlOverride') { + return [settingsLinkBaseUrlOverride, vi.fn()] as const; + } + + return [true, vi.fn()] as const; + }, +})); + +vi.mock('$state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('./useSettingsFocus', () => ({ + useSettingsFocus: () => {}, +})); + +vi.mock('./general', () => ({ + General: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./account', () => ({ + Account: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./Persona/ProfilesPage', () => ({ + PerMessageProfilePage: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./cosmetics/Cosmetics', () => ({ + Cosmetics: ({ requestClose }: { requestClose: () => void }) => ( +
+ + +
+ ), +})); + +vi.mock('./notifications', () => ({ + Notifications: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./devices', () => ({ + Devices: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./emojis-stickers', () => ({ + EmojisStickers: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./developer-tools', () => ({ + DeveloperTools: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./experimental/Experimental', () => ({ + Experimental: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./about', () => ({ + About: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +vi.mock('./keyboard-shortcuts', () => ({ + KeyboardShortcuts: ({ requestClose }: { requestClose: () => void }) => ( + + ), +})); + +beforeEach(() => { + writeText.mockReset(); + settingsLinkBaseUrlOverride = undefined; + vi.stubGlobal('location', { origin: 'https://app.example' } as Location); + vi.stubGlobal('navigator', { clipboard: { writeText } } as unknown as Navigator); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Settings', () => { + it('uses the configured settings link base URL for copied settings permalinks', async () => { + writeText.mockResolvedValueOnce(undefined); + + render( + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /copy settings permalink/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'https://config.example/settings/appearance/?focus=message-link-preview' + ); + }); + }); + + it('prefers the client-side override over the configured settings link base URL', async () => { + writeText.mockResolvedValueOnce(undefined); + settingsLinkBaseUrlOverride = 'https://override.example/'; + + render( + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /copy settings permalink/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'https://override.example/settings/appearance/?focus=message-link-preview' + ); + }); + }); +}); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 72ed29257..d52b4d3a9 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -40,7 +40,10 @@ import { KeyboardShortcuts } from './keyboard-shortcuts'; import { Notifications } from './notifications'; import { PerMessageProfilePage } from './Persona/ProfilesPage'; import { settingsSections, type SettingsSectionId } from './routes'; +import { settingsHeader } from './styles.css'; import { useSettingsFocus } from './useSettingsFocus'; +import { SettingsPermalinkProvider } from './SettingsPermalinkContext'; +import { useSettingsLinkBaseUrl } from './useSettingsLinkBaseUrl'; export enum SettingsPages { GeneralPage, @@ -110,7 +113,7 @@ const settingsSectionIdToPage: Record = { const settingsSectionComponents: Record< SettingsSectionId, - (props: { requestClose: () => void }) => JSX.Element + (props: { requestBack?: () => void; requestClose: () => void }) => JSX.Element > = { general: General, account: Account, @@ -128,25 +131,29 @@ const settingsSectionComponents: Record< type ControlledSettingsProps = { activeSection?: SettingsSectionId | null; onSelectSection?: (section: SettingsSectionId) => void; + onBack?: () => void; requestClose: () => void; initialPage?: SettingsPages; }; function SettingsSectionViewport({ section, + requestBack, requestClose, }: { section: SettingsSectionId; + requestBack?: () => void; requestClose: () => void; }) { useSettingsFocus(); const Section = settingsSectionComponents[section]; - return
; + return
; } export function Settings({ activeSection, onSelectSection, + onBack, requestClose, initialPage, }: ControlledSettingsProps) { @@ -160,6 +167,7 @@ export function Settings({ : undefined; const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const screenSize = useScreenSizeContext(); const isControlled = activeSection !== undefined; @@ -220,12 +228,30 @@ export function Settings({ requestClose(); }; + const handleRequestBack = () => { + if (isControlled) { + onBack?.(); + return; + } + + if (screenSize === ScreenSize.Mobile) { + setLegacyActivePage(undefined); + return; + } + + setLegacyActivePage(SettingsPages.GeneralPage); + }; + + const shouldShowSectionBack = + visibleSection !== null && (screenSize === ScreenSize.Mobile || visibleSection !== 'general'); + const sectionRequestBack = shouldShowSectionBack ? handleRequestBack : undefined; + return ( - + - {screenSize === ScreenSize.Mobile && ( + {visibleSection === null && ( + } onClick={() => handleSelectSection(item.id)} > @@ -273,7 +303,7 @@ export function Settings({ fontWeight: visibleSection === item.id ? config.fontWeight.W600 : undefined, }} - size="T300" + size={screenSize === ScreenSize.Mobile ? 'T400' : 'T300'} truncate > {item.name} @@ -322,7 +352,15 @@ export function Settings({ } > {visibleSection && ( - + + + )} ); diff --git a/src/app/features/settings/SettingsPermalinkContext.tsx b/src/app/features/settings/SettingsPermalinkContext.tsx new file mode 100644 index 000000000..043b1e354 --- /dev/null +++ b/src/app/features/settings/SettingsPermalinkContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; +import { type SettingsSectionId } from './routes'; + +type SettingsPermalinkContextValue = { + section: SettingsSectionId; + baseUrl: string; +}; + +const SettingsPermalinkContext = createContext(null); + +export const SettingsPermalinkProvider = SettingsPermalinkContext.Provider; + +export const useSettingsPermalinkContext = (): SettingsPermalinkContextValue | null => + useContext(SettingsPermalinkContext); + +export type { SettingsPermalinkContextValue }; diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 97cd518f4..91de02140 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -10,28 +10,56 @@ import { useNavigationType, } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; +import { Text } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; +import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { ClientLayout } from '$pages/client'; import { ClientRouteOutlet } from '$pages/client/ClientRouteOutlet'; import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; +import * as pageCss from '$components/page/style.css'; +import { messageJumpHighlight } from '$components/message/layout/layout.css'; import { getHomePath, getSettingsPath } from '$pages/pathUtils'; import { SETTINGS_PATH } from '$pages/paths'; import { SettingsRoute } from './SettingsRoute'; import { SettingsShallowRouteRenderer } from './SettingsShallowRouteRenderer'; import { SettingsSectionPage } from './SettingsSectionPage'; import { focusedSettingTile } from './styles.css'; +import * as settingsCss from './styles.css'; import { useOpenSettings } from './useOpenSettings'; import { useSettingsFocus } from './useSettingsFocus'; +type RouterInitialEntry = + | string + | { + pathname: string; + search?: string; + hash?: string; + state?: unknown; + key?: string; + }; + const { mockMatrixClient, mockProfile, mockUseSetting, createSectionMock } = vi.hoisted(() => { const mockSettingsHook = vi.fn(() => [true, vi.fn()] as const); const createMockSection = (title: string) => - function MockSection({ requestClose }: { requestClose: () => void }) { + function MockSection({ + requestBack, + requestClose, + }: { + requestBack?: () => void; + requestClose: () => void; + }) { return (

{title}

+ {requestBack && ( + + )}
); @@ -66,7 +94,28 @@ vi.mock('$components/Modal500', () => ({ })); vi.mock('./general', () => ({ - General: createSectionMock('General section'), + General: ({ + requestBack, + requestClose, + }: { + requestBack?: () => void; + requestClose: () => void; + }) => ( +
+

General section

+ + Message Layout + + {requestBack && ( + + )} + +
+ ), })); vi.mock('./account', () => ({ @@ -114,7 +163,9 @@ function FocusFixture() { return (
- focus target + + focus target +
); } @@ -167,22 +218,24 @@ function OpenSettingsHomePage() { function renderClientShell( screenSize: ScreenSize, - options?: { initialEntries?: string[]; initialIndex?: number } + options?: { initialEntries?: RouterInitialEntry[]; initialIndex?: number } ) { const initialEntries = options?.initialEntries ?? [getHomePath()]; return render( - - - - - }> - } /> - } /> - - - - - + + + + + + }> + } /> + } /> + + + + + + ); } @@ -198,53 +251,92 @@ function SidebarSettingsShortcut() { function renderClientShellWithOpenSettings( screenSize: ScreenSize, - options?: { initialEntries?: string[]; initialIndex?: number } + options?: { initialEntries?: RouterInitialEntry[]; initialIndex?: number } ) { const initialEntries = options?.initialEntries ?? [getHomePath()]; return render( - - - - - - }> - } /> - } /> - - - - - + + + + + + + }> + } /> + } /> + + + + + + + ); +} + +function renderClientLayoutShell( + screenSize: ScreenSize, + options?: { initialEntries?: RouterInitialEntry[]; initialIndex?: number } +) { + const initialEntries = options?.initialEntries ?? [getHomePath()]; + return render( + + + + + + App sidebar
}> + + + } + > + } /> + } /> + + + + + + ); } function renderSettingsRoute( path: string, screenSize: ScreenSize, - options?: { initialEntries?: string[]; initialIndex?: number } + options?: { initialEntries?: RouterInitialEntry[]; initialIndex?: number } ) { const initialEntries = options?.initialEntries ?? [path]; return render( - - - - - } /> - - - + + + + + + } /> + + + + ); } describe('SettingsSectionPage', () => { - it('shows a back affordance on mobile section pages', () => { + it('reuses the message jump highlight class without adding a separate radius override', () => { + expect(focusedSettingTile).toBe(messageJumpHighlight); + }); + + it('shows back on the left and close on the right for mobile section pages', () => { render( - + ); - expect(screen.getByRole('button')).toBeInTheDocument(); + expect( + screen.getAllByRole('button').map((button) => button.getAttribute('aria-label')) + ).toEqual(['Back', 'Close']); }); it('supports custom title semantics and close label', () => { @@ -254,14 +346,51 @@ describe('SettingsSectionPage', () => { title="Keyboard Shortcuts" titleAs="h1" actionLabel="Close keyboard shortcuts" + requestBack={vi.fn()} requestClose={vi.fn()} /> ); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Keyboard Shortcuts'); + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Close keyboard shortcuts' })).toBeInTheDocument(); }); + + it('uses the default outlined page header treatment', () => { + render( + + + + ); + + expect(screen.getByText('Devices').closest('header')).toHaveClass(pageCss.PageHeader({})); + }); + + it('matches the main settings header title size', () => { + const rootRender = renderSettingsRoute('/settings', ScreenSize.Mobile); + const mainHeaderClassName = screen.getByText('Settings').className; + + rootRender.unmount(); + + render( + + + + ); + + expect(screen.getByText('Devices').className).toBe(mainHeaderClassName); + }); + + it('uses settings header spacing that matches the main settings shell', () => { + render( + + + + ); + + expect(screen.getByText('Devices').closest('header')).toHaveClass(settingsCss.settingsHeader); + }); }); describe('SettingsRoute', () => { @@ -273,6 +402,27 @@ describe('SettingsRoute', () => { expect(screen.queryByRole('heading', { name: 'General section' })).not.toBeInTheDocument(); }); + it('uses the default outlined nav header treatment for the settings menu', () => { + renderSettingsRoute('/settings', ScreenSize.Mobile); + + expect(screen.getByText('Settings').closest('header')).toHaveClass(pageCss.PageNavHeader({})); + }); + + it('uses larger nav labels on mobile settings', () => { + const referenceRender = render( + + Reference + + ); + const mobileClassName = screen.getByText('Reference').className; + + referenceRender.unmount(); + + renderSettingsRoute('/settings', ScreenSize.Mobile); + + expect(screen.getByText('Notifications').className).toBe(mobileClassName); + }); + it('redirects desktop /settings to /settings/general', async () => { renderSettingsRoute('/settings', ScreenSize.Desktop); @@ -291,19 +441,80 @@ describe('SettingsRoute', () => { expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('general')) ); - await user.click(screen.getByRole('button', { name: 'Back' })); + await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()) ); }); + it('closes to the stored background route instead of stepping through prior settings entries', async () => { + const user = userEvent.setup(); + const backgroundLocation = { + pathname: getHomePath(), + search: '', + hash: '', + state: null, + key: 'home', + }; + + renderSettingsRoute(getSettingsPath('devices'), ScreenSize.Desktop, { + initialEntries: [ + getHomePath(), + { + pathname: getSettingsPath('notifications'), + state: { backgroundLocation }, + key: 'settings-notifications', + }, + { + pathname: getSettingsPath('devices'), + state: { backgroundLocation }, + key: 'settings-devices', + }, + ], + initialIndex: 2, + }); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()) + ); + expect(screen.getByTestId('location-probe')).not.toHaveTextContent( + getSettingsPath('notifications') + ); + }); + it('renders the requested section at /settings/devices', () => { renderSettingsRoute('/settings/devices', ScreenSize.Mobile); expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); }); + it('focuses and highlights a real general setting tile from the URL', async () => { + vi.useFakeTimers(); + + try { + renderSettingsRoute('/settings/general?focus=message-layout', ScreenSize.Mobile); + + const target = document.querySelector('[data-settings-focus="message-layout"]'); + const highlightTarget = target?.parentElement; + + expect(target).not.toHaveClass(focusedSettingTile); + expect(highlightTarget).toHaveClass(focusedSettingTile); + expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-layout'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(highlightTarget).not.toHaveClass(focusedSettingTile); + expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/general'); + } finally { + vi.useRealTimers(); + } + }); + it('redirects invalid sections back to /settings', async () => { renderSettingsRoute('/settings/not-a-real-section', ScreenSize.Mobile); @@ -326,6 +537,18 @@ describe('SettingsRoute', () => { expect(screen.getByText('Settings')).toBeInTheDocument(); }); + it('falls back to /home when a direct section entry is closed', async () => { + const user = userEvent.setup(); + + renderSettingsRoute('/settings/devices', ScreenSize.Mobile); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getHomePath()) + ); + }); + it('falls back to /home when the root settings page is closed from a direct entry', async () => { const user = userEvent.setup(); @@ -446,7 +669,7 @@ describe('Settings shallow route shell', () => { expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); }); - it('closes a desktop shallow settings flow in one step after switching sections', async () => { + it('returns to general settings when desktop section back is clicked', async () => { const user = userEvent.setup(); renderClientShell(ScreenSize.Desktop); @@ -455,6 +678,20 @@ describe('Settings shallow route shell', () => { await user.click(screen.getByRole('button', { name: 'Devices' })); await user.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Devices section' })).not.toBeInTheDocument(); + }); + + it('closes a desktop shallow settings flow in one step after switching sections', async () => { + const user = userEvent.setup(); + + renderClientShell(ScreenSize.Desktop); + + await user.click(screen.getByRole('button', { name: 'Open settings' })); + await user.click(screen.getByRole('button', { name: 'Devices' })); + await user.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Devices section' })).not.toBeInTheDocument(); expect( @@ -471,6 +708,15 @@ describe('Settings shallow route shell', () => { expect(screen.queryByRole('heading', { name: 'Home route' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); }); + + it('hides the client sidebar when desktop settings renders as a full page', () => { + renderClientLayoutShell(ScreenSize.Desktop, { + initialEntries: [getSettingsPath('devices')], + }); + + expect(screen.queryByText('App sidebar')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + }); }); describe('useSettingsFocus', () => { @@ -488,20 +734,24 @@ describe('useSettingsFocus', () => { ); const target = document.querySelector('[data-settings-focus="message-link-preview"]'); - expect(target).toHaveClass(focusedSettingTile); + const highlightTarget = target?.parentElement; + expect(target).not.toHaveClass(focusedSettingTile); + expect(highlightTarget).toHaveClass(focusedSettingTile); + expect(highlightTarget).toHaveClass(messageJumpHighlight); expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-link-preview'); await act(async () => { await vi.advanceTimersByTimeAsync(2999); }); expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-link-preview'); - expect(target).toHaveClass(focusedSettingTile); + expect(highlightTarget).toHaveClass(focusedSettingTile); + expect(highlightTarget).toHaveClass(messageJumpHighlight); await act(async () => { await vi.advanceTimersByTimeAsync(1); }); expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/appearance'); - expect(target).not.toHaveClass(focusedSettingTile); + expect(highlightTarget).not.toHaveClass(focusedSettingTile); } finally { vi.useRealTimers(); } diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index 81d266c97..c1fbce5de 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -1,17 +1,13 @@ import { useEffect } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { getHomePath, getSettingsPath } from '$pages/pathUtils'; +import { getSettingsPath } from '$pages/pathUtils'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { getSettingsCloseTarget, type SettingsRouteState } from './navigation'; import { Settings } from './Settings'; import { isSettingsSectionId, type SettingsSectionId } from './routes'; -type SettingsRouteState = { - backgroundLocation?: unknown; - redirectedFromDesktopRoot?: boolean; -}; - function resolveSettingsSection( section: string | undefined, screenSize: ScreenSize, @@ -41,6 +37,13 @@ export function SettingsRoute() { const routeState = location.state as SettingsRouteState | null; const shallowBackgroundState = screenSize !== ScreenSize.Mobile && Boolean(routeState?.backgroundLocation); + const browserHistoryIndex = + typeof window !== 'undefined' && typeof window.history.state?.idx === 'number' + ? window.history.state.idx + : null; + const hasPreviousEntry = + (typeof browserHistoryIndex === 'number' && browserHistoryIndex > 0) || + location.key !== 'default'; const activeSection = resolveSettingsSection(section, screenSize, showPersona); const shouldRedirectToGeneral = section === undefined && screenSize !== ScreenSize.Mobile; @@ -62,23 +65,38 @@ export function SettingsRoute() { if (shouldRedirectToGeneral || shouldRedirectToIndex) return null; - const requestClose = () => { - if (section !== undefined && routeState?.redirectedFromDesktopRoot) { - navigate(getHomePath(), { replace: true }); - return; - } + const requestBack = () => { + if (section === undefined) return; + + if (screenSize === ScreenSize.Mobile) { + if (hasPreviousEntry) { + navigate(-1); + return; + } - if (section !== undefined && location.key === 'default') { - navigate(getSettingsPath(), { replace: true, state: routeState }); + navigate(getSettingsPath(), { + replace: true, + state: routeState?.backgroundLocation ? routeState : undefined, + }); return; } - if (section === undefined && location.key === 'default') { - navigate(getHomePath(), { replace: true }); - return; + let desktopBackState: SettingsRouteState | undefined; + if (routeState?.backgroundLocation) { + desktopBackState = routeState; + } else if (routeState?.redirectedFromDesktopRoot) { + desktopBackState = { redirectedFromDesktopRoot: true }; } - navigate(-1); + navigate(getSettingsPath('general'), { + replace: true, + state: desktopBackState, + }); + }; + + const requestClose = () => { + const closeTarget = getSettingsCloseTarget(routeState); + navigate(closeTarget.to, { replace: true, state: closeTarget.state }); }; const handleSelectSection = (nextSection: SettingsSectionId) => { @@ -93,6 +111,7 @@ export function SettingsRoute() { return ( diff --git a/src/app/features/settings/SettingsSectionPage.tsx b/src/app/features/settings/SettingsSectionPage.tsx index a7f6ff191..277e6c18a 100644 --- a/src/app/features/settings/SettingsSectionPage.tsx +++ b/src/app/features/settings/SettingsSectionPage.tsx @@ -1,39 +1,46 @@ import { ReactNode } from 'react'; import { Box, Icon, IconButton, Icons, Text } from 'folds'; import { Page, PageHeader } from '$components/page'; -import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { settingsHeader } from './styles.css'; type SettingsSectionPageProps = { title: ReactNode; + requestBack?: () => void; requestClose: () => void; titleAs?: 'h1' | 'h2' | 'h3' | 'span' | 'div'; + backLabel?: string; actionLabel?: string; children?: ReactNode; }; export function SettingsSectionPage({ title, + requestBack, requestClose, titleAs, + backLabel, actionLabel, children, }: SettingsSectionPageProps) { - const screenSize = useScreenSizeContext(); - const isMobile = screenSize === ScreenSize.Mobile; - const closeLabel = isMobile ? 'Back' : (actionLabel ?? 'Close'); + const closeLabel = actionLabel ?? 'Close'; return ( - + - + {requestBack && ( + + + + )} + {title} - + diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx index 92256fa82..e44d8746c 100644 --- a/src/app/features/settings/SettingsShallowRouteRenderer.tsx +++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx @@ -3,17 +3,24 @@ import { useScreenSizeContext } from '$hooks/useScreenSize'; import { Modal500 } from '$components/Modal500'; import { isShallowSettingsRoute } from '$pages/client/ClientRouteOutlet'; import { SETTINGS_PATH } from '$pages/paths'; +import { getSettingsCloseTarget, type SettingsRouteState } from './navigation'; import { SettingsRoute } from './SettingsRoute'; export function SettingsShallowRouteRenderer() { const navigate = useNavigate(); const location = useLocation(); const screenSize = useScreenSizeContext(); + const routeState = location.state as SettingsRouteState | null; if (!isShallowSettingsRoute(location.pathname, location.state, screenSize)) return null; + const handleRequestClose = () => { + const closeTarget = getSettingsCloseTarget(routeState); + navigate(closeTarget.to, { replace: true, state: closeTarget.state }); + }; + return ( - navigate(-1)}> + } /> diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 7abb66efb..fdd3d3834 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -51,7 +51,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + {mx.baseUrl} @@ -77,6 +82,7 @@ export function HomeserverInfo() { > {federationUrl} @@ -104,7 +110,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + )} {version.server?.version && ( @@ -114,7 +124,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + )} {version.server?.compiler && ( @@ -124,7 +138,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + )} @@ -143,16 +161,17 @@ export function HomeserverInfo() { } type AboutProps = { + requestBack?: () => void; requestClose: () => void; }; -export function About({ requestClose }: Readonly) { +export function About({ requestBack, requestClose }: Readonly) { const mx = useMatrixClient(); const devLabel = IS_RELEASE_TAG ? '' : '-dev'; const buildLabel = BUILD_HASH ? ` (${BUILD_HASH})` : ''; const openBugReport = useOpenBugReportModal(); return ( - + @@ -214,6 +233,7 @@ export function About({ requestClose }: Readonly) { > ) { > void; requestClose: () => void; }; -export function Account({ requestClose }: AccountProps) { +export function Account({ requestBack, requestClose }: AccountProps) { return ( - + diff --git a/src/app/features/settings/account/AnimalCosmetics.tsx b/src/app/features/settings/account/AnimalCosmetics.tsx index ada07ef99..30fe96a92 100644 --- a/src/app/features/settings/account/AnimalCosmetics.tsx +++ b/src/app/features/settings/account/AnimalCosmetics.tsx @@ -40,6 +40,7 @@ export function AnimalCosmetics({ profile, userId }: Readonly } /> @@ -47,6 +48,7 @@ export function AnimalCosmetics({ profile, userId }: Readonly - + {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( - + {emailIds?.map((email) => ( diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx index 0b3b3470f..a898e799e 100644 --- a/src/app/features/settings/account/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -144,6 +144,7 @@ export function IgnoredUserList() { > diff --git a/src/app/features/settings/account/MatrixId.tsx b/src/app/features/settings/account/MatrixId.tsx index 640437527..8e72acf8e 100644 --- a/src/app/features/settings/account/MatrixId.tsx +++ b/src/app/features/settings/account/MatrixId.tsx @@ -20,6 +20,7 @@ export function MatrixId() { > copyToClipboard(userId)}> Copy diff --git a/src/app/features/settings/account/NameColorEditor.tsx b/src/app/features/settings/account/NameColorEditor.tsx index 73e417d21..8185dc2c0 100644 --- a/src/app/features/settings/account/NameColorEditor.tsx +++ b/src/app/features/settings/account/NameColorEditor.tsx @@ -57,7 +57,7 @@ export function NameColorEditor({ return ( - + ) { return ( ) { const previewUrl = isRemoving ? undefined : imageFileURL || stagedUrl || bannerUrl; return ( - + ) { const hasChanges = displayName !== defaultDisplayName; return ( - + ) { > + diff --git a/src/app/features/settings/account/TimezoneEditor.tsx b/src/app/features/settings/account/TimezoneEditor.tsx index 7d28399a5..2cbade5ba 100644 --- a/src/app/features/settings/account/TimezoneEditor.tsx +++ b/src/app/features/settings/account/TimezoneEditor.tsx @@ -47,6 +47,7 @@ export function TimezoneEditor({ current, onSave }: TimezoneEditorProps) { return ( diff --git a/src/app/features/settings/cosmetics/Cosmetics.tsx b/src/app/features/settings/cosmetics/Cosmetics.tsx index e000075fd..3d50d886e 100644 --- a/src/app/features/settings/cosmetics/Cosmetics.tsx +++ b/src/app/features/settings/cosmetics/Cosmetics.tsx @@ -109,6 +109,7 @@ function JumboEmoji() { } /> @@ -132,6 +133,7 @@ function Privacy() { } /> @@ -140,6 +142,7 @@ function Privacy() { @@ -150,6 +153,7 @@ function Privacy() { @@ -181,6 +185,7 @@ function IdentityCosmetics() { } /> @@ -201,6 +207,7 @@ function IdentityCosmetics() { } /> @@ -208,6 +215,7 @@ function IdentityCosmetics() { @@ -217,6 +225,7 @@ function IdentityCosmetics() { @@ -226,6 +235,7 @@ function IdentityCosmetics() { } /> @@ -233,6 +243,7 @@ function IdentityCosmetics() { } /> @@ -242,12 +253,13 @@ function IdentityCosmetics() { } type CosmeticsProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Cosmetics({ requestClose }: CosmeticsProps) { +export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) { return ( - + diff --git a/src/app/features/settings/cosmetics/LanguageSpecificPronouns.tsx b/src/app/features/settings/cosmetics/LanguageSpecificPronouns.tsx index 32c57e59a..07d368925 100644 --- a/src/app/features/settings/cosmetics/LanguageSpecificPronouns.tsx +++ b/src/app/features/settings/cosmetics/LanguageSpecificPronouns.tsx @@ -78,6 +78,7 @@ export function LanguageSpecificPronouns() { > } /> @@ -272,6 +275,7 @@ function ThemeSettings() { } /> @@ -280,6 +284,7 @@ function ThemeSettings() { } /> @@ -312,6 +318,7 @@ function ThemeSettings() { } /> @@ -319,6 +326,7 @@ function ThemeSettings() { } /> @@ -326,6 +334,7 @@ function ThemeSettings() { @@ -335,6 +344,7 @@ function ThemeSettings() { } /> @@ -446,6 +456,7 @@ export function Appearance() { } /> @@ -454,18 +465,20 @@ export function Appearance() { } /> - } /> + } /> } /> diff --git a/src/app/features/settings/developer-tools/AccountData.tsx b/src/app/features/settings/developer-tools/AccountData.tsx index 6cd68e9bc..bc8737e9f 100644 --- a/src/app/features/settings/developer-tools/AccountData.tsx +++ b/src/app/features/settings/developer-tools/AccountData.tsx @@ -38,6 +38,7 @@ export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataPro > void; requestClose: () => void; }; -export function DeveloperTools({ requestClose }: DeveloperToolsProps) { +export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProps) { const mx = useMatrixClient(); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [expand, setExpend] = useState(false); @@ -49,7 +50,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { } return ( - + @@ -64,6 +69,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { > @@ -113,6 +117,7 @@ export function SentrySettings() { {ALL_CATEGORIES.map((cat) => ( diff --git a/src/app/features/settings/devices/DeviceTile.tsx b/src/app/features/settings/devices/DeviceTile.tsx index b77be8ac5..a63d7b727 100644 --- a/src/app/features/settings/devices/DeviceTile.tsx +++ b/src/app/features/settings/devices/DeviceTile.tsx @@ -28,6 +28,7 @@ import { stopPropagation } from '$utils/keyboard'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; export function DeviceTilePlaceholder() { return ( @@ -285,6 +286,7 @@ export function DeviceTile({ return ( <> void; requestClose: () => void; }; -export function Devices({ requestClose }: DevicesProps) { +export function Devices({ requestBack, requestClose }: DevicesProps) { const mx = useMatrixClient(); const crypto = mx.getCrypto(); const crossSigningActive = useCrossSigningActive(); @@ -62,7 +63,7 @@ export function Devices({ requestClose }: DevicesProps) { ); return ( - + @@ -77,6 +78,7 @@ export function Devices({ requestClose }: DevicesProps) { > diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index eaefc7ff5..09d350f8c 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -73,7 +73,7 @@ function ExportKeys() { }; return ( - + @@ -142,6 +142,7 @@ function ExportKeysTile() { <> @@ -219,7 +220,7 @@ function ImportKeys({ file, onDone }: ImportKeysProps) { }; return ( - + @@ -271,6 +272,7 @@ function ImportKeysTile() { <> diff --git a/src/app/features/settings/devices/OtherDevices.tsx b/src/app/features/settings/devices/OtherDevices.tsx index e044201d1..145be32d3 100644 --- a/src/app/features/settings/devices/OtherDevices.tsx +++ b/src/app/features/settings/devices/OtherDevices.tsx @@ -114,6 +114,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O > void; requestClose: () => void; }; -export function EmojisStickers({ requestClose }: EmojisStickersProps) { +export function EmojisStickers({ requestBack, requestClose }: EmojisStickersProps) { const [imagePack, setImagePack] = useState(); const handleImagePackViewClose = () => { @@ -22,7 +23,11 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { } return ( - + diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index 1c66d4c6f..224ed4934 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -30,6 +30,7 @@ import { SettingTile } from '$components/setting-tile'; import { mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; import { EmoteRoomsContent, ImagePack, @@ -185,6 +186,7 @@ function GlobalPackSelector({ > {pack.meta.attribution}} before={ @@ -358,6 +360,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { gap="400" > {pack.meta.name ?? 'Unknown'} @@ -429,6 +432,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { > diff --git a/src/app/features/settings/emojis-stickers/UserPack.tsx b/src/app/features/settings/emojis-stickers/UserPack.tsx index 03c80da2f..5d0c0c779 100644 --- a/src/app/features/settings/emojis-stickers/UserPack.tsx +++ b/src/app/features/settings/emojis-stickers/UserPack.tsx @@ -39,6 +39,7 @@ export function UserPack({ onViewPack }: UserPackProps) { > diff --git a/src/app/features/settings/experimental/BandwithSavingEmojis.tsx b/src/app/features/settings/experimental/BandwithSavingEmojis.tsx index 7e660fba0..6240f2369 100644 --- a/src/app/features/settings/experimental/BandwithSavingEmojis.tsx +++ b/src/app/features/settings/experimental/BandwithSavingEmojis.tsx @@ -22,6 +22,7 @@ export function BandwidthSavingEmojis() { > diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 134563446..330412185 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -23,6 +23,7 @@ function PersonaToggle() { @@ -34,11 +35,12 @@ function PersonaToggle() { } type ExperimentalProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Experimental({ requestClose }: Readonly) { +export function Experimental({ requestBack, requestClose }: Readonly) { return ( - + diff --git a/src/app/features/settings/experimental/MSC4268HistoryShare.tsx b/src/app/features/settings/experimental/MSC4268HistoryShare.tsx index d93a41031..27c868d19 100644 --- a/src/app/features/settings/experimental/MSC4268HistoryShare.tsx +++ b/src/app/features/settings/experimental/MSC4268HistoryShare.tsx @@ -22,6 +22,7 @@ export function MSC4268HistoryShare() { > ) const hasChanges = dateFormatCustom !== value; return ( - + } /> @@ -407,6 +409,7 @@ function DateAndTime() { } /> @@ -437,6 +440,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { > ) { } /> } /> @@ -464,6 +470,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> @@ -471,6 +478,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> @@ -478,6 +486,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { @@ -771,6 +780,7 @@ function Gestures({ isMobile }: Readonly<{ isMobile: boolean }>) { ) { } /> @@ -848,6 +859,7 @@ function Calls() { Messages - } /> + } + /> - } /> + } + /> - } /> + } + /> } /> @@ -916,6 +941,7 @@ function Messages() { } /> @@ -924,6 +950,7 @@ function Messages() { } /> } /> + + + @@ -1160,6 +1201,7 @@ export function Sync() { } type GeneralProps = { + requestBack?: () => void; requestClose: () => void; }; @@ -1201,11 +1243,16 @@ function SettingsSyncSection() { > } /> {syncEnabled && ( - + )} @@ -1294,6 +1341,7 @@ function DiagnosticsAndPrivacy() { > ) { +export function General({ requestBack, requestClose }: Readonly) { return ( - + diff --git a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx new file mode 100644 index 000000000..f79b1e0c0 --- /dev/null +++ b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { SettingsLinkBaseUrlSetting } from './SettingsLinkBaseUrlSetting'; + +let settingsLinkBaseUrlOverride: string | undefined; +const setSettingsLinkBaseUrlOverride = vi.fn(); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: () => [settingsLinkBaseUrlOverride, setSettingsLinkBaseUrlOverride] as const, +})); + +vi.mock('$state/settings', () => ({ + settingsAtom: {}, +})); + +function renderSetting(settingsLinkBaseUrl = 'https://app.sable.moe') { + return render( + + + + ); +} + +describe('SettingsLinkBaseUrlSetting', () => { + beforeEach(() => { + settingsLinkBaseUrlOverride = undefined; + setSettingsLinkBaseUrlOverride.mockReset(); + }); + + it('shows the configured default in the input and no separate reset button', () => { + renderSetting('https://config.example'); + + expect(screen.getByRole('textbox', { name: 'Settings link base URL' })).toHaveValue( + 'https://config.example' + ); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument(); + }); + + it('uses an inline reset control to restore the configured default URL', () => { + renderSetting('https://config.example'); + + fireEvent.change(screen.getByRole('textbox', { name: 'Settings link base URL' }), { + target: { value: 'https://override.example' }, + }); + + expect( + screen.getByRole('button', { name: 'Reset settings link base URL' }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset settings link base URL' })); + + expect(screen.getByRole('textbox', { name: 'Settings link base URL' })).toHaveValue( + 'https://config.example' + ); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('clears the override when saving the configured default URL', () => { + settingsLinkBaseUrlOverride = 'https://override.example'; + renderSetting('https://config.example'); + + fireEvent.change(screen.getByRole('textbox', { name: 'Settings link base URL' }), { + target: { value: 'https://config.example' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(setSettingsLinkBaseUrlOverride).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx new file mode 100644 index 000000000..22bb6655b --- /dev/null +++ b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx @@ -0,0 +1,108 @@ +import { ChangeEventHandler, FormEventHandler, useEffect, useMemo, useState } from 'react'; +import { Box, Button, config, Icon, IconButton, Icons, Input, Text } from 'folds'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { getConfiguredSettingsLinkBaseUrl, normalizeSettingsLinkBaseUrl } from '../settingsLink'; + +export function SettingsLinkBaseUrlSetting() { + const clientConfig = useClientConfig(); + const [settingsLinkBaseUrlOverride, setSettingsLinkBaseUrlOverride] = useSetting( + settingsAtom, + 'settingsLinkBaseUrlOverride' + ); + const configuredBaseUrl = useMemo( + () => getConfiguredSettingsLinkBaseUrl(clientConfig), + [clientConfig] + ); + const currentValue = + normalizeSettingsLinkBaseUrl(settingsLinkBaseUrlOverride) ?? configuredBaseUrl; + const [inputValue, setInputValue] = useState(currentValue); + + useEffect(() => { + setInputValue(currentValue); + }, [currentValue]); + + const trimmedValue = inputValue.trim(); + const normalizedInputValue = normalizeSettingsLinkBaseUrl(trimmedValue); + const nextOverrideValue = + normalizedInputValue && normalizedInputValue !== configuredBaseUrl + ? normalizedInputValue + : undefined; + const hasChanges = normalizedInputValue !== currentValue; + const isValid = Boolean(normalizedInputValue); + + const handleChange: ChangeEventHandler = (evt) => { + setInputValue(evt.currentTarget.value); + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (!isValid) return; + + setSettingsLinkBaseUrlOverride(nextOverrideValue); + setInputValue(normalizedInputValue ?? configuredBaseUrl); + }; + + const handleReset = () => { + setInputValue(configuredBaseUrl); + }; + + return ( + + + + + + +
+ ) + } + /> +
+ + + + {!isValid && ( + + Enter a full `http://` or `https://` URL. + + )} + + ); +} diff --git a/src/app/features/settings/index.ts b/src/app/features/settings/index.ts index 5b1e43423..c19de644a 100644 --- a/src/app/features/settings/index.ts +++ b/src/app/features/settings/index.ts @@ -1,4 +1,5 @@ export * from './Settings'; export * from './SettingsRoute'; +export * from './settingsLink'; export * from './routes'; export * from './useOpenSettings'; diff --git a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx index 2900f78c7..54e0f0d76 100644 --- a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -107,14 +107,16 @@ function ShortcutRow({ keys, description }: ShortcutEntry) { } type KeyboardShortcutsProps = { + requestBack?: () => void; requestClose: () => void; }; -export function KeyboardShortcuts({ requestClose }: KeyboardShortcutsProps) { +export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcutsProps) { return ( diff --git a/src/app/features/settings/navigation.ts b/src/app/features/settings/navigation.ts new file mode 100644 index 000000000..da78c5978 --- /dev/null +++ b/src/app/features/settings/navigation.ts @@ -0,0 +1,34 @@ +import type { To } from 'react-router-dom'; +import { getHomePath } from '$pages/pathUtils'; + +export type SettingsStoredLocation = { + pathname: string; + search: string; + hash: string; + state?: unknown; + key?: string; +}; + +export type SettingsRouteState = { + backgroundLocation?: SettingsStoredLocation; + redirectedFromDesktopRoot?: boolean; +}; + +export function getSettingsCloseTarget(routeState: SettingsRouteState | null | undefined): { + to: To; + state?: unknown; +} { + const backgroundLocation = routeState?.backgroundLocation; + if (!backgroundLocation) { + return { to: getHomePath() }; + } + + return { + to: { + pathname: backgroundLocation.pathname, + search: backgroundLocation.search, + hash: backgroundLocation.hash, + }, + state: backgroundLocation.state, + }; +} diff --git a/src/app/features/settings/notifications/AllMessages.tsx b/src/app/features/settings/notifications/AllMessages.tsx index 5ee60a178..f6736910b 100644 --- a/src/app/features/settings/notifications/AllMessages.tsx +++ b/src/app/features/settings/notifications/AllMessages.tsx @@ -109,6 +109,7 @@ export function AllMessagesNotifications() { > } /> @@ -121,6 +122,7 @@ export function AllMessagesNotifications() { > } /> @@ -151,6 +154,7 @@ export function AllMessagesNotifications() { > This will remove push notifications from all your sessions/devices. diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index ff235271f..941cf95ef 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -6,6 +6,7 @@ import { AccountDataEvent } from '$types/matrix/accountData'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; import { getNotificationModeActions, NotificationMode, @@ -183,6 +184,7 @@ export function KeywordMessagesNotifications() { > @@ -198,6 +200,9 @@ export function KeywordMessagesNotifications() { > } after={} /> diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 729859a75..b161a357b 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -7,11 +7,16 @@ import { SpecialMessagesNotifications } from './SpecialMessages'; import { KeywordMessagesNotifications } from './KeywordMessages'; type NotificationsProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Notifications({ requestClose }: NotificationsProps) { +export function Notifications({ requestBack, requestClose }: NotificationsProps) { return ( - + diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx index ac8d736f3..866c53826 100644 --- a/src/app/features/settings/notifications/SpecialMessages.tsx +++ b/src/app/features/settings/notifications/SpecialMessages.tsx @@ -146,6 +146,7 @@ export function SpecialMessagesNotifications() { > {result && !result.email && ( @@ -140,6 +141,7 @@ function WebPushNotificationSetting() { return ( @@ -237,6 +239,7 @@ export function SystemNotification() { > } /> @@ -260,6 +263,7 @@ export function SystemNotification() { > } /> @@ -273,6 +277,7 @@ export function SystemNotification() { > } /> @@ -285,6 +290,7 @@ export function SystemNotification() { > } /> @@ -297,6 +303,7 @@ export function SystemNotification() { > } /> @@ -351,6 +359,7 @@ export function SystemNotification() { > @@ -383,6 +393,7 @@ export function SystemNotification() { > @@ -397,6 +408,7 @@ export function SystemNotification() { > } /> @@ -409,6 +421,7 @@ export function SystemNotification() { > diff --git a/src/app/features/settings/settingTileFocusCoverage.test.ts b/src/app/features/settings/settingTileFocusCoverage.test.ts new file mode 100644 index 000000000..76b02f79a --- /dev/null +++ b/src/app/features/settings/settingTileFocusCoverage.test.ts @@ -0,0 +1,31 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const getSettingsFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const filePath = join(dir, entry.name); + + if (entry.isDirectory()) { + return getSettingsFiles(filePath); + } + + if (!entry.isFile() || !filePath.endsWith('.tsx') || filePath.endsWith('.test.tsx')) { + return []; + } + + return [filePath]; + }); + +describe('settings tile focus coverage', () => { + it('requires every settings SettingTile to declare a focusId', () => { + const offenders = getSettingsFiles('src/app/features/settings').flatMap((file) => { + const source = readFileSync(file, 'utf8'); + const matches = [...source.matchAll(/]*\bfocusId=)/g)]; + + return matches.map(() => file); + }); + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/features/settings/settingsLink.test.ts b/src/app/features/settings/settingsLink.test.ts new file mode 100644 index 000000000..a9dfdb100 --- /dev/null +++ b/src/app/features/settings/settingsLink.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SETTINGS_LINK_BASE_URL, + buildSettingsPermalink, + getEffectiveSettingsLinkBaseUrl, + parseSettingsPermalink, + toSettingsFocusIdPart, +} from './settingsLink'; + +describe('settingsLink', () => { + it('builds settings permalinks for plain and hash-router base urls', () => { + expect( + buildSettingsPermalink('https://app.example', 'appearance', 'message-link-preview') + ).toBe('https://app.example/settings/appearance/?focus=message-link-preview'); + expect( + buildSettingsPermalink('https://app.example/#/app', 'appearance', 'message-link-preview') + ).toBe('https://app.example/#/app/settings/appearance/?focus=message-link-preview'); + }); + + it('resolves the settings link base URL from built-in default, config, and override', () => { + expect(getEffectiveSettingsLinkBaseUrl({}, undefined)).toBe(DEFAULT_SETTINGS_LINK_BASE_URL); + expect(getEffectiveSettingsLinkBaseUrl({}, true as never)).toBe(DEFAULT_SETTINGS_LINK_BASE_URL); + expect( + getEffectiveSettingsLinkBaseUrl({ settingsLinkBaseUrl: 'https://config.example/' }) + ).toBe('https://config.example'); + expect( + getEffectiveSettingsLinkBaseUrl( + { settingsLinkBaseUrl: 'https://config.example' }, + 'https://override.example/' + ) + ).toBe('https://override.example'); + }); + + it('parses settings permalinks from the same app origin only', () => { + expect( + parseSettingsPermalink( + 'https://app.example', + 'https://app.example/settings/appearance/?focus=message-link-preview' + ) + ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); + + expect( + parseSettingsPermalink('https://app.example', 'https://other.example/settings/appearance/') + ).toBeUndefined(); + expect( + parseSettingsPermalink('https://app.example', 'https://app.example/home/') + ).toBeUndefined(); + }); + + it('rejects a same-origin hash permalink that does not match the configured app base', () => { + expect( + parseSettingsPermalink( + 'https://app.example/#/app', + 'https://app.example/#/wrong/settings/appearance/?focus=message-link-preview' + ) + ).toBeUndefined(); + }); + + it('rejects a same-origin hash permalink that only shares the configured base as a prefix', () => { + expect( + parseSettingsPermalink( + 'https://app.example/#/app', + 'https://app.example/#/ap/settings/appearance/?focus=message-link-preview' + ) + ).toBeUndefined(); + }); + + it('normalizes focus id parts', () => { + expect(toSettingsFocusIdPart('@alice:example.org')).toBe('alice-example-org'); + expect(toSettingsFocusIdPart('DEVICE-123')).toBe('device-123'); + }); +}); diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts new file mode 100644 index 000000000..4e72526ff --- /dev/null +++ b/src/app/features/settings/settingsLink.ts @@ -0,0 +1,86 @@ +import type { ClientConfig } from '$hooks/useClientConfig'; +import { getAppPathFromHref, getSettingsPath, withOriginBaseUrl } from '$pages/pathUtils'; +import { isSettingsSectionId, type SettingsSectionId } from './routes'; + +export type SettingsPermalink = { + section: SettingsSectionId; + focus?: string; +}; + +export const DEFAULT_SETTINGS_LINK_BASE_URL = 'https://app.sable.moe'; + +export const normalizeSettingsLinkBaseUrl = (value?: string | null): string | undefined => { + if (typeof value !== 'string') return undefined; + + const trimmed = value.trim(); + if (!trimmed) return undefined; + + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return undefined; + } + + return url.toString().replace(/\/+$/, ''); + } catch { + return undefined; + } +}; + +export const getConfiguredSettingsLinkBaseUrl = ( + clientConfig: Pick +): string => + normalizeSettingsLinkBaseUrl(clientConfig.settingsLinkBaseUrl) ?? DEFAULT_SETTINGS_LINK_BASE_URL; + +export const getEffectiveSettingsLinkBaseUrl = ( + clientConfig: Pick, + override?: string +): string => + normalizeSettingsLinkBaseUrl(override) ?? getConfiguredSettingsLinkBaseUrl(clientConfig); + +export const buildSettingsPermalink = ( + baseUrl: string, + section: SettingsSectionId, + focus?: string +): string => withOriginBaseUrl(baseUrl, getSettingsPath(section, focus)); + +export const parseSettingsPermalink = ( + baseUrl: string, + href: string +): SettingsPermalink | undefined => { + try { + const base = new URL(baseUrl); + const target = new URL(href); + + if (base.origin !== target.origin) return undefined; + + if (base.hash) { + const baseHash = base.hash.replace(/\/+$/, ''); + if (!(target.hash === baseHash || target.hash.startsWith(`${baseHash}/`))) { + return undefined; + } + } + + const appPath = getAppPathFromHref(baseUrl, href); + if (!appPath.startsWith('/settings/')) return undefined; + + const [pathname, search = ''] = appPath.split('?'); + const sectionMatch = pathname.match(/^\/settings\/([^/]+)\/$/); + if (!sectionMatch) return undefined; + + const section = sectionMatch[1]; + if (!isSettingsSectionId(section)) return undefined; + + const focus = new URLSearchParams(search).get('focus') ?? undefined; + + return { section, focus }; + } catch { + return undefined; + } +}; + +export const toSettingsFocusIdPart = (value: string): string => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); diff --git a/src/app/features/settings/styles.css.ts b/src/app/features/settings/styles.css.ts index 84d0a317d..23067658a 100644 --- a/src/app/features/settings/styles.css.ts +++ b/src/app/features/settings/styles.css.ts @@ -1,28 +1,14 @@ -import { keyframes, style } from '@vanilla-extract/css'; -import { color, config } from 'folds'; +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; +import { messageJumpHighlight } from '$components/message/layout/layout.css'; export const SequenceCardStyle = style({ padding: config.space.S300, }); -const focusPulse = keyframes({ - '0%': { - backgroundColor: 'transparent', - boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`, - }, - '20%': { - backgroundColor: `color-mix(in srgb, ${color.Primary.Container} 20%, transparent)`, - }, - '50%': { - backgroundColor: `color-mix(in srgb, ${color.Primary.Container} 8%, transparent)`, - }, - '100%': { - backgroundColor: 'transparent', - boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`, - }, +export const settingsHeader = style({ + paddingLeft: config.space.S300, + paddingRight: config.space.S200, }); -export const focusedSettingTile = style({ - borderRadius: config.radii.R400, - animation: `${focusPulse} 3s ease-in-out 1`, -}); +export const focusedSettingTile = messageJumpHighlight; diff --git a/src/app/features/settings/useSettingsFocus.ts b/src/app/features/settings/useSettingsFocus.ts index b301d2097..982966529 100644 --- a/src/app/features/settings/useSettingsFocus.ts +++ b/src/app/features/settings/useSettingsFocus.ts @@ -2,6 +2,10 @@ import { useEffect, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { focusedSettingTile } from './styles.css'; +const focusedSettingTileClasses = focusedSettingTile.split(' ').filter(Boolean); +const getHighlightTarget = (target: HTMLElement): HTMLElement => + target.closest('[data-sequence-card="true"]') ?? target.parentElement ?? target; + export function useSettingsFocus() { const [searchParams, setSearchParams] = useSearchParams(); const focusId = searchParams.get('focus'); @@ -14,7 +18,7 @@ export function useSettingsFocus() { window.clearTimeout(timeoutRef.current); timeoutRef.current = undefined; } - activeTargetRef.current?.classList.remove(focusedSettingTile); + activeTargetRef.current?.classList.remove(...focusedSettingTileClasses); activeTargetRef.current = null; }, [] @@ -27,8 +31,10 @@ export function useSettingsFocus() { document.querySelector(`[data-settings-focus="${focusId}"]`); if (target) { - if (activeTargetRef.current && activeTargetRef.current !== target) { - activeTargetRef.current.classList.remove(focusedSettingTile); + const highlightTarget = getHighlightTarget(target); + + if (activeTargetRef.current && activeTargetRef.current !== highlightTarget) { + activeTargetRef.current.classList.remove(...focusedSettingTileClasses); } if (timeoutRef.current !== undefined) { window.clearTimeout(timeoutRef.current); @@ -36,12 +42,12 @@ export function useSettingsFocus() { } target.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); - target.classList.add(focusedSettingTile); - activeTargetRef.current = target; + highlightTarget.classList.add(...focusedSettingTileClasses); + activeTargetRef.current = highlightTarget; timeoutRef.current = window.setTimeout(() => { - target.classList.remove(focusedSettingTile); - if (activeTargetRef.current === target) { + highlightTarget.classList.remove(...focusedSettingTileClasses); + if (activeTargetRef.current === highlightTarget) { activeTargetRef.current = null; } timeoutRef.current = undefined; diff --git a/src/app/features/settings/useSettingsLinkBaseUrl.ts b/src/app/features/settings/useSettingsLinkBaseUrl.ts new file mode 100644 index 000000000..15eb73a66 --- /dev/null +++ b/src/app/features/settings/useSettingsLinkBaseUrl.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { getEffectiveSettingsLinkBaseUrl } from './settingsLink'; + +export const useSettingsLinkBaseUrl = (): string => { + const clientConfig = useClientConfig(); + const [settingsLinkBaseUrlOverride] = useSetting(settingsAtom, 'settingsLinkBaseUrlOverride'); + + return useMemo( + () => getEffectiveSettingsLinkBaseUrl(clientConfig, settingsLinkBaseUrlOverride), + [clientConfig, settingsLinkBaseUrlOverride] + ); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 87685337d..e523f15a7 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -42,6 +42,7 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; matrixToBaseUrl?: string; + settingsLinkBaseUrl?: string; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useMentionClickHandler.test.tsx b/src/app/hooks/useMentionClickHandler.test.tsx new file mode 100644 index 000000000..55719bead --- /dev/null +++ b/src/app/hooks/useMentionClickHandler.test.tsx @@ -0,0 +1,63 @@ +import { render, fireEvent, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMentionClickHandler } from './useMentionClickHandler'; + +const { mockOpenSettings } = vi.hoisted(() => ({ + mockOpenSettings: vi.fn(), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => ({ getRoom: vi.fn() }), +})); + +vi.mock('$hooks/useRoomNavigate', () => ({ + useRoomNavigate: () => ({ navigateRoom: vi.fn(), navigateSpace: vi.fn() }), +})); + +vi.mock('$hooks/useSpace', () => ({ + useSpaceOptionally: () => undefined, +})); + +vi.mock('$state/hooks/userRoomProfile', () => ({ + useOpenUserRoomProfile: () => vi.fn(), +})); + +vi.mock('$features/settings/useOpenSettings', () => ({ + useOpenSettings: () => mockOpenSettings, +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), +})); + +function Wrapper({ children }: { children: ReactNode }) { + return <>{children}; +} + +describe('useMentionClickHandler', () => { + beforeEach(() => { + mockOpenSettings.mockReset(); + }); + + it('routes settings permalinks through openSettings with section and focus', () => { + const { result } = renderHook(() => useMentionClickHandler('!room:example.org'), { + wrapper: Wrapper, + }); + + const { getByRole } = render( + + ); + + fireEvent.click(getByRole('button', { name: 'Open settings link' })); + + expect(mockOpenSettings).toHaveBeenCalledWith('appearance', 'message-link-preview'); + }); +}); diff --git a/src/app/hooks/useMentionClickHandler.ts b/src/app/hooks/useMentionClickHandler.ts index aadfb9826..350b7c2e8 100644 --- a/src/app/hooks/useMentionClickHandler.ts +++ b/src/app/hooks/useMentionClickHandler.ts @@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'; import { isRoomId, isUserId } from '$utils/matrix'; import { getHomeRoomPath, withSearchParam } from '$pages/pathUtils'; import { RoomSearchParams } from '$pages/paths'; +import { isSettingsSectionId } from '$features/settings/routes'; +import { useOpenSettings } from '$features/settings/useOpenSettings'; import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; import { useMatrixClient } from './useMatrixClient'; import { useRoomNavigate } from './useRoomNavigate'; @@ -14,12 +16,20 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler = useCallback( (evt) => { evt.stopPropagation(); evt.preventDefault(); const target = evt.currentTarget; + const settingsSection = target.getAttribute('data-settings-link-section') || undefined; + if (isSettingsSectionId(settingsSection)) { + const settingsFocus = target.getAttribute('data-settings-link-focus') || undefined; + openSettings(settingsSection, settingsFocus); + return; + } + const mentionId = target.getAttribute('data-mention-id'); if (typeof mentionId !== 'string') return; @@ -40,7 +50,7 @@ export const useMentionClickHandler = (roomId: string): ReactEventHandler(path, { viaServers }) : path); }, - [mx, navigate, navigateRoom, navigateSpace, roomId, space, openProfile] + [mx, navigate, navigateRoom, navigateSpace, openProfile, openSettings, roomId, space] ); return handleClick; diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 3ed43c510..60424b924 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,8 +1,8 @@ +import { lazy, Suspense } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; import { RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import * as Sentry from '@sentry/react'; import { ClientConfigLoader } from '$components/ClientConfigLoader'; @@ -14,12 +14,19 @@ import { ErrorPage } from '$components/DefaultErrorPage'; import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig'; import { FeatureCheck } from './FeatureCheck'; import { createRouter } from './Router'; +import { isReactQueryDevtoolsEnabled } from './reactQueryDevtoolsGate'; const queryClient = new QueryClient(); +const ReactQueryDevtools = lazy(async () => { + const { ReactQueryDevtools: Devtools } = await import('@tanstack/react-query-devtools'); + + return { default: Devtools }; +}); function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); + const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); const portalContainer = document.getElementById('portalContainer') ?? undefined; @@ -51,7 +58,11 @@ function App() { - + {reactQueryDevtoolsEnabled && ( + + + + )} ); diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 4bbd4068f..b22abcb3b 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,14 +1,24 @@ import { ReactNode } from 'react'; import { Box } from 'folds'; +import { matchPath, useLocation } from 'react-router-dom'; +import { useScreenSizeContext } from '$hooks/useScreenSize'; +import { SETTINGS_PATH } from '../paths'; +import { isShallowSettingsRoute } from './ClientRouteOutlet'; type ClientLayoutProps = { nav: ReactNode; children: ReactNode; }; export function ClientLayout({ nav, children }: ClientLayoutProps) { + const location = useLocation(); + const screenSize = useScreenSizeContext(); + const fullPageSettings = + Boolean(matchPath(SETTINGS_PATH, location.pathname)) && + !isShallowSettingsRoute(location.pathname, location.state, screenSize); + return ( - {nav} + {!fullPageSettings && {nav}} {children} ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index dc9bf984f..26ac2f431 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -39,6 +39,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '$hooks/router/useInbox'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; import { @@ -242,6 +243,7 @@ function MessageNotifications() { const clientStartTimeRef = useRef(Date.now()); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const appBaseUrl = useSettingsLinkBaseUrl(); const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); @@ -474,6 +476,7 @@ function MessageNotifications() { content.formatted_body ) { const htmlParserOpts = getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl: appBaseUrl, linkifyOpts: LINKIFY_OPTS, useAuthentication, nicknames: nicknamesRef.current, @@ -532,6 +535,7 @@ function MessageNotifications() { setInAppBanner, setPending, selectedRoomId, + appBaseUrl, useAuthentication, ]); diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index dc3d35297..61ae4096c 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -81,6 +81,7 @@ import { UserAvatar } from '$components/user-avatar'; import { EncryptedContent } from '$features/room/message'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { BackRouteHandler } from '$components/BackRouteHandler'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; @@ -204,6 +205,7 @@ const useNotificationTimeline = ( type RoomNotificationsGroupProps = { room: Room; + appBaseUrl: string; notifications: INotification[]; mediaAutoLoad?: boolean; urlPreview?: boolean; @@ -215,6 +217,7 @@ type RoomNotificationsGroupProps = { }; function RoomNotificationsGroupComp({ room, + appBaseUrl, notifications, mediaAutoLoad, urlPreview, @@ -245,28 +248,41 @@ function RoomNotificationsGroupComp({ const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) + render: factoryRenderLinkifyWithMention( + appBaseUrl, + (href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ), + mentionClickHandler ), }), - [mx, room, mentionClickHandler, nicknames] + [appBaseUrl, mx, room, mentionClickHandler, nicknames] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl: appBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, nicknames, }), - [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, nicknames] + [ + appBaseUrl, + mx, + room, + linkifyOpts, + mentionClickHandler, + spoilerClickHandler, + useAuthentication, + nicknames, + ] ); const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>( @@ -582,6 +598,7 @@ export function Notifications() { const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const screenSize = useScreenSizeContext(); const mDirects = useAtomValue(mDirectAtom); + const appBaseUrl = useSettingsLinkBaseUrl(); const { navigateRoom } = useRoomNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -726,6 +743,7 @@ export function Notifications() { > { + afterEach(() => { + localStorage.clear(); + vi.unstubAllEnvs(); + }); + + it('is disabled by default even in development', () => { + vi.stubEnv('VITE_ENABLE_REACT_QUERY_DEVTOOLS', 'false'); + + expect(isReactQueryDevtoolsEnabled()).toBe(false); + }); + + it('is enabled by the env variable', () => { + vi.stubEnv('VITE_ENABLE_REACT_QUERY_DEVTOOLS', 'true'); + + expect(isReactQueryDevtoolsEnabled()).toBe(true); + }); + + it('is enabled by the local storage flag', () => { + vi.stubEnv('VITE_ENABLE_REACT_QUERY_DEVTOOLS', 'false'); + localStorage.setItem(REACT_QUERY_DEVTOOLS_LOCAL_STORAGE_KEY, '1'); + + expect(isReactQueryDevtoolsEnabled()).toBe(true); + }); +}); diff --git a/src/app/pages/reactQueryDevtoolsGate.ts b/src/app/pages/reactQueryDevtoolsGate.ts new file mode 100644 index 000000000..aa77ba2e9 --- /dev/null +++ b/src/app/pages/reactQueryDevtoolsGate.ts @@ -0,0 +1,5 @@ +export const REACT_QUERY_DEVTOOLS_LOCAL_STORAGE_KEY = 'sable_react_query_devtools'; + +export const isReactQueryDevtoolsEnabled = (): boolean => + import.meta.env.VITE_ENABLE_REACT_QUERY_DEVTOOLS === 'true' || + localStorage.getItem(REACT_QUERY_DEVTOOLS_LOCAL_STORAGE_KEY) === '1'; diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx new file mode 100644 index 000000000..f1a663967 --- /dev/null +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import parse from 'html-react-parser'; +import { describe, expect, it } from 'vitest'; +import * as customHtmlCss from '$styles/CustomHtml.css'; +import { + LINKIFY_OPTS, + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + makeMentionCustomProps, + renderMatrixMention, +} from './react-custom-html-parser'; + +const settingsLinkBaseUrl = 'https://app.example'; + +const mockMx = { + getUserId: () => '@alice:example.org', + getRoom: () => undefined, +}; + +function Subject({ body }: { body: string }) { + const options = getReactCustomHtmlParser(mockMx as never, undefined, { + settingsLinkBaseUrl, + linkifyOpts: LINKIFY_OPTS, + handleMentionClick: undefined, + }); + + return
{parse(body, options)}
; +} + +describe('react custom html parser', () => { + it('renders same-origin raw settings permalinks as mention-style chips through the factory link render path', () => { + const renderLink = factoryRenderLinkifyWithMention( + settingsLinkBaseUrl, + () => undefined, + undefined + ) as (ir: never) => JSX.Element; + + render( +
+ {renderLink({ + tagName: 'a', + attributes: { + href: 'https://app.example/settings/appearance/?focus=message-link-preview', + }, + content: 'https://app.example/settings/appearance/?focus=message-link-preview', + } as never)} +
+ ); + + const link = screen.getByRole('link', { name: 'Appearance / Message Link Preview' }); + expect(link).toHaveAttribute('data-settings-link-section', 'appearance'); + expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); + expect(link.className).toContain(customHtmlCss.Mention({})); + expect(link.className).toContain(customHtmlCss.SettingsMention); + expect(link).not.toHaveTextContent('Settings:'); + }); + + it('renders same-origin settings permalinks as internal app links with settings metadata', () => { + render( + + ); + + const link = screen.getByRole('link', { name: 'Appearance' }); + expect(link).toHaveAttribute( + 'href', + 'https://app.example/settings/appearance/?focus=message-link-preview' + ); + expect(link).toHaveAttribute('data-settings-link-section', 'appearance'); + expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); + expect(link).not.toHaveAttribute('data-mention-id'); + expect(link.className).toContain(customHtmlCss.Mention({})); + expect(link.className).toContain(customHtmlCss.SettingsMention); + }); + + it('renders matrix message permalinks with an icon instead of the Message prefix', () => { + render( +
+ {renderMatrixMention( + { + getUserId: () => '@alice:example.org', + getRoom: () => ({ roomId: '!room:example.org', name: 'Lobby' }), + } as never, + undefined, + 'https://matrix.to/#/!room:example.org/$event123', + makeMentionCustomProps(undefined) + )} +
+ ); + + const link = screen.getByRole('link', { name: '#Lobby' }); + expect(link).toHaveAttribute('data-mention-id', '!room:example.org'); + expect(link).toHaveAttribute('data-mention-event-id', '$event123'); + expect(link.className).toContain(customHtmlCss.Mention({})); + expect(link.className).toContain(customHtmlCss.SettingsMention); + expect(link).not.toHaveTextContent('Message:'); + expect(link.querySelector('[aria-hidden="true"]')).not.toBeNull(); + }); +}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 1a351572f..efb8991b5 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -36,6 +36,8 @@ import { findAndReplace } from '$utils/findAndReplace'; import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { parseSettingsPermalink } from '$features/settings/settingsLink'; +import { settingsSections } from '$features/settings/routes'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { parseMatrixToRoom, @@ -138,21 +140,26 @@ export const renderMatrixMention = ( const mentionRoom = mx.getRoom( isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias ); + const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias; return ( - {customProps.children - ? customProps.children - : `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`} + + {customProps.children ? customProps.children : fallbackContent} ); } @@ -160,8 +167,80 @@ export const renderMatrixMention = ( return undefined; }; +const settingsSectionLabel = Object.fromEntries( + settingsSections.map((section) => [section.id, section.label]) +) as Record<(typeof settingsSections)[number]['id'], string>; + +const humanizeSettingsPermalinkPart = (value: string): string => + value + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +const getSettingsPermalinkLabel = ( + section: keyof typeof settingsSectionLabel, + focus?: string +): string => { + const sectionLabel = settingsSectionLabel[section]; + const focusLabel = focus ? humanizeSettingsPermalinkPart(focus) : undefined; + + return focusLabel ? `${sectionLabel} / ${focusLabel}` : sectionLabel; +}; + +const getSettingsPermalinkChildren = ({ + href, + section, + focus, + content, + fallbackChildren, +}: { + href: string; + section: keyof typeof settingsSectionLabel; + focus?: string; + content?: string; + fallbackChildren?: ReactNode; +}): ReactNode => { + if (!content || content === href || content === safeDecodeUrl(href)) { + return getSettingsPermalinkLabel(section, focus); + } + + return fallbackChildren ?? content; +}; + +const renderSettingsPermalink = ({ + href, + section, + focus, + handleMentionClick, + content, + fallbackChildren, +}: { + href: string; + section: keyof typeof settingsSectionLabel; + focus?: string; + handleMentionClick?: ReactEventHandler; + content?: string; + fallbackChildren?: ReactNode; +}) => ( + + + {getSettingsPermalinkChildren({ href, section, focus, content, fallbackChildren })} + +); + export const factoryRenderLinkifyWithMention = ( - mentionRender: (href: string) => JSX.Element | undefined + settingsLinkBaseUrl: string, + mentionRender: (href: string) => JSX.Element | undefined, + handleMentionClick?: ReactEventHandler ): OptFn<(ir: IntermediateRepresentation) => any> => { const renderLink: OptFn<(ir: IntermediateRepresentation) => any> = ({ tagName, @@ -176,6 +255,21 @@ export const factoryRenderLinkifyWithMention = ( if (mention) return mention; } + if (tagName === 'a' && decodedHref) { + const settingsPermalink = parseSettingsPermalink(settingsLinkBaseUrl, decodedHref); + if (settingsPermalink) { + const { section, focus } = settingsPermalink; + return renderSettingsPermalink({ + href: decodedHref, + section, + focus, + handleMentionClick, + content, + fallbackChildren: content, + }); + } + } + return {content}; }; @@ -344,6 +438,7 @@ export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, params: { + settingsLinkBaseUrl: string; linkifyOpts: LinkifyOpts; highlightRegex?: RegExp; handleSpoilerClick?: ReactEventHandler; @@ -489,23 +584,41 @@ export const getReactCustomHtmlParser = ( if (name === 'a' && typeof props.href === 'string') { const encodedHref = props.href; const decodedHref = encodedHref && safeDecodeUrl(encodedHref); - if (!decodedHref || !testMatrixTo(decodedHref)) { - return undefined; - } + const renderedChildren = renderChildren(); const content = children.find((child) => !(child instanceof DOMText)) ? undefined : children.map((c) => (c instanceof DOMText ? c.data : '')).join(); - const mention = renderMatrixMention( - mx, - roomId, - safeDecodeUrl(props.href), - makeMentionCustomProps(params.handleMentionClick, content), - params.nicknames - ); + if (decodedHref && testMatrixTo(decodedHref)) { + const mention = renderMatrixMention( + mx, + roomId, + decodedHref, + makeMentionCustomProps(params.handleMentionClick, content), + params.nicknames + ); + + if (mention) return mention; + } - if (mention) return mention; + if (decodedHref) { + const settingsPermalink = parseSettingsPermalink( + params.settingsLinkBaseUrl, + decodedHref + ); + if (settingsPermalink) { + const { section, focus } = settingsPermalink; + return renderSettingsPermalink({ + href: decodedHref, + section, + focus, + handleMentionClick: params.handleMentionClick, + content, + fallbackChildren: renderedChildren, + }); + } + } } if (name === 'span' && 'data-mx-spoiler' in props) { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index cfa533866..0b4bf15dc 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -69,6 +69,7 @@ export interface Settings { developerTools: boolean; enableMSC4268CMD: boolean; settingsSyncEnabled: boolean; + settingsLinkBaseUrlOverride?: string; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -162,6 +163,7 @@ const defaultSettings: Settings = { developerTools: false, settingsSyncEnabled: false, + settingsLinkBaseUrlOverride: undefined, // Cosmetics! jumboEmojiSize: 'normal', diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index 6ff53d9d5..2102ea4e8 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -171,6 +171,18 @@ export const Mention = recipe({ }, }); +export const SettingsMention = style({ + display: 'inline-flex', + alignItems: 'center', + gap: config.space.S100, + padding: `0 ${config.space.S100}`, +}); + +export const SettingsMentionIcon = style({ + display: 'inline-flex', + flexShrink: 0, +}); + export const Command = recipe({ base: [ DefaultReset, diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 12fc93e7e..e51bdfedd 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -187,22 +187,35 @@ export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'insta }); }; -export const copyToClipboard = (text: string) => { +export const copyToClipboard = async (text: string): Promise => { if (navigator.clipboard) { - navigator.clipboard.writeText(text); - } else { - const host = document.body; - const copyInput = document.createElement('input'); - copyInput.style.position = 'fixed'; - copyInput.style.opacity = '0'; - copyInput.value = text; - host.append(copyInput); - - copyInput.select(); - copyInput.setSelectionRange(0, 99999); - document.execCommand('Copy'); - copyInput.remove(); + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } + } + + const host = document.body; + const copyInput = document.createElement('input'); + copyInput.style.position = 'fixed'; + copyInput.style.opacity = '0'; + copyInput.value = text; + host.append(copyInput); + + copyInput.select(); + copyInput.setSelectionRange(0, 99999); + + let copied = false; + try { + copied = document.execCommand('Copy'); + } catch { + copied = false; } + + copyInput.remove(); + return copied; }; export const setFavicon = (url: string): void => { diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 66c4f5274..fb5ad6c8b 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -30,6 +30,7 @@ describe('NON_SYNCABLE_KEYS', () => { 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + 'settingsLinkBaseUrlOverride', 'developerTools', 'settingsSyncEnabled', ] as const; diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index a154ff92d..0f6b25888 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -14,6 +14,7 @@ export const NON_SYNCABLE_KEYS = new Set([ 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + 'settingsLinkBaseUrlOverride', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) From 1e000baa15fc39d73d34943c1c0717095df564f5 Mon Sep 17 00:00:00 2001 From: hazre Date: Fri, 27 Mar 2026 15:21:06 +0100 Subject: [PATCH 14/18] fix: polish settings permalinks and routing --- .../components/RenderMessageContent.test.tsx | 2 +- .../setting-tile/SettingTile.test.tsx | 4 +- src/app/features/settings/Settings.test.tsx | 4 +- .../features/settings/SettingsRoute.test.tsx | 108 +++++++++++++++++- src/app/features/settings/SettingsRoute.tsx | 43 ++++++- .../settings/SettingsShallowRouteRenderer.tsx | 11 +- .../features/settings/settingsLink.test.ts | 16 ++- src/app/features/settings/settingsLink.ts | 2 +- src/app/features/settings/useSettingsFocus.ts | 104 +++++++++++------ src/app/pages/pathUtils.test.ts | 6 +- src/app/pages/pathUtils.ts | 3 +- .../plugins/react-custom-html-parser.test.tsx | 8 +- 12 files changed, 241 insertions(+), 70 deletions(-) diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx index ffacb90a3..647cc3640 100644 --- a/src/app/components/RenderMessageContent.test.tsx +++ b/src/app/components/RenderMessageContent.test.tsx @@ -32,7 +32,7 @@ function renderMessage(body: string, settingsLinkBaseUrl = 'https://app.sable.mo describe('RenderMessageContent', () => { it('does not render url previews for settings permalinks', () => { - renderMessage('https://app.sable.moe/settings/account/?focus=status'); + renderMessage('https://app.sable.moe/settings/account?focus=status'); expect(screen.queryByTestId('url-preview-holder')).not.toBeInTheDocument(); expect(screen.queryByTestId('url-preview-card')).not.toBeInTheDocument(); diff --git a/src/app/components/setting-tile/SettingTile.test.tsx b/src/app/components/setting-tile/SettingTile.test.tsx index a21a59f72..05fb9f576 100644 --- a/src/app/components/setting-tile/SettingTile.test.tsx +++ b/src/app/components/setting-tile/SettingTile.test.tsx @@ -44,7 +44,7 @@ describe('SettingTile', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://settings.example/settings/appearance/?focus=message-link-preview' + 'https://settings.example/settings/appearance?focus=message-link-preview' ); }); expect(screen.getByRole('button', { name: /copied settings permalink/i })).toBeInTheDocument(); @@ -59,7 +59,7 @@ describe('SettingTile', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://settings.example/settings/appearance/?focus=message-link-preview' + 'https://settings.example/settings/appearance?focus=message-link-preview' ); }); expect(screen.getByRole('button', { name: /copy settings permalink/i })).toBeInTheDocument(); diff --git a/src/app/features/settings/Settings.test.tsx b/src/app/features/settings/Settings.test.tsx index cad14033e..a26bd8c41 100644 --- a/src/app/features/settings/Settings.test.tsx +++ b/src/app/features/settings/Settings.test.tsx @@ -162,7 +162,7 @@ describe('Settings', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://config.example/settings/appearance/?focus=message-link-preview' + 'https://config.example/settings/appearance?focus=message-link-preview' ); }); }); @@ -183,7 +183,7 @@ describe('Settings', () => { await waitFor(() => { expect(writeText).toHaveBeenCalledWith( - 'https://override.example/settings/appearance/?focus=message-link-preview' + 'https://override.example/settings/appearance?focus=message-link-preview' ); }); }); diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 91de02140..35d2f8f44 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -1,6 +1,6 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ReactNode } from 'react'; +import { type ReactNode, useState } from 'react'; import { MemoryRouter, Route, @@ -170,6 +170,19 @@ function FocusFixture() { ); } +function FocusFixtureToggle() { + const [visible, setVisible] = useState(true); + + return ( +
+ + {visible && } +
+ ); +} + function LocationProbe() { const location = useLocation(); const navigationType = useNavigationType(); @@ -212,6 +225,9 @@ function OpenSettingsHomePage() { + ); } @@ -432,6 +448,16 @@ describe('SettingsRoute', () => { expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); }); + it('canonicalizes legacy trailing-slash settings section routes', async () => { + renderSettingsRoute('/settings/general/', ScreenSize.Mobile); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/general') + ); + expect(screen.getByTestId('location-probe')).not.toHaveTextContent('/settings/general/'); + expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); + }); + it('falls back to /home when the redirected desktop general page is closed from a direct root entry', async () => { const user = userEvent.setup(); @@ -591,7 +617,7 @@ describe('SettingsRoute', () => { const user = userEvent.setup(); renderSettingsRoute('/settings/devices', ScreenSize.Mobile, { - initialEntries: ['/settings/', '/settings/devices/'], + initialEntries: [getSettingsPath(), getSettingsPath('devices')], initialIndex: 1, }); @@ -603,6 +629,31 @@ describe('SettingsRoute', () => { }); describe('Settings shallow route shell', () => { + it('keeps desktop settings shallow after the focus highlight completes', async () => { + vi.useFakeTimers(); + + try { + renderClientShellWithOpenSettings(ScreenSize.Desktop); + + fireEvent.click(screen.getByRole('button', { name: 'Open focused general settings' })); + + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); + expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-layout'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/general'); + expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-layout'); + expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it('opens device settings through route navigation and keeps the desktop background mounted', async () => { const user = userEvent.setup(); @@ -750,7 +801,56 @@ describe('useSettingsFocus', () => { await act(async () => { await vi.advanceTimersByTimeAsync(1); }); - expect(screen.getByTestId('location-probe')).toHaveTextContent('/settings/appearance'); + expect(screen.getByTestId('location-probe')).toHaveTextContent( + '/settings/appearance?focus=message-link-preview' + ); + expect(highlightTarget).not.toHaveClass(focusedSettingTile); + } finally { + vi.useRealTimers(); + } + }); + + it('does not re-highlight when the same focus entry remounts', async () => { + vi.useFakeTimers(); + + try { + render( + + + + + + + ); + + let target = document.querySelector('[data-settings-focus="message-link-preview"]'); + let highlightTarget = target?.parentElement; + + expect(highlightTarget).toHaveClass(focusedSettingTile); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(highlightTarget).not.toHaveClass(focusedSettingTile); + expect(screen.getByTestId('location-probe')).toHaveTextContent( + '/settings/appearance?focus=message-link-preview' + ); + + fireEvent.click(screen.getByRole('button', { name: 'Toggle focus fixture' })); + fireEvent.click(screen.getByRole('button', { name: 'Toggle focus fixture' })); + + target = document.querySelector('[data-settings-focus="message-link-preview"]'); + highlightTarget = target?.parentElement; + expect(highlightTarget).not.toHaveClass(focusedSettingTile); } finally { vi.useRealTimers(); diff --git a/src/app/features/settings/SettingsRoute.tsx b/src/app/features/settings/SettingsRoute.tsx index c1fbce5de..b23fa24c5 100644 --- a/src/app/features/settings/SettingsRoute.tsx +++ b/src/app/features/settings/SettingsRoute.tsx @@ -4,6 +4,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { getSettingsPath } from '$pages/pathUtils'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { trimTrailingSlash } from '$utils/common'; import { getSettingsCloseTarget, type SettingsRouteState } from './navigation'; import { Settings } from './Settings'; import { isSettingsSectionId, type SettingsSectionId } from './routes'; @@ -28,8 +29,13 @@ function resolveSettingsSection( return section; } -export function SettingsRoute() { - const { section } = useParams<{ section?: string }>(); +type SettingsRouteProps = { + routeSection?: string; +}; + +export function SettingsRoute({ routeSection }: SettingsRouteProps) { + const { section: paramsSection } = useParams<{ section?: string }>(); + const section = routeSection ?? paramsSection; const navigate = useNavigate(); const location = useLocation(); const screenSize = useScreenSizeContext(); @@ -37,6 +43,10 @@ export function SettingsRoute() { const routeState = location.state as SettingsRouteState | null; const shallowBackgroundState = screenSize !== ScreenSize.Mobile && Boolean(routeState?.backgroundLocation); + const canonicalPathname = trimTrailingSlash(location.pathname); + const shouldCanonicalizeTrailingSlash = + location.pathname.length > canonicalPathname.length && + canonicalPathname.startsWith('/settings'); const browserHistoryIndex = typeof window !== 'undefined' && typeof window.history.state?.idx === 'number' ? window.history.state.idx @@ -50,6 +60,18 @@ export function SettingsRoute() { const shouldRedirectToIndex = section !== undefined && activeSection === null; useEffect(() => { + if (shouldCanonicalizeTrailingSlash) { + navigate( + { + pathname: canonicalPathname, + search: location.search, + hash: location.hash, + }, + { replace: true, state: routeState } + ); + return; + } + if (shouldRedirectToGeneral) { navigate(getSettingsPath('general'), { replace: true, @@ -61,9 +83,20 @@ export function SettingsRoute() { if (!shouldRedirectToIndex) return; navigate(getSettingsPath(), { replace: true, state: routeState }); - }, [navigate, routeState, shouldRedirectToGeneral, shouldRedirectToIndex]); - - if (shouldRedirectToGeneral || shouldRedirectToIndex) return null; + }, [ + canonicalPathname, + location.hash, + location.search, + navigate, + routeState, + shouldCanonicalizeTrailingSlash, + shouldRedirectToGeneral, + shouldRedirectToIndex, + ]); + + if (shouldCanonicalizeTrailingSlash || shouldRedirectToGeneral || shouldRedirectToIndex) { + return null; + } const requestBack = () => { if (section === undefined) return; diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx index e44d8746c..2fd7053ce 100644 --- a/src/app/features/settings/SettingsShallowRouteRenderer.tsx +++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx @@ -1,4 +1,4 @@ -import { Route, Routes, useNavigate, useLocation } from 'react-router-dom'; +import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { useScreenSizeContext } from '$hooks/useScreenSize'; import { Modal500 } from '$components/Modal500'; import { isShallowSettingsRoute } from '$pages/client/ClientRouteOutlet'; @@ -11,8 +11,11 @@ export function SettingsShallowRouteRenderer() { const location = useLocation(); const screenSize = useScreenSizeContext(); const routeState = location.state as SettingsRouteState | null; + const routeMatch = matchPath(SETTINGS_PATH, location.pathname); - if (!isShallowSettingsRoute(location.pathname, location.state, screenSize)) return null; + if (!isShallowSettingsRoute(location.pathname, location.state, screenSize) || !routeMatch) { + return null; + } const handleRequestClose = () => { const closeTarget = getSettingsCloseTarget(routeState); @@ -21,9 +24,7 @@ export function SettingsShallowRouteRenderer() { return ( - - } /> - + ); } diff --git a/src/app/features/settings/settingsLink.test.ts b/src/app/features/settings/settingsLink.test.ts index a9dfdb100..4fe490d90 100644 --- a/src/app/features/settings/settingsLink.test.ts +++ b/src/app/features/settings/settingsLink.test.ts @@ -11,10 +11,10 @@ describe('settingsLink', () => { it('builds settings permalinks for plain and hash-router base urls', () => { expect( buildSettingsPermalink('https://app.example', 'appearance', 'message-link-preview') - ).toBe('https://app.example/settings/appearance/?focus=message-link-preview'); + ).toBe('https://app.example/settings/appearance?focus=message-link-preview'); expect( buildSettingsPermalink('https://app.example/#/app', 'appearance', 'message-link-preview') - ).toBe('https://app.example/#/app/settings/appearance/?focus=message-link-preview'); + ).toBe('https://app.example/#/app/settings/appearance?focus=message-link-preview'); }); it('resolves the settings link base URL from built-in default, config, and override', () => { @@ -32,6 +32,12 @@ describe('settingsLink', () => { }); it('parses settings permalinks from the same app origin only', () => { + expect( + parseSettingsPermalink( + 'https://app.example', + 'https://app.example/settings/appearance?focus=message-link-preview' + ) + ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); expect( parseSettingsPermalink( 'https://app.example', @@ -40,7 +46,7 @@ describe('settingsLink', () => { ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); expect( - parseSettingsPermalink('https://app.example', 'https://other.example/settings/appearance/') + parseSettingsPermalink('https://app.example', 'https://other.example/settings/appearance') ).toBeUndefined(); expect( parseSettingsPermalink('https://app.example', 'https://app.example/home/') @@ -51,7 +57,7 @@ describe('settingsLink', () => { expect( parseSettingsPermalink( 'https://app.example/#/app', - 'https://app.example/#/wrong/settings/appearance/?focus=message-link-preview' + 'https://app.example/#/wrong/settings/appearance?focus=message-link-preview' ) ).toBeUndefined(); }); @@ -60,7 +66,7 @@ describe('settingsLink', () => { expect( parseSettingsPermalink( 'https://app.example/#/app', - 'https://app.example/#/ap/settings/appearance/?focus=message-link-preview' + 'https://app.example/#/ap/settings/appearance?focus=message-link-preview' ) ).toBeUndefined(); }); diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 4e72526ff..0f2228c7f 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -65,7 +65,7 @@ export const parseSettingsPermalink = ( if (!appPath.startsWith('/settings/')) return undefined; const [pathname, search = ''] = appPath.split('?'); - const sectionMatch = pathname.match(/^\/settings\/([^/]+)\/$/); + const sectionMatch = pathname.match(/^\/settings\/([^/]+)\/?$/); if (!sectionMatch) return undefined; const section = sectionMatch[1]; diff --git a/src/app/features/settings/useSettingsFocus.ts b/src/app/features/settings/useSettingsFocus.ts index 982966529..8edfaa020 100644 --- a/src/app/features/settings/useSettingsFocus.ts +++ b/src/app/features/settings/useSettingsFocus.ts @@ -1,14 +1,24 @@ import { useEffect, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { focusedSettingTile } from './styles.css'; const focusedSettingTileClasses = focusedSettingTile.split(' ').filter(Boolean); const getHighlightTarget = (target: HTMLElement): HTMLElement => target.closest('[data-sequence-card="true"]') ?? target.parentElement ?? target; +const SETTINGS_FOCUS_HANDLED_STATE_KEY = 'settingsFocusHandledKey'; + +type SettingsFocusRouteState = { + [SETTINGS_FOCUS_HANDLED_STATE_KEY]?: string; +}; export function useSettingsFocus() { - const [searchParams, setSearchParams] = useSearchParams(); - const focusId = searchParams.get('focus'); + const navigate = useNavigate(); + const location = useLocation(); + const focusId = new URLSearchParams(location.search).get('focus'); + const focusNavigationKey = focusId ? `${location.pathname}${location.search}` : undefined; + const handledFocusNavigationKey = (location.state as SettingsFocusRouteState | null)?.[ + SETTINGS_FOCUS_HANDLED_STATE_KEY + ]; const activeTargetRef = useRef(null); const timeoutRef = useRef(undefined); @@ -25,43 +35,65 @@ export function useSettingsFocus() { ); useEffect(() => { - if (focusId) { - const target = - document.getElementById(focusId) ?? - document.querySelector(`[data-settings-focus="${focusId}"]`); + if (!focusId || !focusNavigationKey || handledFocusNavigationKey === focusNavigationKey) { + return; + } + + const target = + document.getElementById(focusId) ?? + document.querySelector(`[data-settings-focus="${focusId}"]`); + + if (!target) return; - if (target) { - const highlightTarget = getHighlightTarget(target); + const highlightTarget = getHighlightTarget(target); - if (activeTargetRef.current && activeTargetRef.current !== highlightTarget) { - activeTargetRef.current.classList.remove(...focusedSettingTileClasses); - } - if (timeoutRef.current !== undefined) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = undefined; - } + if (activeTargetRef.current && activeTargetRef.current !== highlightTarget) { + activeTargetRef.current.classList.remove(...focusedSettingTileClasses); + } + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + } - target.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); - highlightTarget.classList.add(...focusedSettingTileClasses); - activeTargetRef.current = highlightTarget; + target.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + highlightTarget.classList.add(...focusedSettingTileClasses); + activeTargetRef.current = highlightTarget; - timeoutRef.current = window.setTimeout(() => { - highlightTarget.classList.remove(...focusedSettingTileClasses); - if (activeTargetRef.current === highlightTarget) { - activeTargetRef.current = null; - } - timeoutRef.current = undefined; + navigate( + { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }, + { + replace: true, + state: + location.state && typeof location.state === 'object' + ? { + ...(location.state as Record), + [SETTINGS_FOCUS_HANDLED_STATE_KEY]: focusNavigationKey, + } + : { + [SETTINGS_FOCUS_HANDLED_STATE_KEY]: focusNavigationKey, + }, + } + ); - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete('focus'); - return next; - }, - { replace: true } - ); - }, 3000); + timeoutRef.current = window.setTimeout(() => { + highlightTarget.classList.remove(...focusedSettingTileClasses); + if (activeTargetRef.current === highlightTarget) { + activeTargetRef.current = null; } - } - }, [focusId, searchParams, setSearchParams]); + timeoutRef.current = undefined; + }, 3000); + }, [ + focusId, + focusNavigationKey, + handledFocusNavigationKey, + location.hash, + location.pathname, + location.search, + location.state, + navigate, + ]); } diff --git a/src/app/pages/pathUtils.test.ts b/src/app/pages/pathUtils.test.ts index 813006d80..141ee990e 100644 --- a/src/app/pages/pathUtils.test.ts +++ b/src/app/pages/pathUtils.test.ts @@ -3,13 +3,13 @@ import { getSettingsPath } from './pathUtils'; describe('getSettingsPath', () => { it('returns the settings root path', () => { - expect(getSettingsPath()).toBe('/settings/'); + expect(getSettingsPath()).toBe('/settings'); }); it('returns a section path with an optional focus query', () => { - expect(getSettingsPath('devices')).toBe('/settings/devices/'); + expect(getSettingsPath('devices')).toBe('/settings/devices'); expect(getSettingsPath('appearance', 'message-link-preview')).toBe( - '/settings/appearance/?focus=message-link-preview' + '/settings/appearance?focus=message-link-preview' ); }); }); diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 0061b0a54..120a1a70c 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -162,8 +162,7 @@ export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; export const getSettingsPath = (section?: string, focus?: string): string => { - const basePath = generatePath(SETTINGS_PATH, { section: section ?? null }); - const path = basePath.endsWith('/') ? basePath : `${basePath}/`; + const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); if (!focus) return path; const params: SettingsPathSearchParams = { focus }; diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index f1a663967..945789084 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -40,9 +40,9 @@ describe('react custom html parser', () => { {renderLink({ tagName: 'a', attributes: { - href: 'https://app.example/settings/appearance/?focus=message-link-preview', + href: 'https://app.example/settings/appearance?focus=message-link-preview', }, - content: 'https://app.example/settings/appearance/?focus=message-link-preview', + content: 'https://app.example/settings/appearance?focus=message-link-preview', } as never)} ); @@ -57,13 +57,13 @@ describe('react custom html parser', () => { it('renders same-origin settings permalinks as internal app links with settings metadata', () => { render( - + ); const link = screen.getByRole('link', { name: 'Appearance' }); expect(link).toHaveAttribute( 'href', - 'https://app.example/settings/appearance/?focus=message-link-preview' + 'https://app.example/settings/appearance?focus=message-link-preview' ); expect(link).toHaveAttribute('data-settings-link-section', 'appearance'); expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); From 50542e9e915fb6266a4164a0e5007b426a8480c4 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 28 Mar 2026 19:42:14 +0100 Subject: [PATCH 15/18] fix: polish settings permalink controls --- .../setting-tile/SettingTile.css.ts | 25 +++++++++++++------ .../setting-tile/SettingTile.test.tsx | 7 ++++++ .../components/setting-tile/SettingTile.tsx | 2 ++ .../SettingsLinkBaseUrlSetting.test.tsx | 6 +++++ .../general/SettingsLinkBaseUrlSetting.tsx | 2 +- .../plugins/react-custom-html-parser.test.tsx | 8 +++--- src/app/plugins/react-custom-html-parser.tsx | 8 +++--- src/app/styles/CustomHtml.css.ts | 7 +++--- 8 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/app/components/setting-tile/SettingTile.css.ts b/src/app/components/setting-tile/SettingTile.css.ts index 34f707900..031648c03 100644 --- a/src/app/components/setting-tile/SettingTile.css.ts +++ b/src/app/components/setting-tile/SettingTile.css.ts @@ -1,5 +1,4 @@ import { style } from '@vanilla-extract/css'; -import { config } from 'folds'; export const settingTileRoot = style({ minWidth: 0, @@ -9,9 +8,24 @@ export const settingTileTitleRow = style({ minWidth: 0, }); -const permalinkActionBase = style({ - transition: - 'opacity 150ms ease, transform 150ms ease, color 150ms ease, background-color 150ms ease', +const permalinkActionBase = style({}); + +export const settingTilePermalinkActionTransparentBackground = style({ + backgroundColor: 'transparent', + selectors: { + '&[aria-pressed=true]': { + backgroundColor: 'transparent', + }, + '&:hover': { + backgroundColor: 'transparent', + }, + '&:focus-visible': { + backgroundColor: 'transparent', + }, + '&:active': { + backgroundColor: 'transparent', + }, + }, }); export const settingTilePermalinkAction = style([ @@ -30,16 +44,13 @@ export const settingTilePermalinkActionDesktopHidden = style([ { opacity: 0, pointerEvents: 'none', - transform: `translateX(${config.space.S100})`, selectors: { [`${settingTileRoot}:hover &`]: { opacity: 1, - transform: 'translateX(0)', pointerEvents: 'auto', }, [`${settingTileRoot}:focus-within &`]: { opacity: 1, - transform: 'translateX(0)', pointerEvents: 'auto', }, }, diff --git a/src/app/components/setting-tile/SettingTile.test.tsx b/src/app/components/setting-tile/SettingTile.test.tsx index 05fb9f576..99aa01dda 100644 --- a/src/app/components/setting-tile/SettingTile.test.tsx +++ b/src/app/components/setting-tile/SettingTile.test.tsx @@ -7,6 +7,7 @@ import { SettingTile } from './SettingTile'; import { settingTilePermalinkActionDesktopHidden, settingTilePermalinkActionMobileVisible, + settingTilePermalinkActionTransparentBackground, } from './SettingTile.css'; const writeText = vi.fn(); @@ -82,6 +83,12 @@ describe('SettingTile', () => { expect(screen.getByText('Appearance').parentElement).toContainElement( screen.getByRole('button', { name: /copy settings permalink/i }) ); + expect(screen.getByRole('button', { name: /copy settings permalink/i })).toHaveClass( + settingTilePermalinkActionTransparentBackground, + { + exact: false, + } + ); expect(screen.getByRole('button', { name: /copy settings permalink/i })).toHaveClass( settingTilePermalinkActionDesktopHidden ); diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index 33b6ce116..4e2d723a2 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -11,6 +11,7 @@ import { settingTilePermalinkAction, settingTilePermalinkActionDesktopHidden, settingTilePermalinkActionMobileVisible, + settingTilePermalinkActionTransparentBackground, settingTileRoot, settingTileTitleRow, } from './SettingTile.css'; @@ -43,6 +44,7 @@ function SettingTilePermalinkAction({ aria-label={copied ? 'Copied settings permalink' : 'Copy settings permalink'} className={[ settingTilePermalinkAction, + settingTilePermalinkActionTransparentBackground, screenSize === ScreenSize.Desktop ? settingTilePermalinkActionDesktopHidden : settingTilePermalinkActionMobileVisible, diff --git a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx index f79b1e0c0..6bc4a8518 100644 --- a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx +++ b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx @@ -28,6 +28,12 @@ describe('SettingsLinkBaseUrlSetting', () => { setSettingsLinkBaseUrlOverride.mockReset(); }); + it('uses Url casing in the visible setting title', () => { + renderSetting('https://config.example'); + + expect(screen.getByText('Settings Link Base Url')).toBeInTheDocument(); + }); + it('shows the configured default in the input and no separate reset button', () => { renderSetting('https://config.example'); diff --git a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx index 22bb6655b..deb6b760b 100644 --- a/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx +++ b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.tsx @@ -51,7 +51,7 @@ export function SettingsLinkBaseUrlSetting() { return ( diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 945789084..d259204db 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -51,7 +51,8 @@ describe('react custom html parser', () => { expect(link).toHaveAttribute('data-settings-link-section', 'appearance'); expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); expect(link.className).toContain(customHtmlCss.Mention({})); - expect(link.className).toContain(customHtmlCss.SettingsMention); + expect(link.className).not.toContain(customHtmlCss.Mention({ highlight: true })); + expect(link.className).toContain(customHtmlCss.MentionWithIcon); expect(link).not.toHaveTextContent('Settings:'); }); @@ -69,7 +70,8 @@ describe('react custom html parser', () => { expect(link).toHaveAttribute('data-settings-link-focus', 'message-link-preview'); expect(link).not.toHaveAttribute('data-mention-id'); expect(link.className).toContain(customHtmlCss.Mention({})); - expect(link.className).toContain(customHtmlCss.SettingsMention); + expect(link.className).not.toContain(customHtmlCss.Mention({ highlight: true })); + expect(link.className).toContain(customHtmlCss.MentionWithIcon); }); it('renders matrix message permalinks with an icon instead of the Message prefix', () => { @@ -91,7 +93,7 @@ describe('react custom html parser', () => { expect(link).toHaveAttribute('data-mention-id', '!room:example.org'); expect(link).toHaveAttribute('data-mention-event-id', '$event123'); expect(link.className).toContain(customHtmlCss.Mention({})); - expect(link.className).toContain(customHtmlCss.SettingsMention); + expect(link.className).toContain(customHtmlCss.MentionWithIcon); expect(link).not.toHaveTextContent('Message:'); expect(link.querySelector('[aria-hidden="true"]')).not.toBeNull(); }); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index efb8991b5..3bbb6800e 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -150,13 +150,13 @@ export const renderMatrixMention = ( css.Mention({ highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias), }), - css.SettingsMention + css.MentionWithIcon )} data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias} data-mention-event-id={eventId} data-mention-via={viaServers?.join(',')} > -
); diff --git a/src/app/features/settings/SettingsLinkContext.tsx b/src/app/features/settings/SettingsLinkContext.tsx new file mode 100644 index 000000000..aa8f1dede --- /dev/null +++ b/src/app/features/settings/SettingsLinkContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; +import { type SettingsSectionId } from './routes'; + +type SettingsLinkContextValue = { + section: SettingsSectionId; + baseUrl: string; +}; + +const SettingsLinkContext = createContext(null); + +export const SettingsLinkProvider = SettingsLinkContext.Provider; + +export const useSettingsLinkContext = (): SettingsLinkContextValue | null => + useContext(SettingsLinkContext); + +export type { SettingsLinkContextValue }; diff --git a/src/app/features/settings/SettingsPermalinkContext.tsx b/src/app/features/settings/SettingsPermalinkContext.tsx deleted file mode 100644 index 043b1e354..000000000 --- a/src/app/features/settings/SettingsPermalinkContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext, useContext } from 'react'; -import { type SettingsSectionId } from './routes'; - -type SettingsPermalinkContextValue = { - section: SettingsSectionId; - baseUrl: string; -}; - -const SettingsPermalinkContext = createContext(null); - -export const SettingsPermalinkProvider = SettingsPermalinkContext.Provider; - -export const useSettingsPermalinkContext = (): SettingsPermalinkContextValue | null => - useContext(SettingsPermalinkContext); - -export type { SettingsPermalinkContextValue }; diff --git a/src/app/features/settings/devices/DeviceTile.tsx b/src/app/features/settings/devices/DeviceTile.tsx index a63d7b727..385f70965 100644 --- a/src/app/features/settings/devices/DeviceTile.tsx +++ b/src/app/features/settings/devices/DeviceTile.tsx @@ -287,6 +287,7 @@ export function DeviceTile({ <> { - it('builds settings permalinks for plain and hash-router base urls', () => { + it('builds settings links for plain and hash-router base urls', () => { + expect(buildSettingsLink('https://app.example', 'appearance', 'message-link-preview')).toBe( + 'https://app.example/settings/appearance?focus=message-link-preview' + ); expect( - buildSettingsPermalink('https://app.example', 'appearance', 'message-link-preview') - ).toBe('https://app.example/settings/appearance?focus=message-link-preview'); - expect( - buildSettingsPermalink('https://app.example/#/app', 'appearance', 'message-link-preview') + buildSettingsLink('https://app.example/#/app', 'appearance', 'message-link-preview') ).toBe('https://app.example/#/app/settings/appearance?focus=message-link-preview'); }); @@ -31,40 +31,38 @@ describe('settingsLink', () => { ).toBe('https://override.example'); }); - it('parses settings permalinks from the same app origin only', () => { + it('parses settings links from the same app origin only', () => { expect( - parseSettingsPermalink( + parseSettingsLink( 'https://app.example', 'https://app.example/settings/appearance?focus=message-link-preview' ) ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); expect( - parseSettingsPermalink( + parseSettingsLink( 'https://app.example', 'https://app.example/settings/appearance/?focus=message-link-preview' ) ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); expect( - parseSettingsPermalink('https://app.example', 'https://other.example/settings/appearance') - ).toBeUndefined(); - expect( - parseSettingsPermalink('https://app.example', 'https://app.example/home/') + parseSettingsLink('https://app.example', 'https://other.example/settings/appearance') ).toBeUndefined(); + expect(parseSettingsLink('https://app.example', 'https://app.example/home/')).toBeUndefined(); }); - it('rejects a same-origin hash permalink that does not match the configured app base', () => { + it('rejects a same-origin hash settings link that does not match the configured app base', () => { expect( - parseSettingsPermalink( + parseSettingsLink( 'https://app.example/#/app', 'https://app.example/#/wrong/settings/appearance?focus=message-link-preview' ) ).toBeUndefined(); }); - it('rejects a same-origin hash permalink that only shares the configured base as a prefix', () => { + it('rejects a same-origin hash settings link that only shares the configured base as a prefix', () => { expect( - parseSettingsPermalink( + parseSettingsLink( 'https://app.example/#/app', 'https://app.example/#/ap/settings/appearance?focus=message-link-preview' ) diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 0f2228c7f..da652d28a 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -2,7 +2,7 @@ import type { ClientConfig } from '$hooks/useClientConfig'; import { getAppPathFromHref, getSettingsPath, withOriginBaseUrl } from '$pages/pathUtils'; import { isSettingsSectionId, type SettingsSectionId } from './routes'; -export type SettingsPermalink = { +export type SettingsLink = { section: SettingsSectionId; focus?: string; }; @@ -38,16 +38,13 @@ export const getEffectiveSettingsLinkBaseUrl = ( ): string => normalizeSettingsLinkBaseUrl(override) ?? getConfiguredSettingsLinkBaseUrl(clientConfig); -export const buildSettingsPermalink = ( +export const buildSettingsLink = ( baseUrl: string, section: SettingsSectionId, focus?: string ): string => withOriginBaseUrl(baseUrl, getSettingsPath(section, focus)); -export const parseSettingsPermalink = ( - baseUrl: string, - href: string -): SettingsPermalink | undefined => { +export const parseSettingsLink = (baseUrl: string, href: string): SettingsLink | undefined => { try { const base = new URL(baseUrl); const target = new URL(href); diff --git a/src/app/hooks/useMentionClickHandler.test.tsx b/src/app/hooks/useMentionClickHandler.test.tsx index 55719bead..171e071c4 100644 --- a/src/app/hooks/useMentionClickHandler.test.tsx +++ b/src/app/hooks/useMentionClickHandler.test.tsx @@ -40,7 +40,7 @@ describe('useMentionClickHandler', () => { mockOpenSettings.mockReset(); }); - it('routes settings permalinks through openSettings with section and focus', () => { + it('routes settings links through openSettings with section and focus', () => { const { result } = renderHook(() => useMentionClickHandler('!room:example.org'), { wrapper: Wrapper, }); diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index d259204db..c3ff91d8b 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -28,7 +28,7 @@ function Subject({ body }: { body: string }) { } describe('react custom html parser', () => { - it('renders same-origin raw settings permalinks as mention-style chips through the factory link render path', () => { + it('renders same-origin raw settings links as mention-style chips through the factory link render path', () => { const renderLink = factoryRenderLinkifyWithMention( settingsLinkBaseUrl, () => undefined, @@ -56,7 +56,7 @@ describe('react custom html parser', () => { expect(link).not.toHaveTextContent('Settings:'); }); - it('renders same-origin settings permalinks as internal app links with settings metadata', () => { + it('renders same-origin settings links as internal app links with settings metadata', () => { render( ); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 3bbb6800e..117c316d4 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -36,7 +36,7 @@ import { findAndReplace } from '$utils/findAndReplace'; import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; -import { parseSettingsPermalink } from '$features/settings/settingsLink'; +import { parseSettingsLink } from '$features/settings/settingsLink'; import { settingsSections } from '$features/settings/routes'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { @@ -171,24 +171,24 @@ const settingsSectionLabel = Object.fromEntries( settingsSections.map((section) => [section.id, section.label]) ) as Record<(typeof settingsSections)[number]['id'], string>; -const humanizeSettingsPermalinkPart = (value: string): string => +const humanizeSettingsLinkPart = (value: string): string => value .split(/[^a-zA-Z0-9]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); -const getSettingsPermalinkLabel = ( +const getSettingsLinkLabel = ( section: keyof typeof settingsSectionLabel, focus?: string ): string => { const sectionLabel = settingsSectionLabel[section]; - const focusLabel = focus ? humanizeSettingsPermalinkPart(focus) : undefined; + const focusLabel = focus ? humanizeSettingsLinkPart(focus) : undefined; return focusLabel ? `${sectionLabel} / ${focusLabel}` : sectionLabel; }; -const getSettingsPermalinkChildren = ({ +const getSettingsLinkChildren = ({ href, section, focus, @@ -202,13 +202,13 @@ const getSettingsPermalinkChildren = ({ fallbackChildren?: ReactNode; }): ReactNode => { if (!content || content === href || content === safeDecodeUrl(href)) { - return getSettingsPermalinkLabel(section, focus); + return getSettingsLinkLabel(section, focus); } return fallbackChildren ?? content; }; -const renderSettingsPermalink = ({ +const renderSettingsLink = ({ href, section, focus, @@ -233,7 +233,7 @@ const renderSettingsPermalink = ({ - {getSettingsPermalinkChildren({ href, section, focus, content, fallbackChildren })} + {getSettingsLinkChildren({ href, section, focus, content, fallbackChildren })} ); @@ -256,10 +256,10 @@ export const factoryRenderLinkifyWithMention = ( } if (tagName === 'a' && decodedHref) { - const settingsPermalink = parseSettingsPermalink(settingsLinkBaseUrl, decodedHref); - if (settingsPermalink) { - const { section, focus } = settingsPermalink; - return renderSettingsPermalink({ + const settingsLink = parseSettingsLink(settingsLinkBaseUrl, decodedHref); + if (settingsLink) { + const { section, focus } = settingsLink; + return renderSettingsLink({ href: decodedHref, section, focus, @@ -603,13 +603,10 @@ export const getReactCustomHtmlParser = ( } if (decodedHref) { - const settingsPermalink = parseSettingsPermalink( - params.settingsLinkBaseUrl, - decodedHref - ); - if (settingsPermalink) { - const { section, focus } = settingsPermalink; - return renderSettingsPermalink({ + const settingsLink = parseSettingsLink(params.settingsLinkBaseUrl, decodedHref); + if (settingsLink) { + const { section, focus } = settingsLink; + return renderSettingsLink({ href: decodedHref, section, focus, From 9925b94f832dafc6f248ce63026bf899d899391e Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 28 Mar 2026 20:09:57 +0100 Subject: [PATCH 17/18] docs: add settings changesets --- .changeset/settings-links.md | 5 +++++ .changeset/settings-route-based-navigation.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/settings-links.md create mode 100644 .changeset/settings-route-based-navigation.md diff --git a/.changeset/settings-links.md b/.changeset/settings-links.md new file mode 100644 index 000000000..7e36f2cc7 --- /dev/null +++ b/.changeset/settings-links.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +You can now share direct links to specific settings, and opening one takes you to the right section and highlights the target option. diff --git a/.changeset/settings-route-based-navigation.md b/.changeset/settings-route-based-navigation.md new file mode 100644 index 000000000..ff0abe0cb --- /dev/null +++ b/.changeset/settings-route-based-navigation.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Settings now use route-based navigation with improved desktop and mobile behavior, including better back and close handling. From ae66ba6bec82f59c866873d5f75aaff8c16429f8 Mon Sep 17 00:00:00 2001 From: hazre Date: Tue, 31 Mar 2026 14:19:05 +0200 Subject: [PATCH 18/18] fix: hide desktop settings back button --- src/app/features/settings/Settings.tsx | 3 +-- src/app/features/settings/SettingsRoute.test.tsx | 11 +++++------ src/app/features/settings/SettingsSectionPage.tsx | 5 ++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 2d5faa186..796ff2b86 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -242,8 +242,7 @@ export function Settings({ setLegacyActivePage(SettingsPages.GeneralPage); }; - const shouldShowSectionBack = - visibleSection !== null && (screenSize === ScreenSize.Mobile || visibleSection !== 'general'); + const shouldShowSectionBack = visibleSection !== null && screenSize === ScreenSize.Mobile; const sectionRequestBack = shouldShowSectionBack ? handleRequestBack : undefined; return ( diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 35d2f8f44..e65c925bc 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -355,7 +355,7 @@ describe('SettingsSectionPage', () => { ).toEqual(['Back', 'Close']); }); - it('supports custom title semantics and close label', () => { + it('supports custom title semantics and close label without a desktop back button', () => { render( { ); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Keyboard Shortcuts'); - expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Close keyboard shortcuts' })).toBeInTheDocument(); }); @@ -720,18 +720,17 @@ describe('Settings shallow route shell', () => { expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); }); - it('returns to general settings when desktop section back is clicked', async () => { + it('does not show a desktop section back button in shallow settings', async () => { const user = userEvent.setup(); renderClientShell(ScreenSize.Desktop); await user.click(screen.getByRole('button', { name: 'Open settings' })); await user.click(screen.getByRole('button', { name: 'Devices' })); - await user.click(screen.getByRole('button', { name: 'Back' })); expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: 'Devices section' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Devices section' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); }); it('closes a desktop shallow settings flow in one step after switching sections', async () => { diff --git a/src/app/features/settings/SettingsSectionPage.tsx b/src/app/features/settings/SettingsSectionPage.tsx index 277e6c18a..ba1a662fe 100644 --- a/src/app/features/settings/SettingsSectionPage.tsx +++ b/src/app/features/settings/SettingsSectionPage.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import { Box, Icon, IconButton, Icons, Text } from 'folds'; import { Page, PageHeader } from '$components/page'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { settingsHeader } from './styles.css'; type SettingsSectionPageProps = { @@ -22,14 +23,16 @@ export function SettingsSectionPage({ actionLabel, children, }: SettingsSectionPageProps) { + const screenSize = useScreenSizeContext(); const closeLabel = actionLabel ?? 'Close'; + const showBack = screenSize === ScreenSize.Mobile && requestBack; return ( - {requestBack && ( + {showBack && (