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 }) => (
+