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
+5 -2
View File
@@ -46,7 +46,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { setAfterLoginRedirectPath, takeAfterLoginPath } from './afterLoginRedirectPath';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -112,7 +112,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route
index
loader={() => {
if (getFallbackSession()) return redirect(getHomePath());
// A stored deep link is normally consumed by the password login;
// the OIDC callback instead reloads at the app root, so consume it
// here too (Gitea #130 — SSO users landing on a shared room link).
if (getFallbackSession()) return redirect(takeAfterLoginPath(getHomePath()));
const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
+11
View File
@@ -10,3 +10,14 @@ export const getAfterLoginRedirectPath = (): string | undefined => {
export const deleteAfterLoginRedirectPath = (): void => {
localStorage.removeItem(AFTER_LOGIN_REDIRECT_PATH_KEY);
};
/**
* The in-app path to land on after a login: the stored deep link when it is a
* plain app path (never a protocol-relative or absolute URL), else `fallback`.
* Consumes the stored value. Shared by the password and OIDC flows.
*/
export const takeAfterLoginPath = (fallback: string): string => {
const stored = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
return stored && /^\/(?!\/)/.test(stored) ? stored : fallback;
};
+2 -9
View File
@@ -5,10 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
import { autoDiscovery, specVersions } from '../../../cs-api';
import { ErrorCode } from '../../../cs-errorcode';
import {
deleteAfterLoginRedirectPath,
getAfterLoginRedirectPath,
} from '../../afterLoginRedirectPath';
import { takeAfterLoginPath } from '../../afterLoginRedirectPath';
import { getHomePath } from '../../pathUtils';
import { setFallbackSession } from '../../../state/sessions';
@@ -115,11 +112,7 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
if (data) {
const { response: loginRes, baseUrl: loginBaseUrl } = data;
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
const _redir = afterLoginRedirectUrl;
const _safePath = _redir && /^\/(?!\/)/.test(_redir) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
navigate(takeAfterLoginPath(getHomePath()), { replace: true });
}
}, [data, navigate]);
};
+15 -1
View File
@@ -20,11 +20,15 @@ import { notificationPermission, setFavicon, showOsNotification } from '../../ut
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { setStripTrackingOnRender } from '../../plugins/react-custom-html-parser';
import {
setLotusPermalinkBase,
setStripTrackingOnRender,
} from '../../plugins/react-custom-html-parser';
import { useSettingsSync } from '../../hooks/useSettingsSync';
import { usePwaInstallPrompt } from '../../hooks/usePwaInstallPrompt';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import {
@@ -92,6 +96,15 @@ function SystemEmojiFeature() {
}
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function LotusPermalinkFeature() {
const { hashRouter } = useClientConfig();
useEffect(() => {
setLotusPermalinkBase(getOriginBaseUrl(hashRouter));
return () => setLotusPermalinkBase(undefined);
}, [hashRouter]);
return null;
}
function TrackingParamsFeature() {
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
useEffect(() => {
@@ -928,6 +941,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<SearchCacheInvalidationFeature />
<SystemEmojiFeature />
<PageZoomFeature />
<LotusPermalinkFeature />
<TrackingParamsFeature />
<SettingsSyncFeature />
<PwaInstallFeature />
+17 -1
View File
@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
@@ -6,6 +6,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
@@ -16,6 +17,21 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
// [Gitea #130] `/home/<room>` is the universal permalink form (Lotus links,
// matrix.to → Cinny links). A room we are already in but that lives under a
// space or in Direct goes straight to its own route instead of a preview
// card with a "View" button.
const { navigateRoom, navigateSpace } = useRoomNavigate();
const joinedElsewhere =
!!room && room.getMyMembership() === 'join' && !rooms.includes(room.roomId);
useEffect(() => {
if (!joinedElsewhere || !room) return;
if (room.isSpaceRoom()) navigateSpace(room.roomId, { replace: true });
else navigateRoom(room.roomId, eventId, { replace: true });
}, [joinedElsewhere, room, eventId, navigateRoom, navigateSpace]);
if (joinedElsewhere) return null;
if (!room || !rooms.includes(room.roomId)) {
return (
<JoinBeforeNavigate
+29 -1
View File
@@ -47,7 +47,13 @@ import {
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpacePath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import {
SidebarAvatar,
SidebarItem,
@@ -85,6 +91,7 @@ import { markAsRead } from '../../../utils/notifications';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getLotusSpacePermalink } from '../../../plugins/lotus-permalink';
import { getViaServers } from '../../../plugins/via-servers';
import { getRoomAvatarUrl } from '../../../utils/room';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -103,6 +110,7 @@ type SpaceMenuProps = {
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
@@ -138,6 +146,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
requestClose();
};
// [Gitea #130] direct link into this deployment
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
@@ -209,6 +227,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLotusLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Lotus Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
+29 -1
View File
@@ -39,7 +39,13 @@ import {
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import {
getOriginBaseUrl,
getSpaceLobbyPath,
getSpaceRoomPath,
getSpaceSearchPath,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
@@ -70,6 +76,7 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
import { Membership, StateEvent } from '../../../../types/matrix/room';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getLotusSpacePermalink } from '../../../plugins/lotus-permalink';
import { getViaServers } from '../../../plugins/via-servers';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
@@ -93,6 +100,7 @@ type SpaceMenuProps = {
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const { hashRouter } = useClientConfig();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
@@ -125,6 +133,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
requestClose();
};
// [Gitea #130] direct link into this deployment
const handleCopyLotusLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(
getLotusSpacePermalink(getOriginBaseUrl(hashRouter), roomIdOrAlias, viaServers),
);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
@@ -189,6 +207,16 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLotusLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Lotus Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
+3 -1
View File
@@ -3,7 +3,9 @@ import { _RoomSearchParams, DirectCreateSearchParams } from './paths';
type SearchParamsGetter<T> = (searchParams: URLSearchParams) => T;
export const getRoomSearchParams: SearchParamsGetter<_RoomSearchParams> = (searchParams) => ({
viaServers: searchParams.get('viaServers') ?? undefined,
// `via` is what the matrix.to Cinny adapter emits (`?via=a,b`); accept it as
// an alias so those links keep their routing hints (Gitea #130).
viaServers: searchParams.get('viaServers') ?? searchParams.get('via') ?? undefined,
});
export const getDirectCreateSearchParams: SearchParamsGetter<DirectCreateSearchParams> = (