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. 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..d795542fa --- /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 links', () => { + 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 56517903b..cc07d5082 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 { parseSettingsLink } 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', @@ -102,7 +105,9 @@ function RenderMessageContentInternal({ const renderUrlsPreview = useCallback( (urls: string[]) => { - const filteredUrls = urls.filter((url) => !testMatrixTo(url)); + const filteredUrls = urls.filter( + (url) => !testMatrixTo(url) && !parseSettingsLink(settingsLinkBaseUrl, url) + ); if (filteredUrls.length === 0) return undefined; const analyzed = filteredUrls.map((url) => ({ @@ -130,7 +135,7 @@ function RenderMessageContentInternal({ ); }, - [ts, clientUrlPreview, urlPreview] + [ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview] ); const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined; 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.test.tsx b/src/app/components/message/Reply.test.tsx index aa5c19fb5..1d57a4645 100644 --- a/src/app/components/message/Reply.test.tsx +++ b/src/app/components/message/Reply.test.tsx @@ -60,6 +60,10 @@ vi.mock('$hooks/useMentionClickHandler', () => ({ useMentionClickHandler: () => undefined, })); +vi.mock('$features/settings/useSettingsLinkBaseUrl', () => ({ + useSettingsLinkBaseUrl: () => 'https://app.sable.moe', +})); + vi.mock('$utils/room', async (importActual) => { const actual = await importActual(); return { diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index cf1786ca8..331ef043a 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -28,6 +28,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, @@ -134,6 +135,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 ? : ; @@ -161,22 +163,26 @@ 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 (isFormattedReply && formattedBody !== '') { const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody); 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..000842c6b --- /dev/null +++ b/src/app/components/setting-tile/SettingTile.css.ts @@ -0,0 +1,65 @@ +import { style } from '@vanilla-extract/css'; + +export const settingTileRoot = style({ + minWidth: 0, +}); + +export const settingTileTitleRow = style({ + minWidth: 0, +}); + +const settingLinkActionBase = style({}); + +export const settingTileSettingLinkActionTransparentBackground = style({ + backgroundColor: 'transparent', + selectors: { + '&[aria-pressed=true]': { + backgroundColor: 'transparent', + }, + '&:hover': { + backgroundColor: 'transparent', + }, + '&:focus-visible': { + backgroundColor: 'transparent', + }, + '&:active': { + backgroundColor: 'transparent', + }, + }, +}); + +export const settingTileSettingLinkAction = style([ + settingLinkActionBase, + { + minWidth: 0, + minHeight: 0, + width: 'auto', + height: 'auto', + padding: 0, + }, +]); + +export const settingTileSettingLinkActionDesktopHidden = style([ + settingLinkActionBase, + { + opacity: 0, + pointerEvents: 'none', + selectors: { + [`${settingTileRoot}:hover &`]: { + opacity: 1, + pointerEvents: 'auto', + }, + [`${settingTileRoot}:focus-within &`]: { + opacity: 1, + pointerEvents: 'auto', + }, + }, + }, +]); + +export const settingTileSettingLinkActionMobileVisible = style([ + settingLinkActionBase, + { + 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..da5d25c10 --- /dev/null +++ b/src/app/components/setting-tile/SettingTile.test.tsx @@ -0,0 +1,112 @@ +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 { SettingsLinkProvider } from '$features/settings/SettingsLinkContext'; +import { SettingTile } from './SettingTile'; +import { + settingTileSettingLinkActionDesktopHidden, + settingTileSettingLinkActionMobileVisible, + settingTileSettingLinkActionTransparentBackground, +} from './SettingTile.css'; + +const writeText = vi.fn(); + +function renderTile( + screenSize: ScreenSize, + focusId?: string, + options?: Partial> +) { + return render( + + + + + + + + ); +} + +beforeEach(() => { + writeText.mockReset(); + vi.stubGlobal('navigator', { clipboard: { writeText } } as unknown as Navigator); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('SettingTile', () => { + it('copies the real settings link when a focus id is present', async () => { + writeText.mockResolvedValueOnce(undefined); + + renderTile(ScreenSize.Desktop, 'message-link-preview'); + + fireEvent.click(screen.getByRole('button', { name: /copy settings link/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'https://settings.example/settings/appearance?focus=message-link-preview' + ); + }); + expect(screen.getByRole('button', { name: /copied settings link/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 link/i })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'https://settings.example/settings/appearance?focus=message-link-preview' + ); + }); + expect(screen.getByRole('button', { name: /copy settings link/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /copied settings link/i })).not.toBeInTheDocument(); + }); + + it('does not render a copy button without a focus id', () => { + renderTile(ScreenSize.Desktop); + + expect(screen.queryByRole('button', { name: /copy settings link/i })).not.toBeInTheDocument(); + }); + + it('does not render a copy button when setting link actions are disabled', () => { + renderTile(ScreenSize.Desktop, 'message-link-preview', { + showSettingLinkAction: false, + }); + + expect(screen.queryByRole('button', { name: /copy settings link/i })).not.toBeInTheDocument(); + }); + + it('uses the desktop hidden-until-hover class for the setting link action', () => { + renderTile(ScreenSize.Desktop, 'message-link-preview'); + + expect(screen.getByText('Appearance').parentElement).toContainElement( + screen.getByRole('button', { name: /copy settings link/i }) + ); + expect(screen.getByRole('button', { name: /copy settings link/i })).toHaveClass( + settingTileSettingLinkActionTransparentBackground, + { + exact: false, + } + ); + expect(screen.getByRole('button', { name: /copy settings link/i })).toHaveClass( + settingTileSettingLinkActionDesktopHidden + ); + }); + + it('uses the mobile always-visible class for the setting link action', () => { + renderTile(ScreenSize.Mobile, 'message-link-preview'); + + expect(screen.getByRole('button', { name: /copy settings link/i })).toHaveClass( + settingTileSettingLinkActionMobileVisible + ); + }); +}); diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index e57dccd55..9ee2696dc 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -1,23 +1,120 @@ import { ReactNode } from 'react'; -import { Box, Text } from 'folds'; +import { Box, Icon, IconButton, Icons, Text } from 'folds'; import { BreakWord } from '$styles/Text.css'; +import { buildSettingsLink } from '$features/settings/settingsLink'; +import { copyToClipboard } from '$utils/dom'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { useSettingsLinkContext } from '$features/settings/SettingsLinkContext'; +import { type SettingsSectionId } from '$features/settings/routes'; +import { + settingTileSettingLinkAction, + settingTileSettingLinkActionDesktopHidden, + settingTileSettingLinkActionMobileVisible, + settingTileSettingLinkActionTransparentBackground, + settingTileRoot, + settingTileTitleRow, +} from './SettingTile.css'; type SettingTileProps = { + focusId?: string; + showSettingLinkAction?: boolean; + className?: string; title?: ReactNode; description?: ReactNode; before?: ReactNode; after?: ReactNode; children?: ReactNode; }; -export function SettingTile({ title, description, before, after, children }: SettingTileProps) { + +function SettingTileSettingLinkAction({ + baseUrl, + section, + focusId, +}: { + baseUrl: string; + section: SettingsSectionId; + focusId: string; +}) { + const screenSize = useScreenSizeContext(); + const [copied, setCopied] = useTimeoutToggle(); + const copyPath = buildSettingsLink(baseUrl, section, focusId); + + return ( + { + if (await copyToClipboard(copyPath)) setCopied(); + }} + size="300" + variant="Surface" + fill="None" + radii="Inherit" + > + + + ); +} + +export function SettingTile({ + focusId, + showSettingLinkAction = true, + className, + title, + description, + before, + after, + children, +}: SettingTileProps) { + const settingsLink = useSettingsLinkContext(); + const copyAction = + settingsLink && focusId && showSettingLinkAction ? ( + + ) : 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 && ( @@ -26,7 +123,7 @@ export function SettingTile({ title, description, before, after, children }: Set )} {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 fb4b33515..c37c6d385 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'; @@ -179,6 +180,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); @@ -490,17 +492,20 @@ 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 abbrMap = useRoomAbbreviationsContext(); @@ -508,6 +513,7 @@ export function RoomTimeline({ const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication: mediaAuthentication, handleSpoilerClick: spoilerClickHandler, @@ -525,6 +531,7 @@ export function RoomTimeline({ nicknames, mediaAuthentication, spoilerClickHandler, + settingsLinkBaseUrl, abbrMap, ] ); 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 b662d2ab3..73b6e7787 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'; @@ -369,6 +370,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 @@ -383,17 +385,20 @@ 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 abbrMap = useRoomAbbreviationsContext(); @@ -401,6 +406,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { + settingsLinkBaseUrl, linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, @@ -416,6 +422,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra mentionClickHandler, useAuthentication, nicknames, + settingsLinkBaseUrl, abbrMap, ] ); diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index ad25ba45f..e30ebe1b5 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/PKCompat.tsx b/src/app/features/settings/Persona/PKCompat.tsx index f69644df0..8d55eaec1 100644 --- a/src/app/features/settings/Persona/PKCompat.tsx +++ b/src/app/features/settings/Persona/PKCompat.tsx @@ -19,6 +19,7 @@ export function PKCompatSettings() { gap="100" > void; requestClose: () => void; }; -export function PerMessageProfilePage({ requestClose }: PerMessageProfilePageProps) { +export function PerMessageProfilePage({ requestBack, requestClose }: PerMessageProfilePageProps) { return ( - - - - - - Persona - - - - - - - - - + - + ); } diff --git a/src/app/features/settings/Settings.test.tsx b/src/app/features/settings/Settings.test.tsx new file mode 100644 index 000000000..0ad55b17b --- /dev/null +++ b/src/app/features/settings/Settings.test.tsx @@ -0,0 +1,190 @@ +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 { SettingTile } from '$components/setting-tile'; +import { Settings } from './Settings'; + +const writeText = vi.fn(); + +const { mockMatrixClient, mockProfile } = vi.hoisted(() => ({ + 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 links', async () => { + writeText.mockResolvedValueOnce(undefined); + + render( + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /copy settings link/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 link/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 2dd1e4ae4..796ff2b86 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -28,17 +28,22 @@ 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 { settingsHeader } from './styles.css'; +import { useSettingsFocus } from './useSettingsFocus'; +import { SettingsLinkProvider } from './SettingsLinkContext'; +import { useSettingsLinkBaseUrl } from './useSettingsLinkBaseUrl'; export enum SettingsPages { GeneralPage, @@ -55,84 +60,103 @@ 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: { requestBack?: () => void; 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; + onBack?: () => void; requestClose: () => void; + initialPage?: SettingsPages; }; -export function Settings({ initialPage, requestClose }: SettingsProps) { + +function SettingsSectionViewport({ + section, + requestBack, + requestClose, +}: { + section: SettingsSectionId; + requestBack?: () => void; + requestClose: () => void; +}) { + useSettingsFocus(); + const Section = settingsSectionComponents[section]; + return
; +} + +export function Settings({ + activeSection, + onSelectSection, + onBack, + requestClose, + initialPage, +}: ControlledSettingsProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const userId = mx.getUserId()!; @@ -143,9 +167,11 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { : undefined; const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); - + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); const screenSize = useScreenSizeContext(); - const [activePage, setActivePage] = useState(() => { + const isControlled = activeSection !== undefined; + + const [legacyActivePage, setLegacyActivePage] = useState(() => { if (initialPage === SettingsPages.PerMessageProfilesPage && !showPersona) { return SettingsPages.GeneralPage; } @@ -153,22 +179,78 @@ 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(); }; + const handleRequestBack = () => { + if (isControlled) { + onBack?.(); + return; + } + + if (screenSize === ScreenSize.Mobile) { + setLegacyActivePage(undefined); + return; + } + + setLegacyActivePage(SettingsPages.GeneralPage); + }; + + const shouldShowSectionBack = visibleSection !== null && screenSize === ScreenSize.Mobile; + const sectionRequestBack = shouldShowSectionBack ? handleRequestBack : undefined; + return ( - + - {screenSize === ScreenSize.Mobile && ( - + {visibleSection === null && ( + )} @@ -194,25 +280,29 @@ 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)} > {item.name} @@ -260,36 +350,14 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { ) } > - {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/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/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx new file mode 100644 index 000000000..e65c925bc --- /dev/null +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -0,0 +1,858 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactNode, useState } from 'react'; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, + 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({ + requestBack, + requestClose, + }: { + requestBack?: () => void; + requestClose: () => void; + }) { + return ( +
+

{title}

+ {requestBack && ( + + )} + +
+ ); + }; + + 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('$components/Modal500', () => ({ + Modal500: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('./general', () => ({ + General: ({ + requestBack, + requestClose, + }: { + requestBack?: () => void; + requestClose: () => void; + }) => ( +
+

General section

+ + Message Layout + + {requestBack && ( + + )} + +
+ ), +})); + +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(); + + return ( +
+ + focus target + +
+ ); +} + +function FocusFixtureToggle() { + const [visible, setVisible] = useState(true); + + return ( +
+ + {visible && } +
+ ); +} + +function LocationProbe() { + const location = useLocation(); + const navigationType = useNavigationType(); + return ( +
+ {location.pathname} + {location.search} + {navigationType} +
+ ); +} + +function HomePage() { + const navigate = useNavigate(); + const location = useLocation(); + + return ( +
+

Home route

+ +
+ ); +} + +function OpenSettingsHomePage() { + const openSettings = useOpenSettings(); + + return ( +
+

Home route

+ + +
+ ); +} + +function renderClientShell( + screenSize: ScreenSize, + options?: { initialEntries?: RouterInitialEntry[]; initialIndex?: number } +) { + const initialEntries = options?.initialEntries ?? [getHomePath()]; + return render( + + + + + + }> + } /> + } /> + + + + + + + ); +} + +function SidebarSettingsShortcut() { + const openSettings = useOpenSettings(); + + return ( + + ); +} + +function renderClientShellWithOpenSettings( + screenSize: ScreenSize, + 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?: RouterInitialEntry[]; initialIndex?: number } +) { + const initialEntries = options?.initialEntries ?? [path]; + return render( + + + + + + } /> + + + + + ); +} + +describe('SettingsSectionPage', () => { + 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.getAllByRole('button').map((button) => button.getAttribute('aria-label')) + ).toEqual(['Back', 'Close']); + }); + + 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.queryByRole('button', { name: 'Back' })).not.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', () => { + 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('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); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('general')) + ); + 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(); + + renderSettingsRoute('/settings', ScreenSize.Desktop); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath('general')) + ); + + 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); + + await waitFor(() => + expect(screen.getByTestId('location-probe')).toHaveTextContent(getSettingsPath()) + ); + 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 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(); + + 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(); + + 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('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(); + + renderSettingsRoute('/settings/devices', ScreenSize.Mobile, { + initialEntries: [getSettingsPath(), getSettingsPath('devices')], + initialIndex: 1, + }); + + await user.click(screen.getByRole('button', { name: 'Back' })); + + await waitFor(() => expect(screen.getByTestId('location-probe')).toHaveTextContent('POP')); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); +}); + +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(); + + 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('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(); + + 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('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('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' })); + + expect(screen.getByRole('heading', { name: 'Home route' })).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 () => { + 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( + 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')], + }); + + 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', () => { + it('highlights a focus target from the query string', async () => { + vi.useFakeTimers(); + + try { + render( + + + + + + + ); + + const target = document.querySelector('[data-settings-focus="message-link-preview"]'); + 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(highlightTarget).toHaveClass(focusedSettingTile); + expect(highlightTarget).toHaveClass(messageJumpHighlight); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + 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 new file mode 100644 index 000000000..b23fa24c5 --- /dev/null +++ b/src/app/features/settings/SettingsRoute.tsx @@ -0,0 +1,152 @@ +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 { trimTrailingSlash } from '$utils/common'; +import { getSettingsCloseTarget, type SettingsRouteState } from './navigation'; +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; +} + +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(); + const [showPersona] = useSetting(settingsAtom, 'showPersonaSetting'); + 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 + : null; + const hasPreviousEntry = + (typeof browserHistoryIndex === 'number' && browserHistoryIndex > 0) || + location.key !== 'default'; + + const activeSection = resolveSettingsSection(section, screenSize, showPersona); + const shouldRedirectToGeneral = section === undefined && screenSize !== ScreenSize.Mobile; + 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, + state: routeState?.backgroundLocation ? routeState : { redirectedFromDesktopRoot: true }, + }); + return; + } + + if (!shouldRedirectToIndex) return; + + navigate(getSettingsPath(), { replace: true, state: routeState }); + }, [ + canonicalPathname, + location.hash, + location.search, + navigate, + routeState, + shouldCanonicalizeTrailingSlash, + shouldRedirectToGeneral, + shouldRedirectToIndex, + ]); + + if (shouldCanonicalizeTrailingSlash || shouldRedirectToGeneral || shouldRedirectToIndex) { + return null; + } + + const requestBack = () => { + if (section === undefined) return; + + if (screenSize === ScreenSize.Mobile) { + if (hasPreviousEntry) { + navigate(-1); + return; + } + + navigate(getSettingsPath(), { + replace: true, + state: routeState?.backgroundLocation ? routeState : undefined, + }); + return; + } + + let desktopBackState: SettingsRouteState | undefined; + if (routeState?.backgroundLocation) { + desktopBackState = routeState; + } else if (routeState?.redirectedFromDesktopRoot) { + desktopBackState = { redirectedFromDesktopRoot: true }; + } + + 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) => { + if (nextSection === activeSection) return; + + navigate(getSettingsPath(nextSection), { + replace: shallowBackgroundState, + state: location.state, + }); + }; + + return ( + + ); +} diff --git a/src/app/features/settings/SettingsSectionPage.tsx b/src/app/features/settings/SettingsSectionPage.tsx new file mode 100644 index 000000000..ba1a662fe --- /dev/null +++ b/src/app/features/settings/SettingsSectionPage.tsx @@ -0,0 +1,54 @@ +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 closeLabel = actionLabel ?? 'Close'; + const showBack = screenSize === ScreenSize.Mobile && requestBack; + + return ( + + + + + {showBack && ( + + + + )} + + {title} + + + + + + + + + + {children} + + ); +} diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx new file mode 100644 index 000000000..2fd7053ce --- /dev/null +++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx @@ -0,0 +1,30 @@ +import { matchPath, useLocation, useNavigate } 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 { 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; + const routeMatch = matchPath(SETTINGS_PATH, location.pathname); + + if (!isShallowSettingsRoute(location.pathname, location.state, screenSize) || !routeMatch) { + return null; + } + + const handleRequestClose = () => { + const closeTarget = getSettingsCloseTarget(routeState); + navigate(closeTarget.to, { replace: true, state: closeTarget.state }); + }; + + return ( + + + + ); +} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 44f42c8f6..fdd3d3834 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(); @@ -50,7 +51,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + {mx.baseUrl} @@ -76,6 +82,7 @@ export function HomeserverInfo() { > {federationUrl} @@ -103,7 +110,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + )} {version.server?.version && ( @@ -113,7 +124,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + )} {version.server?.compiler && ( @@ -123,7 +138,11 @@ export function HomeserverInfo() { direction="Column" gap="400" > - + )} @@ -142,30 +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 ( - - - - - - About - - - - - - - - - + @@ -227,6 +233,7 @@ 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..7581014c8 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -1,30 +1,18 @@ -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'; import { IgnoredUserList } from './IgnoredUserList'; type AccountProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Account({ requestClose }: AccountProps) { +export function Account({ requestBack, requestClose }: AccountProps) { return ( - - - - - - Account - - - - - - - - - + @@ -37,6 +25,6 @@ export function Account({ requestClose }: AccountProps) { - + ); } 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 7e44bf480..3d50d886e 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'; @@ -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,26 +253,13 @@ function IdentityCosmetics() { } type CosmeticsProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Cosmetics({ requestClose }: CosmeticsProps) { +export function Cosmetics({ requestBack, requestClose }: CosmeticsProps) { return ( - - - - - - Appearance - - - - - - - - - + @@ -275,6 +273,6 @@ export function Cosmetics({ requestClose }: CosmeticsProps) { - + ); } 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() { > } /> @@ -295,6 +301,7 @@ function ThemeSettings() { > } /> @@ -304,6 +311,7 @@ function ThemeSettings() { } /> @@ -312,6 +320,7 @@ function ThemeSettings() { } /> @@ -344,6 +354,7 @@ function ThemeSettings() { } /> @@ -351,6 +362,7 @@ function ThemeSettings() { } /> @@ -358,6 +370,7 @@ function ThemeSettings() { @@ -367,6 +380,7 @@ function ThemeSettings() { } /> @@ -480,6 +494,7 @@ export function Appearance() { } /> @@ -488,18 +503,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); @@ -48,21 +50,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { } return ( - - - - - - Developer Tools - - - - - - - - - + @@ -77,6 +69,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { > - + ); } diff --git a/src/app/features/settings/developer-tools/SentrySettings.tsx b/src/app/features/settings/developer-tools/SentrySettings.tsx index b0425e76f..75bf09c45 100644 --- a/src/app/features/settings/developer-tools/SentrySettings.tsx +++ b/src/app/features/settings/developer-tools/SentrySettings.tsx @@ -3,6 +3,7 @@ import { Box, Text, Switch, Button } from 'folds'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; import { getDebugLogger, LogCategory } from '$utils/debugLogger'; const ALL_CATEGORIES: LogCategory[] = [ @@ -87,14 +88,17 @@ export function SentrySettings() { > @@ -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..385f70965 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,8 @@ 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(); @@ -61,21 +63,7 @@ export function Devices({ requestClose }: DevicesProps) { ); return ( - - - - - - Devices - - - - - - - - - + @@ -90,6 +78,7 @@ export function Devices({ requestClose }: DevicesProps) { > @@ -156,6 +145,6 @@ 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 = () => { @@ -21,21 +23,11 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { } return ( - - - - - - Emojis & Stickers - - - - - - - - - + @@ -46,6 +38,6 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + ); } 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 9c3a1c40f..330412185 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'; @@ -22,6 +23,7 @@ function PersonaToggle() { @@ -33,25 +35,12 @@ function PersonaToggle() { } type ExperimentalProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Experimental({ requestClose }: Readonly) { +export function Experimental({ requestBack, requestClose }: Readonly) { return ( - - - - - - Experimental - - - - - - - - - + @@ -77,6 +66,6 @@ export function Experimental({ requestClose }: Readonly) { - + ); } 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 ( - + } /> @@ -406,6 +409,7 @@ function DateAndTime() { } /> @@ -436,6 +440,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { > ) { } /> } /> @@ -463,6 +470,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> @@ -470,6 +478,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { } /> @@ -477,6 +486,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { @@ -770,6 +780,7 @@ function Gestures({ isMobile }: Readonly<{ isMobile: boolean }>) { ) { } /> @@ -847,6 +859,7 @@ function Calls() { Messages - } /> + } + /> - } /> + } + /> - } /> + } + /> } /> @@ -915,6 +941,7 @@ function Messages() { } /> @@ -923,6 +950,7 @@ function Messages() { } /> } /> + + + @@ -1159,6 +1201,7 @@ export function Sync() { } type GeneralProps = { + requestBack?: () => void; requestClose: () => void; }; @@ -1200,11 +1243,16 @@ function SettingsSyncSection() { > } /> {syncEnabled && ( - + )} @@ -1293,6 +1341,7 @@ function DiagnosticsAndPrivacy() { > ) { +export function General({ requestBack, requestClose }: Readonly) { return ( - - - - - - General - - - - - - - - - + @@ -1372,6 +1408,6 @@ export function General({ requestClose }: Readonly) { - + ); } 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..6bc4a8518 --- /dev/null +++ b/src/app/features/settings/general/SettingsLinkBaseUrlSetting.test.tsx @@ -0,0 +1,77 @@ +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('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'); + + 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..deb6b760b --- /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 90e269730..c19de644a 100644 --- a/src/app/features/settings/index.ts +++ b/src/app/features/settings/index.ts @@ -1 +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 00c4c528b..a0b52da48 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; @@ -105,29 +106,18 @@ function ShortcutRow({ keys, description }: ShortcutEntry) { } type KeyboardShortcutsProps = { + requestBack?: () => void; requestClose: () => void; }; -export function KeyboardShortcuts({ requestClose }: KeyboardShortcutsProps) { +export function KeyboardShortcuts({ requestBack, requestClose }: KeyboardShortcutsProps) { return ( - - - - - - Keyboard Shortcuts - - - - - - - - - + @@ -153,6 +143,6 @@ export function KeyboardShortcuts({ requestClose }: KeyboardShortcutsProps) { - + ); } 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 c66ddea30..ab7f24a8d 100644 --- a/src/app/features/settings/notifications/AllMessages.tsx +++ b/src/app/features/settings/notifications/AllMessages.tsx @@ -121,6 +121,7 @@ export function AllMessagesNotifications() { > } /> @@ -133,6 +134,7 @@ export function AllMessagesNotifications() { > } /> @@ -163,6 +166,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 4c7ab55f0..7eab66eb7 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -7,6 +7,7 @@ import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { SettingMenuSelector } from '$components/setting-menu-selector'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; import { getNotificationModeActions, NotificationMode, @@ -194,6 +195,7 @@ export function KeywordMessagesNotifications() { > @@ -209,6 +211,9 @@ export function KeywordMessagesNotifications() { > } after={} /> diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index f8935b782..b161a357b 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -1,30 +1,22 @@ -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'; import { KeywordMessagesNotifications } from './KeywordMessages'; type NotificationsProps = { + requestBack?: () => void; requestClose: () => void; }; -export function Notifications({ requestClose }: NotificationsProps) { +export function Notifications({ requestBack, requestClose }: NotificationsProps) { return ( - - - - - - Notifications - - - - - - - - - + @@ -37,6 +29,6 @@ export function Notifications({ requestClose }: NotificationsProps) { - + ); } diff --git a/src/app/features/settings/notifications/SpecialMessages.tsx b/src/app/features/settings/notifications/SpecialMessages.tsx index 261ea97c2..5b7389548 100644 --- a/src/app/features/settings/notifications/SpecialMessages.tsx +++ b/src/app/features/settings/notifications/SpecialMessages.tsx @@ -158,6 +158,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/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/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..cb695e28f --- /dev/null +++ b/src/app/features/settings/settingsLink.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SETTINGS_LINK_BASE_URL, + buildSettingsLink, + getEffectiveSettingsLinkBaseUrl, + parseSettingsLink, + toSettingsFocusIdPart, +} from './settingsLink'; + +describe('settingsLink', () => { + 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( + buildSettingsLink('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 links from the same app origin only', () => { + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/appearance?focus=message-link-preview' + ) + ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); + expect( + parseSettingsLink( + 'https://app.example', + 'https://app.example/settings/appearance/?focus=message-link-preview' + ) + ).toEqual({ section: 'appearance', focus: 'message-link-preview' }); + + expect( + 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 settings link that does not match the configured app base', () => { + expect( + parseSettingsLink( + 'https://app.example/#/app', + 'https://app.example/#/wrong/settings/appearance?focus=message-link-preview' + ) + ).toBeUndefined(); + }); + + it('rejects a same-origin hash settings link that only shares the configured base as a prefix', () => { + expect( + parseSettingsLink( + '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..da652d28a --- /dev/null +++ b/src/app/features/settings/settingsLink.ts @@ -0,0 +1,83 @@ +import type { ClientConfig } from '$hooks/useClientConfig'; +import { getAppPathFromHref, getSettingsPath, withOriginBaseUrl } from '$pages/pathUtils'; +import { isSettingsSectionId, type SettingsSectionId } from './routes'; + +export type SettingsLink = { + 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 buildSettingsLink = ( + baseUrl: string, + section: SettingsSectionId, + focus?: string +): string => withOriginBaseUrl(baseUrl, getSettingsPath(section, focus)); + +export const parseSettingsLink = (baseUrl: string, href: string): SettingsLink | 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 ce89c16ee..23067658a 100644 --- a/src/app/features/settings/styles.css.ts +++ b/src/app/features/settings/styles.css.ts @@ -1,6 +1,14 @@ 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, }); + +export const settingsHeader = style({ + paddingLeft: config.space.S300, + paddingRight: config.space.S200, +}); + +export const focusedSettingTile = messageJumpHighlight; diff --git a/src/app/features/settings/useOpenSettings.ts b/src/app/features/settings/useOpenSettings.ts new file mode 100644 index 000000000..9c38bb2a8 --- /dev/null +++ b/src/app/features/settings/useOpenSettings.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; +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() { + const navigate = useNavigate(); + const location = useLocation(); + + return useCallback( + (section?: SettingsSectionId, focus?: string) => { + const settingsState = matchPath(SETTINGS_PATH, location.pathname) + ? undefined + : { backgroundLocation: location }; + + navigate(getSettingsPath(section, focus), { + state: settingsState, + }); + }, + [location, navigate] + ); +} diff --git a/src/app/features/settings/useSettingsFocus.ts b/src/app/features/settings/useSettingsFocus.ts new file mode 100644 index 000000000..8edfaa020 --- /dev/null +++ b/src/app/features/settings/useSettingsFocus.ts @@ -0,0 +1,99 @@ +import { useEffect, useRef } from 'react'; +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 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); + + useEffect( + () => () => { + if (timeoutRef.current !== undefined) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + } + activeTargetRef.current?.classList.remove(...focusedSettingTileClasses); + activeTargetRef.current = null; + }, + [] + ); + + useEffect(() => { + if (!focusId || !focusNavigationKey || handledFocusNavigationKey === focusNavigationKey) { + return; + } + + const target = + document.getElementById(focusId) ?? + document.querySelector(`[data-settings-focus="${focusId}"]`); + + if (!target) return; + + 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; + } + + target.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + highlightTarget.classList.add(...focusedSettingTileClasses); + activeTargetRef.current = highlightTarget; + + 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, + }, + } + ); + + timeoutRef.current = window.setTimeout(() => { + highlightTarget.classList.remove(...focusedSettingTileClasses); + if (activeTargetRef.current === highlightTarget) { + activeTargetRef.current = null; + } + timeoutRef.current = undefined; + }, 3000); + }, [ + focusId, + focusNavigationKey, + handledFocusNavigationKey, + location.hash, + location.pathname, + location.search, + location.state, + navigate, + ]); +} 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..171e071c4 --- /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 links 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/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) } /> } /> + } /> - {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/ClientRouteOutlet.tsx b/src/app/pages/client/ClientRouteOutlet.tsx new file mode 100644 index 000000000..83fc2d24b --- /dev/null +++ b/src/app/pages/client/ClientRouteOutlet.tsx @@ -0,0 +1,35 @@ +import { useRef } from 'react'; +import { matchPath, useLocation, useOutlet } from 'react-router-dom'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { SETTINGS_PATH } from '../paths'; + +type BackgroundLocationState = { + backgroundLocation?: unknown; +}; + +export const isShallowSettingsRoute = ( + pathname: string, + state: unknown, + screenSize: ScreenSize +): boolean => { + 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/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() { > (); 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 && ( - - - - )} ); } diff --git a/src/app/pages/pathUtils.test.ts b/src/app/pages/pathUtils.test.ts new file mode 100644 index 000000000..141ee990e --- /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..120a1a70c 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -20,11 +20,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 +160,11 @@ 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?: string, focus?: string): string => { + const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); + 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?/'; diff --git a/src/app/pages/reactQueryDevtoolsGate.test.ts b/src/app/pages/reactQueryDevtoolsGate.test.ts new file mode 100644 index 000000000..b55eb0ab7 --- /dev/null +++ b/src/app/pages/reactQueryDevtoolsGate.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + isReactQueryDevtoolsEnabled, + REACT_QUERY_DEVTOOLS_LOCAL_STORAGE_KEY, +} from './reactQueryDevtoolsGate'; + +describe('reactQueryDevtoolsGate', () => { + 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 index 3a36ffe36..09a85ac1e 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -1,9 +1,17 @@ import { render, screen } from '@testing-library/react'; import parse from 'html-react-parser'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as css from '$styles/CustomHtml.css'; +import * as customHtmlCss from '$styles/CustomHtml.css'; import { sanitizeCustomHtml } from '$utils/sanitize'; -import { getReactCustomHtmlParser, LINKIFY_OPTS } from './react-custom-html-parser'; +import { + LINKIFY_OPTS, + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + makeMentionCustomProps, + renderMatrixMention, +} from './react-custom-html-parser'; + +const settingsLinkBaseUrl = 'https://app.example'; const { CodeHighlightRenderer } = vi.hoisted(() => ({ CodeHighlightRenderer: vi.fn( @@ -31,24 +39,26 @@ vi.mock('$components/code-highlight', () => ({ CodeHighlightRenderer, })); -function createMatrixClient() { - return { +const createMatrixClient = (overrides: Record = {}) => + ({ + getUserId: () => '@alice:example.org', getRoom: () => undefined, - getUserId: () => '@me:example.com', mxcUrlToHttp: () => null, - } as any; -} + ...overrides, + }) as never; function renderParsedHtml( html: string, options: { sanitize?: boolean; - mx?: any; + mx?: ReturnType; } = {} ) { const { sanitize = true, mx = createMatrixClient() } = options; const parserOptions = getReactCustomHtmlParser(mx, '!room:example.com', { + settingsLinkBaseUrl, linkifyOpts: LINKIFY_OPTS, + handleMentionClick: undefined, }); return render(
{parse(sanitize ? sanitizeCustomHtml(html) : html, parserOptions)}
); @@ -76,7 +86,9 @@ describe('getReactCustomHtmlParser code blocks', () => { expect(arboriumCode).toHaveTextContent('fn main()'); expect(arboriumCode).toHaveAttribute('data-language', 'rust'); expect(arboriumCode).toHaveAttribute('data-allow-detect', 'false'); - expect(container.querySelector('#code-block-content')).toHaveClass(css.CodeBlockInternal); + expect(container.querySelector('#code-block-content')).toHaveClass( + customHtmlCss.CodeBlockInternal + ); expect(CodeHighlightRenderer).toHaveBeenCalledWith( expect.objectContaining({ code: expect.stringContaining('let fifteenth = 15;'), @@ -122,7 +134,75 @@ describe('getReactCustomHtmlParser code blocks', () => { }); }); -describe('getReactCustomHtmlParser', () => { +describe('react custom html parser', () => { + it('renders same-origin raw settings links 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).not.toHaveTextContent('Settings:'); + expect(link.className).toContain(customHtmlCss.MentionWithIcon); + }); + + it('renders same-origin settings links as internal app links with settings metadata', () => { + renderParsedHtml( + 'Appearance', + { sanitize: false } + ); + + 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.MentionWithIcon); + }); + + it('renders matrix message permalinks with an icon instead of the Message prefix', () => { + render( +
+ {renderMatrixMention( + createMatrixClient({ + getRoom: () => ({ roomId: '!room:example.org', name: 'Lobby' }), + }), + 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.MentionWithIcon); + expect(link).not.toHaveTextContent('Message:'); + expect(link.querySelector('[aria-hidden="true"]')).not.toBeNull(); + }); + it('translates Matrix color data attributes into rendered styles', () => { renderParsedHtml('colored'); @@ -150,7 +230,8 @@ describe('getReactCustomHtmlParser', () => { it('renders a readable fallback for unresolved legacy emote MXC images', () => { const { container } = renderParsedHtml( - 'blobcat' + 'blobcat', + { sanitize: false } ); expect(screen.getByText(':blobcat:')).toBeInTheDocument(); @@ -159,7 +240,8 @@ describe('getReactCustomHtmlParser', () => { it('renders a readable fallback for unresolved non-emote MXC images', () => { const { container } = renderParsedHtml( - 'media' + 'media', + { sanitize: false } ); expect(screen.getByText('media')).toBeInTheDocument(); @@ -169,7 +251,9 @@ describe('getReactCustomHtmlParser', () => { it('renders unresolved MXC fallbacks without emitting debug logs', () => { const logSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - renderParsedHtml('media'); + renderParsedHtml('media', { + sanitize: false, + }); expect(logSpy).not.toHaveBeenCalled(); }); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 9a7c78f97..c9749bd5a 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -35,6 +35,8 @@ import { onEnterOrSpace } from '$utils/keyboard'; import { copyToClipboard } from '$utils/dom'; import { isMatrixHexColor } from '$utils/matrixHtml'; import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; +import { parseSettingsLink } from '$features/settings/settingsLink'; +import { settingsSections } from '$features/settings/routes'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { CodeHighlightRenderer } from '$components/code-highlight'; import { @@ -172,21 +174,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} ); } @@ -194,8 +201,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 humanizeSettingsLinkPart = (value: string): string => + value + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +const getSettingsLinkLabel = ( + section: keyof typeof settingsSectionLabel, + focus?: string +): string => { + const sectionLabel = settingsSectionLabel[section]; + const focusLabel = focus ? humanizeSettingsLinkPart(focus) : undefined; + + return focusLabel ? `${sectionLabel} / ${focusLabel}` : sectionLabel; +}; + +const getSettingsLinkChildren = ({ + 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 getSettingsLinkLabel(section, focus); + } + + return fallbackChildren ?? content; +}; + +const renderSettingsLink = ({ + href, + section, + focus, + handleMentionClick, + content, + fallbackChildren, +}: { + href: string; + section: keyof typeof settingsSectionLabel; + focus?: string; + handleMentionClick?: ReactEventHandler; + content?: string; + fallbackChildren?: ReactNode; +}) => ( + + + {getSettingsLinkChildren({ 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, @@ -210,6 +289,21 @@ export const factoryRenderLinkifyWithMention = ( if (mention) return mention; } + if (tagName === 'a' && decodedHref) { + const settingsLink = parseSettingsLink(settingsLinkBaseUrl, decodedHref); + if (settingsLink) { + const { section, focus } = settingsLink; + return renderSettingsLink({ + href: decodedHref, + section, + focus, + handleMentionClick, + content, + fallbackChildren: content, + }); + } + } + return {content}; }; @@ -398,6 +492,7 @@ export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, params: { + settingsLinkBaseUrl: string; linkifyOpts: LinkifyOpts; highlightRegex?: RegExp; handleSpoilerClick?: ReactEventHandler; @@ -550,28 +645,44 @@ export const getReactCustomHtmlParser = ( if (name === 'a' && typeof props.href === 'string') { const encodedHref = props.href; const decodedHref = encodedHref && safeDecodeUrl(encodedHref); + const renderedChildren = renderChildren(); const anchorProps = { ...props, rel: ensureNoopenerRel(props.rel), }; - if (!decodedHref || !testMatrixTo(decodedHref)) { - return {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 (decodedHref) { + const settingsLink = parseSettingsLink(params.settingsLinkBaseUrl, decodedHref); + if (settingsLink) { + const { section, focus } = settingsLink; + return renderSettingsLink({ + href: decodedHref, + section, + focus, + handleMentionClick: params.handleMentionClick, + content, + fallbackChildren: renderedChildren, + }); + } + } - if (mention) return mention; + return {renderedChildren}; } if (name === 'span' && 'data-mx-spoiler' in props) { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 85ca08500..8a4956394 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -73,6 +73,7 @@ export interface Settings { developerTools: boolean; enableMSC4268CMD: boolean; settingsSyncEnabled: boolean; + settingsLinkBaseUrlOverride?: string; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -175,6 +176,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 8669b7fa5..fb6882f95 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -174,6 +174,17 @@ export const Mention = recipe({ }, }); +export const MentionWithIcon = style({ + display: 'inline-flex', + alignItems: 'center', + gap: toRem(2), +}); + +export const MentionIcon = 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 270d60a11..e3d29aebc 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)