feat: route desktop matrix: deep links into the client
CI / Build & Quality Checks (push) Successful in 15m17s
Trigger Desktop Build / trigger (push) Successful in 4s

Adds useDeepLinkNavigate (mounted in ClientNonUIFeatures): listens for the
'lotus-deeplink' DOM CustomEvent that the Tauri shell dispatches when a
matrix:/matrix.to link opens the app, converts matrix: URIs to matrix.to, and
navigates via the same path as useMentionClickHandler (reusing matrix-to.ts
parsers + useRoomNavigate). No-op outside Tauri.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:52:23 -04:00
parent 3282832a4a
commit 053b364a44
2 changed files with 105 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from './useMatrixClient';
import { useRoomNavigate } from './useRoomNavigate';
import { isRoomId } from '../utils/matrix';
import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils';
import { _RoomSearchParams } from '../pages/paths';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../plugins/matrix-to';
/** Desktop (Tauri) injects deep links via a DOM CustomEvent — see lib.rs. */
const DEEP_LINK_EVENT = 'lotus-deeplink';
const inTauri = (): boolean => '__TAURI_INTERNALS__' in window;
const tryDecode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
/**
* Convert a `matrix:` URI (MSC2312) to its `matrix.to` equivalent so we can
* reuse the existing matrix.to parsers. Returns undefined for forms we don't
* route (e.g. user links, which need an anchor/room context to open a profile).
*
* matrix:r/room:server -> https://matrix.to/#/#room:server
* matrix:roomid/id:server/e/$ev -> https://matrix.to/#/!id:server/$ev
*/
const matrixUriToMatrixTo = (uri: string): string | undefined => {
const body = uri.slice('matrix:'.length);
const [pathPart, queryPart] = body.split('?');
const segments = pathPart.split('/');
if (segments.length < 2) return undefined;
const [kind, rawId, evKind, rawEventId] = segments;
const sigil = kind === 'r' ? '#' : kind === 'roomid' ? '!' : undefined;
if (!sigil || !rawId) return undefined;
let fragment = `${sigil}${tryDecode(rawId)}`;
if (evKind === 'e' && rawEventId) {
fragment += `/$${tryDecode(rawEventId)}`;
}
const query = queryPart ? `?${queryPart}` : '';
return `https://matrix.to/#/${fragment}${query}`;
};
/**
* Routes deep links opened from outside the app (Windows `matrix:` links,
* forwarded from Tauri) into the client, mirroring the navigation in
* useMentionClickHandler. No-op outside Tauri.
*/
export const useDeepLinkNavigate = (): void => {
const mx = useMatrixClient();
const navigate = useNavigate();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const navigateUri = useCallback(
(rawUrl: string) => {
const href = rawUrl.startsWith('matrix:') ? matrixUriToMatrixTo(rawUrl) : rawUrl;
if (!href || !testMatrixTo(href)) return;
const roomEvent = parseMatrixToRoomEvent(href);
const target = roomEvent ?? parseMatrixToRoom(href);
if (!target) return; // users / unsupported forms
const { roomIdOrAlias, viaServers } = target;
const eventId = roomEvent?.eventId;
// Already-joined room/space: navigate directly.
if (isRoomId(roomIdOrAlias) && mx.getRoom(roomIdOrAlias)) {
if (mx.getRoom(roomIdOrAlias)?.isSpaceRoom()) navigateSpace(roomIdOrAlias);
else navigateRoom(roomIdOrAlias, eventId);
return;
}
// Otherwise route through the home path (triggers the join/preview flow).
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers && viaServers.length > 0
? withSearchParam<_RoomSearchParams>(path, { viaServers: viaServers.join(',') })
: path,
);
},
[mx, navigate, navigateRoom, navigateSpace],
);
useEffect(() => {
if (!inTauri()) return undefined;
const handler = (evt: Event) => {
const { detail } = evt as CustomEvent<unknown>;
if (typeof detail === 'string') navigateUri(detail);
};
window.addEventListener(DEEP_LINK_EVENT, handler);
return () => window.removeEventListener(DEEP_LINK_EVENT, handler);
}, [navigateUri]);
};
@@ -34,6 +34,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
function isInQuietHours(start: string, end: string): boolean {
@@ -376,6 +377,11 @@ type ClientNonUIFeaturesProps = {
children: ReactNode;
};
function DeepLinkNavigator() {
useDeepLinkNavigate();
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -385,6 +391,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PresenceUpdater />
<InviteNotifications />
<MessageNotifications />
<DeepLinkNavigator />
{children}
</>
);