feat(permalinks): point Share links/QR at to.healthchat.ch

Add a config-driven matrix_to_url option so Share dialog links and QR
codes resolve against a matrix.to-compatible resolver instead of the
hardcoded public matrix.to. MatrixToPermalinkConstructor now reads the
base URL lazily from config (default https://matrix.to), keeping the
matrix.to URL format. HealthChat configs point it at the in-house fork
https://to.healthchat.ch and drop the conflicting permalink_prefix
(which routes to the Element-format constructor a matrix.to fork can't
parse).
This commit is contained in:
2026-06-30 17:30:56 +02:00
parent 62e7334a1b
commit c2b11111dd
7 changed files with 72 additions and 15 deletions
@@ -33,7 +33,7 @@
"preferred_domain": "jitsi.healthchat.ch" "preferred_domain": "jitsi.healthchat.ch"
}, },
"logout_redirect_url": "", "logout_redirect_url": "",
"permalink_prefix": "https://matrix.to", "matrix_to_url": "https://to.healthchat.ch",
"room_directory": { "room_directory": {
"servers": ["matrix.org"] "servers": ["matrix.org"]
}, },
+1 -1
View File
@@ -35,7 +35,7 @@
"preferred_domain": "jitsi.healthchat.ch" "preferred_domain": "jitsi.healthchat.ch"
}, },
"logout_redirect_url": "", "logout_redirect_url": "",
"permalink_prefix": "https://matrix.to", "matrix_to_url": "https://to.healthchat.ch",
"room_directory": { "room_directory": {
"servers": [ "servers": [
"matrix.org" "matrix.org"
+6
View File
@@ -70,6 +70,12 @@ export interface IConfigOptions {
}; };
permalink_prefix?: string; permalink_prefix?: string;
/**
* Base URL of a matrix.to-compatible resolver to use for Share links and QR codes,
* instead of the public `https://matrix.to`. Must use the matrix.to URL format
* (`/#/<entity>`), e.g. a self-hosted matrix.to fork.
*/
matrix_to_url?: string;
update_base_url?: string; update_base_url?: string;
desktop_builds: { desktop_builds: {
@@ -7,10 +7,27 @@ Please see LICENSE files in the repository root for full details.
*/ */
import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor"; import PermalinkConstructor, { PermalinkParts } from "./PermalinkConstructor";
import SdkConfig from "../../SdkConfig";
export const host = "matrix.to"; const DEFAULT_BASE_URL = "https://matrix.to";
export const baseUrl = `https://${host}`;
export const baseUrlPattern = `^(?:https?://)?${host.replace(".", "\\.")}/#/(.*)`; /**
* The base URL of the matrix.to-compatible resolver to generate permalinks against.
* Defaults to the public `https://matrix.to`, but can be pointed at a self-hosted
* matrix.to fork via the `matrix_to_url` config option. Read lazily so the value
* reflects the runtime config rather than module-load time.
*/
export function baseUrl(): string {
return SdkConfig.get("matrix_to_url") ?? DEFAULT_BASE_URL;
}
export function host(): string {
return new URL(baseUrl()).host;
}
export function baseUrlPattern(): string {
return `^(?:https?://)?${host().replaceAll(".", "\\.")}/#/(.*)`;
}
/** /**
* Generates matrix.to permalinks * Generates matrix.to permalinks
@@ -21,23 +38,23 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor {
} }
public forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { public forEvent(roomId: string, eventId: string, serverCandidates: string[]): string {
return `${baseUrl}/#/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; return `${baseUrl()}/#/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`;
} }
public forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { public forRoom(roomIdOrAlias: string, serverCandidates: string[]): string {
return `${baseUrl}/#/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; return `${baseUrl()}/#/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`;
} }
public forUser(userId: string): string { public forUser(userId: string): string {
return `${baseUrl}/#/${userId}`; return `${baseUrl()}/#/${userId}`;
} }
public forEntity(entityId: string): string { public forEntity(entityId: string): string {
return `${baseUrl}/#/${entityId}`; return `${baseUrl()}/#/${entityId}`;
} }
public isPermalinkHost(testHost: string): boolean { public isPermalinkHost(testHost: string): boolean {
return testHost === host; return testHost === host();
} }
public encodeServerCandidates(candidates: string[]): string { public encodeServerCandidates(candidates: string[]): string {
@@ -52,7 +69,7 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor {
throw new Error("Does not appear to be a permalink"); throw new Error("Does not appear to be a permalink");
} }
const matches = [...fullUrl.matchAll(new RegExp(baseUrlPattern, "gi"))][0]; const matches = [...fullUrl.matchAll(new RegExp(baseUrlPattern(), "gi"))][0];
if (!matches || matches.length < 2) { if (!matches || matches.length < 2) {
throw new Error("Does not appear to be a permalink"); throw new Error("Does not appear to be a permalink");
+5 -4
View File
@@ -16,6 +16,7 @@ import MatrixToPermalinkConstructor, {
baseUrl as matrixtoBaseUrl, baseUrl as matrixtoBaseUrl,
baseUrlPattern as matrixToBaseUrlPattern, baseUrlPattern as matrixToBaseUrlPattern,
} from "./MatrixToPermalinkConstructor"; } from "./MatrixToPermalinkConstructor";
// `matrixtoBaseUrl` and `matrixToBaseUrlPattern` are functions (config-driven); call them at use sites.
import { type PermalinkParts } from "./PermalinkConstructor"; import { type PermalinkParts } from "./PermalinkConstructor";
import type PermalinkConstructor from "./PermalinkConstructor"; import type PermalinkConstructor from "./PermalinkConstructor";
import ElementPermalinkConstructor from "./ElementPermalinkConstructor"; import ElementPermalinkConstructor from "./ElementPermalinkConstructor";
@@ -342,13 +343,13 @@ export function tryTransformEntityToPermalink(matrixClient: MatrixClient, entity
if (permalinkParts) { if (permalinkParts) {
if (permalinkParts.roomIdOrAlias) { if (permalinkParts.roomIdOrAlias) {
const eventIdPart = permalinkParts.eventId ? `/${permalinkParts.eventId}` : ""; const eventIdPart = permalinkParts.eventId ? `/${permalinkParts.eventId}` : "";
let pl = matrixtoBaseUrl + `/#/${permalinkParts.roomIdOrAlias}${eventIdPart}`; let pl = matrixtoBaseUrl() + `/#/${permalinkParts.roomIdOrAlias}${eventIdPart}`;
if (permalinkParts.viaServers?.length) { if (permalinkParts.viaServers?.length) {
pl += new MatrixToPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers); pl += new MatrixToPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers);
} }
return pl; return pl;
} else if (permalinkParts.userId) { } else if (permalinkParts.userId) {
return matrixtoBaseUrl + `/#/${permalinkParts.userId}`; return matrixtoBaseUrl() + `/#/${permalinkParts.userId}`;
} }
} }
} catch {} } catch {}
@@ -437,7 +438,7 @@ export function getPrimaryPermalinkEntity(permalink: string): string | null {
*/ */
function getPermalinkConstructor(isPill = false): PermalinkConstructor { function getPermalinkConstructor(isPill = false): PermalinkConstructor {
const elementPrefix = SdkConfig.get("permalink_prefix"); const elementPrefix = SdkConfig.get("permalink_prefix");
if (elementPrefix && elementPrefix !== matrixtoBaseUrl && !isPill) { if (elementPrefix && elementPrefix !== matrixtoBaseUrl() && !isPill) {
return new ElementPermalinkConstructor(elementPrefix); return new ElementPermalinkConstructor(elementPrefix);
} }
@@ -448,7 +449,7 @@ export function parsePermalink(fullUrl: string): PermalinkParts | null {
try { try {
const elementPrefix = SdkConfig.get("permalink_prefix"); const elementPrefix = SdkConfig.get("permalink_prefix");
const decodedUrl = decodeURIComponent(fullUrl); const decodedUrl = decodeURIComponent(fullUrl);
if (new RegExp(matrixToBaseUrlPattern, "i").test(decodedUrl)) { if (new RegExp(matrixToBaseUrlPattern(), "i").test(decodedUrl)) {
return new MatrixToPermalinkConstructor().parsePermalink(decodedUrl); return new MatrixToPermalinkConstructor().parsePermalink(decodedUrl);
} else if (fullUrl.startsWith("matrix:")) { } else if (fullUrl.startsWith("matrix:")) {
return new MatrixSchemePermalinkConstructor().parsePermalink(fullUrl); return new MatrixSchemePermalinkConstructor().parsePermalink(fullUrl);
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import MatrixToPermalinkConstructor from "../../../../src/utils/permalinks/MatrixToPermalinkConstructor"; import MatrixToPermalinkConstructor from "../../../../src/utils/permalinks/MatrixToPermalinkConstructor";
import { PermalinkParts } from "../../../../src/utils/permalinks/PermalinkConstructor"; import { PermalinkParts } from "../../../../src/utils/permalinks/PermalinkConstructor";
import SdkConfig from "../../../../src/SdkConfig";
describe("MatrixToPermalinkConstructor", () => { describe("MatrixToPermalinkConstructor", () => {
const peramlinkConstructor = new MatrixToPermalinkConstructor(); const peramlinkConstructor = new MatrixToPermalinkConstructor();
@@ -49,4 +50,32 @@ describe("MatrixToPermalinkConstructor", () => {
).toEqual("https://matrix.to/#/!myroom:example.com/$event4?via=one.example.com&via=two.example.com"); ).toEqual("https://matrix.to/#/!myroom:example.com/$event4?via=one.example.com&via=two.example.com");
}); });
}); });
describe("with a custom matrix_to_url config", () => {
beforeEach(() => {
SdkConfig.add({ matrix_to_url: "https://to.healthchat.ch" });
});
afterEach(() => {
SdkConfig.reset();
});
it("generates links against the configured host", () => {
const constructor = new MatrixToPermalinkConstructor();
expect(constructor.forUser("@user:example.com")).toEqual("https://to.healthchat.ch/#/@user:example.com");
expect(constructor.forRoom("!myroom:example.com", [])).toEqual(
"https://to.healthchat.ch/#/!myroom:example.com",
);
});
it("parses links from the configured host and rejects matrix.to links", () => {
const constructor = new MatrixToPermalinkConstructor();
expect(constructor.parsePermalink("https://to.healthchat.ch/#/@user:example.com")).toEqual(
new PermalinkParts(null, null, "@user:example.com", null),
);
expect(() => constructor.parsePermalink("https://matrix.to/#/@user:example.com")).toThrow(
new Error("Does not appear to be a permalink"),
);
});
});
}); });
+4
View File
@@ -125,6 +125,10 @@ complete re-branding/private labeling, a more personalised experience can be ach
3. `brand`: Optional name for the app. Defaults to `Element`. This is used throughout the application in various strings/locations. 3. `brand`: Optional name for the app. Defaults to `Element`. This is used throughout the application in various strings/locations.
4. `permalink_prefix`: An optional URL pointing to an Element Web deployment. For example, `https://app.element.io`. This will 4. `permalink_prefix`: An optional URL pointing to an Element Web deployment. For example, `https://app.element.io`. This will
change all permalinks (via the "Share" menus) to point at the Element Web deployment rather than `matrix.to`. change all permalinks (via the "Share" menus) to point at the Element Web deployment rather than `matrix.to`.
4a. `matrix_to_url`: An optional base URL of a matrix.to-compatible resolver to use for Share links and QR codes instead of
the public `https://matrix.to`. Unlike `permalink_prefix` (which points at an Element Web deployment and uses Element's
`/#/room/…` URL format), this keeps the matrix.to URL format (`/#/<entity>`), so it must point at a matrix.to deployment or
fork. For example, `https://to.example.com`.
5. `desktop_builds`: Optional. Where the desktop builds for the application are, if available. This is explained in more detail 5. `desktop_builds`: Optional. Where the desktop builds for the application are, if available. This is explained in more detail
down below. down below.
6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below. 6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below.