From 053b364a4414e6077cdd9b92e10b0a01a555636b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 13 Jun 2026 18:52:23 -0400 Subject: [PATCH] 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 --- src/app/hooks/useDeepLinkNavigate.ts | 98 ++++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++ 2 files changed, 105 insertions(+) create mode 100644 src/app/hooks/useDeepLinkNavigate.ts diff --git a/src/app/hooks/useDeepLinkNavigate.ts b/src/app/hooks/useDeepLinkNavigate.ts new file mode 100644 index 000000000..8f53bef03 --- /dev/null +++ b/src/app/hooks/useDeepLinkNavigate.ts @@ -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; + if (typeof detail === 'string') navigateUri(detail); + }; + window.addEventListener(DEEP_LINK_EVENT, handler); + return () => window.removeEventListener(DEEP_LINK_EVENT, handler); + }, [navigateUri]); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f3ff24964..4c8531224 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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) { + {children} );