feat(links): Copy Lotus Link permalinks, in-app recognition, /home redirect for joined rooms, via= alias, OIDC deep-link redirect (#130)
CI / Build & Quality Checks (push) Successful in 2m4s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m11s

matrix.to cannot target this deployment (Cinny adapter hard-codes
app.cinny.in; web-instance[] is Element-only, allowlisted), so add a
"Copy Lotus Link" next to every Copy Link (message menu, space header
menu, sidebar space tab) producing https://<this origin>/home/<room>/<event>
?viaServers=… via plugins/lotus-permalink.ts. Lotus links in messages are
rewritten to their matrix.to form inside the HTML parser so they render as
room/event mentions and navigate in place.

/home/<room> for a joined room that belongs to a space or Direct now
redirects to its own route (was a preview card with a View button; also
the form matrix.to → Cinny links use). ?via= is accepted as an alias of
?viaServers= (what the matrix.to Cinny adapter emits). A deep link
visited while logged out is now honoured after an OIDC login: the OIDC
callback reloads at the app root, which discarded the stored path — the
index loader consumes it via the shared takeAfterLoginPath().

Verified end-to-end with Playwright on a local Synapse: logged-out cold
link → login → lands on the event under the space route; menu copies the
expected link; a pasted Lotus link renders as a mention and jumps in
place; both space menus copy the space link.

Closes #130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 00:18:45 -04:00
co-authored by Claude Opus 5
parent d929143f7d
commit f528e5e440
14 changed files with 396 additions and 22 deletions
+91
View File
@@ -0,0 +1,91 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
getLotusRoomPermalink,
getLotusSpacePermalink,
lotusPermalinkToMatrixTo,
parseLotusPermalink,
} from './lotus-permalink';
const BASE = 'https://chat.lotusguild.org/';
test('builds room / event / space permalinks with encoded ids and viaServers', () => {
assert.equal(
getLotusRoomPermalink(BASE, '!abc:matrix.lotusguild.org', '$ev1', ['matrix.lotusguild.org']),
'https://chat.lotusguild.org/home/!abc%3Amatrix.lotusguild.org/%24ev1?viaServers=matrix.lotusguild.org',
);
assert.equal(
getLotusRoomPermalink(BASE, '#general:matrix.lotusguild.org'),
'https://chat.lotusguild.org/home/%23general%3Amatrix.lotusguild.org',
);
assert.equal(
getLotusSpacePermalink('https://chat.lotusguild.org', '#guild:matrix.lotusguild.org'),
'https://chat.lotusguild.org/%23guild%3Amatrix.lotusguild.org',
);
});
test('parses its own output back (encoded and decoded forms)', () => {
const link = getLotusRoomPermalink(BASE, '!abc:lotus', '$ev1', ['a.org', 'b.org']);
assert.deepEqual(parseLotusPermalink(BASE, link), {
roomIdOrAlias: '!abc:lotus',
eventId: '$ev1',
viaServers: ['a.org', 'b.org'],
});
assert.deepEqual(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/!abc:lotus/$ev1'), {
roomIdOrAlias: '!abc:lotus',
eventId: '$ev1',
viaServers: undefined,
});
assert.deepEqual(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/direct/!dm:lotus/'), {
roomIdOrAlias: '!dm:lotus',
viaServers: undefined,
});
});
test('space routes: lobby → the space, nested room → the room', () => {
assert.deepEqual(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/%23guild%3Alotus/'), {
roomIdOrAlias: '#guild:lotus',
viaServers: undefined,
});
assert.deepEqual(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org/%23guild%3Alotus/!room%3Alotus/%24e'),
{ roomIdOrAlias: '!room:lotus', eventId: '$e', viaServers: undefined },
);
});
test('accepts the matrix.to Cinny adapter form (?via=a,b)', () => {
assert.deepEqual(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/!r:x?via=a.org,b.org'),
{
roomIdOrAlias: '!r:x',
viaServers: ['a.org', 'b.org'],
},
);
});
test('rejects other origins, non-room routes and malformed ids', () => {
assert.equal(parseLotusPermalink(BASE, 'https://app.cinny.in/home/!r:x'), undefined);
assert.equal(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/settings'), undefined);
assert.equal(parseLotusPermalink(BASE, 'https://chat.lotusguild.org/explore/lotus'), undefined);
assert.equal(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org/home/!r:x/notanevent'),
undefined,
);
assert.equal(
parseLotusPermalink(BASE, 'https://chat.lotusguild.org.evil.com/home/!r:x'),
undefined,
);
assert.equal(parseLotusPermalink(BASE, 'https://chat.lotusguild.org'), undefined);
});
test('rewrites to the matrix.to form the existing mention pipeline understands', () => {
assert.equal(
lotusPermalinkToMatrixTo(BASE, 'https://chat.lotusguild.org/home/!r:x/$e?viaServers=a.org'),
'https://matrix.to/#/!r:x/$e?via=a.org',
);
assert.equal(
lotusPermalinkToMatrixTo(BASE, 'https://chat.lotusguild.org/%23guild%3Ax/'),
'https://matrix.to/#/#guild:x',
);
assert.equal(lotusPermalinkToMatrixTo(BASE, 'https://example.org/home/!r:x'), undefined);
});
+109
View File
@@ -0,0 +1,109 @@
import { getHomeRoomPath, getSpacePath, withSearchParam } from '../pages/pathUtils';
import { _RoomSearchParams } from '../pages/paths';
import { MatrixToRoom, MatrixToRoomEvent } from './matrix-to';
/**
* [Gitea #130] Direct Lotus permalinks.
*
* `matrix.to` cannot be pointed at this deployment (its Cinny adapter is
* hard-coded to app.cinny.in and `web-instance[]` only works for Element), so
* next to every "Copy Link" there is a "Copy Lotus Link" that yields
* `https://<this deployment>/home/<room>/<event>?viaServers=…` — the same
* routes the app already serves cold (logged-out → login → back to the room).
*
* `baseUrl` is `getOriginBaseUrl(hashRouter)` so the result is right for any
* deployment and for the hash-router config.
*/
const trimSlash = (s: string): string => s.replace(/\/+$/, '');
const withVia = (path: string, viaServers?: string[]): string =>
viaServers && viaServers.length > 0
? withSearchParam<_RoomSearchParams>(path, { viaServers: viaServers.join(',') })
: path;
export const getLotusRoomPermalink = (
baseUrl: string,
roomIdOrAlias: string,
eventId?: string,
viaServers?: string[],
): string => `${trimSlash(baseUrl)}${withVia(getHomeRoomPath(roomIdOrAlias, eventId), viaServers)}`;
export const getLotusSpacePermalink = (
baseUrl: string,
spaceIdOrAlias: string,
viaServers?: string[],
): string => `${trimSlash(baseUrl)}${withVia(getSpacePath(spaceIdOrAlias), viaServers)}`;
const tryDecode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const isRoomIdOrAlias = (s: string): boolean => /^[!#][^/\s]+:[^/\s]+$/.test(s);
const isEventId = (s: string): boolean => /^\$[^/\s]+$/.test(s);
/**
* Parse a link into the same shape the matrix.to parsers return, when it
* points at THIS deployment's room/space routes:
*
* <base>/home/<room>/<event>?viaServers=a,b
* <base>/direct/<room>/<event>
* <base>/<space>/ (space lobby)
* <base>/<space>/<room>/<event> (room inside a space → the room)
*
* Anything else — settings pages, explore, a different origin — is undefined.
* `via` is accepted as an alias of `viaServers` because the matrix.to Cinny
* adapter emits it.
*/
export const parseLotusPermalink = (
baseUrl: string,
href: string,
): MatrixToRoom | MatrixToRoomEvent | undefined => {
const base = trimSlash(baseUrl);
if (!href.startsWith(`${base}/`)) return undefined;
const rest = href.slice(base.length);
let url: URL;
try {
url = new URL(rest, 'http://lotus.invalid');
} catch {
return undefined;
}
const segments = url.pathname
.split('/')
.filter((s) => s.length > 0)
.map(tryDecode);
const via = [...url.searchParams.getAll('viaServers'), ...url.searchParams.getAll('via')]
.flatMap((v) => v.split(','))
.map((v) => v.trim())
.filter((v) => v.length > 0);
const viaServers = via.length > 0 ? via : undefined;
let roomIdOrAlias: string | undefined;
let eventId: string | undefined;
if (segments[0] === 'home' || segments[0] === 'direct') {
[, roomIdOrAlias, eventId] = segments;
} else if (segments[0] && isRoomIdOrAlias(segments[0])) {
// /<space>/ or /<space>/<room>/<event>
roomIdOrAlias = segments[1] && isRoomIdOrAlias(segments[1]) ? segments[1] : segments[0];
if (segments[1] && isRoomIdOrAlias(segments[1])) [, , eventId] = segments;
}
if (!roomIdOrAlias || !isRoomIdOrAlias(roomIdOrAlias)) return undefined;
if (eventId !== undefined && !isEventId(eventId)) return undefined;
return eventId ? { roomIdOrAlias, eventId, viaServers } : { roomIdOrAlias, viaServers };
};
/** Rewrite a Lotus permalink as the matrix.to link the rest of the app already understands. */
export const lotusPermalinkToMatrixTo = (baseUrl: string, href: string): string | undefined => {
const parsed = parseLotusPermalink(baseUrl, href);
if (!parsed) return undefined;
const fragment =
'eventId' in parsed ? `${parsed.roomIdOrAlias}/${parsed.eventId}` : parsed.roomIdOrAlias;
const query = parsed.viaServers ? `?${parsed.viaServers.map((s) => `via=${s}`).join('&')}` : '';
return `https://matrix.to/#/${fragment}${query}`;
};
+25 -4
View File
@@ -23,6 +23,7 @@ import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import { stripTrackingParams } from '../utils/urlTracking';
import { lotusPermalinkToMatrixTo } from './lotus-permalink';
import * as css from '../styles/CustomHtml.css';
import {
getMxIdLocalPart,
@@ -118,6 +119,22 @@ export const setStripTrackingOnRender = (enabled: boolean): void => {
const cleanHref = (href: string): string =>
stripTrackingOnRender ? stripTrackingParams(href) : href;
// [Gitea #130] Links to THIS deployment's room routes render and click like
// matrix.to links. The base is set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks.
let lotusPermalinkBase: string | undefined;
export const setLotusPermalinkBase = (baseUrl: string | undefined): void => {
lotusPermalinkBase = baseUrl;
};
/**
* The matrix.to form of `href` when it is a matrix.to link or a Lotus
* permalink; undefined for every other link.
*/
export const toMatrixToHref = (href: string): string | undefined => {
if (testMatrixTo(href)) return href;
return lotusPermalinkBase ? lotusPermalinkToMatrixTo(lotusPermalinkBase, href) : undefined;
};
export const LINKIFY_OPTS: LinkifyOpts = {
attributes: {
target: '_blank',
@@ -235,8 +252,10 @@ export const factoryRenderLinkifyWithMention = (
attributes,
content,
}) => {
if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) {
const mention = mentionRender(tryDecodeURIComponent(attributes.href));
const matrixHref =
tagName === 'a' ? toMatrixToHref(tryDecodeURIComponent(attributes.href)) : undefined;
if (matrixHref) {
const mention = mentionRender(matrixHref);
if (mention) return mention;
}
@@ -559,7 +578,9 @@ export const getReactCustomHtmlParser = (
}
}
if (name === 'a' && testMatrixTo(tryDecodeURIComponent(String(props.href)))) {
const matrixHref =
name === 'a' ? toMatrixToHref(tryDecodeURIComponent(String(props.href))) : undefined;
if (matrixHref) {
const content = children.find((child) => !(child instanceof DOMText))
? undefined
: children.map((c) => (c instanceof DOMText ? c.data : '')).join();
@@ -567,7 +588,7 @@ export const getReactCustomHtmlParser = (
const mention = renderMatrixMention(
mx,
roomId,
tryDecodeURIComponent(String(props.href)),
matrixHref,
makeMentionCustomProps(params.handleMentionClick, content),
);