feat: route desktop matrix: deep links into the client
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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user