diff --git a/.changeset/fix-matrix-to-custom-base.md b/.changeset/fix-matrix-to-custom-base.md
new file mode 100644
index 00000000..396d2a71
--- /dev/null
+++ b/.changeset/fix-matrix-to-custom-base.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Support `matrixToBaseUrl` in `config.json` to override the default `matrix.to` link base URL.
diff --git a/src/app/components/ClientConfigLoader.test.tsx b/src/app/components/ClientConfigLoader.test.tsx
new file mode 100644
index 00000000..b80e4088
--- /dev/null
+++ b/src/app/components/ClientConfigLoader.test.tsx
@@ -0,0 +1,99 @@
+/**
+ * Integration tests: exercises the full config-load → setMatrixToBase → URL
+ * generation pipeline that App.tsx runs on startup.
+ *
+ * The pattern under test mirrors App.tsx:
+ *
+ * {(config) => { setMatrixToBase(config.matrixToBaseUrl); ... }}
+ *
+ *
+ * We mock fetch so we don't need a real config.json or a live matrix.to instance.
+ */
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { setMatrixToBase, getMatrixToRoom, getMatrixToUser } from '$plugins/matrix-to';
+import { ClientConfigLoader } from './ClientConfigLoader';
+
+afterEach(() => {
+ setMatrixToBase(); // reset module state to 'https://matrix.to'
+ vi.unstubAllGlobals();
+});
+
+const mockFetch = (config: object) =>
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve(config) }));
+
+describe('ClientConfigLoader + matrix-to wiring', () => {
+ it('generates a standard matrix.to URL when no custom base is configured', async () => {
+ mockFetch({});
+
+ render(
+
+ {(config) => {
+ setMatrixToBase(config.matrixToBaseUrl);
+ return {getMatrixToRoom('!room:example.com')};
+ }}
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('link')).toHaveTextContent('https://matrix.to/#/!room:example.com')
+ );
+ });
+
+ it('generates a custom-base URL for rooms when matrixToBaseUrl is set', async () => {
+ mockFetch({ matrixToBaseUrl: 'https://custom.example.org' });
+
+ render(
+
+ {(config) => {
+ setMatrixToBase(config.matrixToBaseUrl);
+ return {getMatrixToRoom('!room:example.com')};
+ }}
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('link')).toHaveTextContent(
+ 'https://custom.example.org/#/!room:example.com'
+ )
+ );
+ });
+
+ it('generates a custom-base URL for users when matrixToBaseUrl is set', async () => {
+ mockFetch({ matrixToBaseUrl: 'https://custom.example.org' });
+
+ render(
+
+ {(config) => {
+ setMatrixToBase(config.matrixToBaseUrl);
+ return {getMatrixToUser('@alice:example.com')};
+ }}
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('user')).toHaveTextContent(
+ 'https://custom.example.org/#/@alice:example.com'
+ )
+ );
+ });
+
+ it('strips a trailing slash from matrixToBaseUrl', async () => {
+ mockFetch({ matrixToBaseUrl: 'https://custom.example.org/' });
+
+ render(
+
+ {(config) => {
+ setMatrixToBase(config.matrixToBaseUrl);
+ return {getMatrixToRoom('!room:example.com')};
+ }}
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId('link')).toHaveTextContent(
+ 'https://custom.example.org/#/!room:example.com'
+ )
+ );
+ });
+});
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 4d6cec62..87685337 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -40,6 +40,8 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
+
+ matrixToBaseUrl?: string;
};
const ClientConfigContext = createContext(null);
diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx
index 0408f38e..bf5ee639 100644
--- a/src/app/pages/App.tsx
+++ b/src/app/pages/App.tsx
@@ -7,6 +7,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { ClientConfigLoader } from '$components/ClientConfigLoader';
import { ClientConfigProvider } from '$hooks/useClientConfig';
+import { setMatrixToBase } from '$plugins/matrix-to';
import { ScreenSizeProvider, useScreenSize } from '$hooks/useScreenSize';
import { useCompositionEndTracking } from '$hooks/useComposingCheck';
import { ErrorPage } from '$components/DefaultErrorPage';
@@ -35,16 +36,19 @@ function App() {
)}
>
- {(clientConfig) => (
-
-
-
-
-
-
-
-
- )}
+ {(clientConfig) => {
+ setMatrixToBase(clientConfig.matrixToBaseUrl);
+ return (
+
+
+
+
+
+
+
+
+ );
+ }}
diff --git a/src/app/plugins/matrix-to.test.ts b/src/app/plugins/matrix-to.test.ts
new file mode 100644
index 00000000..519ecd82
--- /dev/null
+++ b/src/app/plugins/matrix-to.test.ts
@@ -0,0 +1,231 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ getMatrixToRoom,
+ getMatrixToRoomEvent,
+ getMatrixToUser,
+ parseMatrixToRoom,
+ parseMatrixToRoomEvent,
+ parseMatrixToUser,
+ setMatrixToBase,
+ testMatrixTo,
+} from './matrix-to';
+
+// Reset to default after each test so state doesn't leak between tests.
+afterEach(() => {
+ setMatrixToBase(undefined);
+});
+
+// ---------------------------------------------------------------------------
+// Link generation
+// ---------------------------------------------------------------------------
+
+describe('getMatrixToUser', () => {
+ it('generates a standard matrix.to user link', () => {
+ expect(getMatrixToUser('@alice:example.com')).toBe('https://matrix.to/#/@alice:example.com');
+ });
+
+ it('uses custom base when configured', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(getMatrixToUser('@alice:example.com')).toBe(
+ 'https://matrix.example.org/#/@alice:example.com'
+ );
+ });
+
+ it('strips trailing slash from custom base', () => {
+ setMatrixToBase('https://matrix.example.org/');
+ expect(getMatrixToUser('@alice:example.com')).toBe(
+ 'https://matrix.example.org/#/@alice:example.com'
+ );
+ });
+});
+
+describe('getMatrixToRoom', () => {
+ it('generates a standard matrix.to room link', () => {
+ expect(getMatrixToRoom('!room:example.com')).toBe('https://matrix.to/#/!room:example.com');
+ });
+
+ it('appends via servers', () => {
+ expect(getMatrixToRoom('!room:example.com', ['s1.org', 's2.org'])).toBe(
+ 'https://matrix.to/#/!room:example.com?via=s1.org&via=s2.org'
+ );
+ });
+
+ it('uses custom base when configured', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(getMatrixToRoom('#general:example.com')).toBe(
+ 'https://matrix.example.org/#/#general:example.com'
+ );
+ });
+});
+
+describe('getMatrixToRoomEvent', () => {
+ it('generates a standard matrix.to event link', () => {
+ expect(getMatrixToRoomEvent('!room:example.com', '$event123')).toBe(
+ 'https://matrix.to/#/!room:example.com/$event123'
+ );
+ });
+
+ it('appends via servers', () => {
+ expect(getMatrixToRoomEvent('!room:example.com', '$event123', ['s1.org'])).toBe(
+ 'https://matrix.to/#/!room:example.com/$event123?via=s1.org'
+ );
+ });
+
+ it('uses custom base when configured', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(getMatrixToRoomEvent('!room:example.com', '$event123')).toBe(
+ 'https://matrix.example.org/#/!room:example.com/$event123'
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// testMatrixTo
+// ---------------------------------------------------------------------------
+
+describe('testMatrixTo', () => {
+ it('matches standard matrix.to URLs', () => {
+ expect(testMatrixTo('https://matrix.to/#/@alice:example.com')).toBe(true);
+ expect(testMatrixTo('https://matrix.to/#/!room:example.com')).toBe(true);
+ expect(testMatrixTo('https://matrix.to/#/!room:example.com/$event')).toBe(true);
+ expect(testMatrixTo('http://matrix.to/#/@alice:example.com')).toBe(true);
+ });
+
+ it('rejects non-matrix.to URLs', () => {
+ expect(testMatrixTo('https://example.com')).toBe(false);
+ expect(testMatrixTo('https://notmatrix.to/#/@alice:example.com')).toBe(false);
+ });
+
+ it('matches custom base URLs after setMatrixToBase', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(testMatrixTo('https://matrix.example.org/#/@alice:example.com')).toBe(true);
+ });
+
+ it('still matches standard matrix.to after setMatrixToBase (cross-client compat)', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(testMatrixTo('https://matrix.to/#/@alice:example.com')).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseMatrixToUser
+// ---------------------------------------------------------------------------
+
+describe('parseMatrixToUser', () => {
+ it('parses a standard matrix.to user link', () => {
+ expect(parseMatrixToUser('https://matrix.to/#/@alice:example.com')).toBe('@alice:example.com');
+ });
+
+ it('returns undefined for non-user links', () => {
+ expect(parseMatrixToUser('https://matrix.to/#/!room:example.com')).toBeUndefined();
+ });
+
+ it('parses user links from custom base', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(parseMatrixToUser('https://matrix.example.org/#/@alice:example.com')).toBe(
+ '@alice:example.com'
+ );
+ });
+
+ it('parses standard matrix.to user links even after custom base is set', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(parseMatrixToUser('https://matrix.to/#/@alice:example.com')).toBe('@alice:example.com');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseMatrixToRoom
+// ---------------------------------------------------------------------------
+
+describe('parseMatrixToRoom', () => {
+ it('parses a room ID link', () => {
+ expect(parseMatrixToRoom('https://matrix.to/#/!room:example.com')).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ viaServers: undefined,
+ });
+ });
+
+ it('parses a room alias link', () => {
+ expect(parseMatrixToRoom('https://matrix.to/#/#general:example.com')).toEqual({
+ roomIdOrAlias: '#general:example.com',
+ viaServers: undefined,
+ });
+ });
+
+ it('parses via servers', () => {
+ expect(
+ parseMatrixToRoom('https://matrix.to/#/!room:example.com?via=s1.org&via=s2.org')
+ ).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ viaServers: ['s1.org', 's2.org'],
+ });
+ });
+
+ it('returns undefined for event links (too many segments)', () => {
+ expect(parseMatrixToRoom('https://matrix.to/#/!room:example.com/$event123')).toBeUndefined();
+ });
+
+ it('parses room links from custom base', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(parseMatrixToRoom('https://matrix.example.org/#/!room:example.com')).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ viaServers: undefined,
+ });
+ });
+
+ it('still parses standard matrix.to room links after custom base is set', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(parseMatrixToRoom('https://matrix.to/#/!room:example.com')).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ viaServers: undefined,
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseMatrixToRoomEvent
+// ---------------------------------------------------------------------------
+
+describe('parseMatrixToRoomEvent', () => {
+ it('parses a room event link', () => {
+ expect(parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com/$event123')).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ eventId: '$event123',
+ viaServers: undefined,
+ });
+ });
+
+ it('parses via servers', () => {
+ expect(
+ parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com/$event123?via=s1.org')
+ ).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ eventId: '$event123',
+ viaServers: ['s1.org'],
+ });
+ });
+
+ it('returns undefined for room-only links', () => {
+ expect(parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com')).toBeUndefined();
+ });
+
+ it('parses event links from custom base', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(
+ parseMatrixToRoomEvent('https://matrix.example.org/#/!room:example.com/$event123')
+ ).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ eventId: '$event123',
+ viaServers: undefined,
+ });
+ });
+
+ it('still parses standard matrix.to event links after custom base is set', () => {
+ setMatrixToBase('https://matrix.example.org');
+ expect(parseMatrixToRoomEvent('https://matrix.to/#/!room:example.com/$event123')).toEqual({
+ roomIdOrAlias: '!room:example.com',
+ eventId: '$event123',
+ viaServers: undefined,
+ });
+ });
+});
diff --git a/src/app/plugins/matrix-to.ts b/src/app/plugins/matrix-to.ts
index 03a7d2c1..0397a801 100644
--- a/src/app/plugins/matrix-to.ts
+++ b/src/app/plugins/matrix-to.ts
@@ -1,4 +1,12 @@
-const MATRIX_TO_BASE = 'https://matrix.to';
+let MATRIX_TO_BASE = 'https://matrix.to';
+
+/**
+ * Override the default matrix.to base URL (configurable per deployment).
+ * Must be called before any getMatrixTo* functions are used.
+ */
+export const setMatrixToBase = (baseUrl?: string): void => {
+ MATRIX_TO_BASE = baseUrl ? baseUrl.replace(/\/$/, '') : 'https://matrix.to';
+};
export const getMatrixToUser = (userId: string): string => `${MATRIX_TO_BASE}/#/${userId}`;
@@ -38,23 +46,49 @@ export type MatrixToRoomEvent = MatrixToRoom & {
eventId: string;
};
-const MATRIX_TO = /^https?:\/\/matrix\.to\S*$/;
-export const testMatrixTo = (href: string): boolean => MATRIX_TO.test(href);
+const escapeForRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+// Lazily cached regex set; rebuilt if MATRIX_TO_BASE changes.
+let cachedRegexBase = '';
+let cachedRegexes: {
+ any: RegExp;
+ user: RegExp;
+ room: RegExp;
+ event: RegExp;
+} | null = null;
+
+/**
+ * Returns regexes that match BOTH https://matrix.to (for cross-client links
+ * received from standard clients) and the configured custom base URL (if any).
+ */
+const getMatchRegexes = () => {
+ if (cachedRegexBase === MATRIX_TO_BASE && cachedRegexes) return cachedRegexes;
+ cachedRegexBase = MATRIX_TO_BASE;
+ // Use https? so both http:// and https://matrix.to are accepted (original behaviour).
+ const standard = `https?://${escapeForRegex('matrix.to')}`;
+ const b =
+ MATRIX_TO_BASE !== 'https://matrix.to'
+ ? `(?:${standard}|${escapeForRegex(MATRIX_TO_BASE)})`
+ : standard;
+ cachedRegexes = {
+ any: new RegExp(`^${b}\\S*$`),
+ user: new RegExp(`^${b}/#/(@[^:\\s]+:[^?/\\s]+)\\/?$`),
+ room: new RegExp(`^${b}/#/([#!][^?/\\s]+)\\/?(\\?[\\S]*)?$`),
+ event: new RegExp(`^${b}/#/([#!][^?/\\s]+)/(\\$[^?/\\s]+)\\/?([?\\S]*)?$`),
+ };
+ return cachedRegexes;
+};
-const MATRIX_TO_USER = /^https?:\/\/matrix\.to\/#\/(@[^:\s]+:[^?/\s]+)\/?$/;
-const MATRIX_TO_ROOM = /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/?(\?[\S]*)?$/;
-const MATRIX_TO_ROOM_EVENT =
- /^https?:\/\/matrix\.to\/#\/([#!][^?/\s]+)\/(\$[^?/\s]+)\/?(\?[\S]*)?$/;
+export const testMatrixTo = (href: string): boolean => getMatchRegexes().any.test(href);
export const parseMatrixToUser = (href: string): string | undefined => {
- const match = href.match(MATRIX_TO_USER);
+ const match = href.match(getMatchRegexes().user);
if (!match) return undefined;
- const userId = match[1];
- return userId;
+ return match[1];
};
export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
- const match = href.match(MATRIX_TO_ROOM);
+ const match = href.match(getMatchRegexes().room);
if (!match) return undefined;
const roomIdOrAlias = match[1];
@@ -68,7 +102,7 @@ export const parseMatrixToRoom = (href: string): MatrixToRoom | undefined => {
};
export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefined => {
- const match = href.match(MATRIX_TO_ROOM_EVENT);
+ const match = href.match(getMatchRegexes().event);
if (!match) return undefined;
const roomIdOrAlias = match[1];