Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a6376d7
feat: add settings route helpers
hazre Mar 26, 2026
144ff90
refactor: remove settings route type inversion
hazre Mar 26, 2026
0ec2944
refactor: share settings section layout and focus handling
hazre Mar 26, 2026
f786860
feat: make settings route-driven
hazre Mar 26, 2026
7aad1ac
fix: align settings mobile back behavior
hazre Mar 26, 2026
3f03a1a
fix: harden settings route navigation
hazre Mar 26, 2026
bb47bd0
feat: render settings as a shallow desktop modal
hazre Mar 26, 2026
fbde318
fix: preserve shallow settings background state
hazre Mar 26, 2026
8a8791e
fix: avoid shallow settings history churn
hazre Mar 26, 2026
e76c278
refactor: open settings via route navigation
hazre Mar 26, 2026
633bd25
fix: prevent nested settings openers
hazre Mar 26, 2026
8d1c6e1
fix: redirect desktop settings root
hazre Mar 26, 2026
95335de
feat: route settings and add permalinks
hazre Mar 27, 2026
1e000ba
fix: polish settings permalinks and routing
hazre Mar 27, 2026
50542e9
fix: polish settings permalink controls
hazre Mar 28, 2026
8bfe21a
refactor: rename settings permalinks to setting links
hazre Mar 28, 2026
6bd125b
Merge origin/dev into codex/settings-routing-links
hazre Mar 28, 2026
9925b94
docs: add settings changesets
hazre Mar 28, 2026
be89126
Merge remote-tracking branch 'origin/dev' into hazre/feat/settings-ro…
hazre Mar 30, 2026
0c4face
Merge remote-tracking branch 'origin/dev' into hazre/feat/settings-ro…
hazre Mar 31, 2026
ae66ba6
fix: hide desktop settings back button
hazre Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/settings-links.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/settings-route-based-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Settings now use route-based navigation with improved desktop and mobile behavior, including better back and close handling.
2 changes: 2 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"webPushAppID": "moe.sable.app.sygnal"
},

"settingsLinkBaseUrl": "https://app.sable.moe",

"slidingSync": {
"enabled": true
},
Expand Down
15 changes: 15 additions & 0 deletions src/app/components/Modal500.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Modal500 requestClose={vi.fn()}>
<div>Empty modal content</div>
</Modal500>
)
).not.toThrow();
});
});
7 changes: 5 additions & 2 deletions src/app/components/Modal500.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,18 +8,21 @@ type Modal500Props = {
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
const modalRef = useRef<HTMLDivElement | null>(null);

return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
fallbackFocus: () => modalRef.current ?? document.body,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
<Modal ref={modalRef} tabIndex={-1} size="500" variant="Background">
{children}
</Modal>
</FocusTrap>
Expand Down
48 changes: 48 additions & 0 deletions src/app/components/RenderMessageContent.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="url-preview-holder">{children}</div>
),
UrlPreviewCard: ({ url }: { url: string }) => <div data-testid="url-preview-card">{url}</div>,
ClientPreview: ({ url }: { url: string }) => <div data-testid="client-preview">{url}</div>,
youtubeUrl: () => false,
}));

function renderMessage(body: string, settingsLinkBaseUrl = 'https://app.sable.moe') {
return render(
<ClientConfigProvider value={{ settingsLinkBaseUrl }}>
<RenderMessageContent
displayName="Alice"
msgType={MsgType.Text}
ts={0}
getContent={() => ({ body }) as never}
urlPreview
clientUrlPreview
htmlReactParserOptions={{}}
linkifyOpts={{}}
/>
</ClientConfigProvider>
);
}

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');
});
});
9 changes: 7 additions & 2 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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) => ({
Expand Down Expand Up @@ -130,7 +135,7 @@ function RenderMessageContentInternal({
</UrlPreviewHolder>
);
},
[ts, clientUrlPreview, urlPreview]
[ts, clientUrlPreview, settingsLinkBaseUrl, urlPreview]
);
const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined;

Expand Down
5 changes: 4 additions & 1 deletion src/app/components/event-history/EventHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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);
Expand All @@ -72,11 +74,12 @@ export const EventHistory = as<'div', EventHistoryProps>(
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() =>
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);
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/message/Reply.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('$utils/room')>();
return {
Expand Down
24 changes: 15 additions & 9 deletions src/app/components/message/Reply.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ? <MessageDeletedContent /> : <MessageFailedContent />;

Expand Down Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions src/app/components/message/layout/layout.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions src/app/components/sequence-card/SequenceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const SequenceCard = as<
ContainerColor({ variant }),
className
)}
data-sequence-card="true"
data-first-child={firstChild}
data-last-child={lastChild}
{...props}
Expand Down
65 changes: 65 additions & 0 deletions src/app/components/setting-tile/SettingTile.css.ts
Original file line number Diff line number Diff line change
@@ -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,
},
]);
Loading
Loading