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];