diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 371818f9..082476d2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,23 @@ + + + + + + + + window.global ||= window; +
diff --git a/src/app/features/create-chat/CreateChat.tsx b/src/app/features/create-chat/CreateChat.tsx index dc626364..377e2bba 100644 --- a/src/app/features/create-chat/CreateChat.tsx +++ b/src/app/features/create-chat/CreateChat.tsx @@ -5,7 +5,7 @@ import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-j import { useNavigate } from 'react-router-dom'; import { SettingTile } from '../../components/setting-tile'; import { SequenceCard } from '../../components/sequence-card'; -import { addRoomIdToMDirect, isUserId } from '../../utils/matrix'; +import { addRoomIdToMDirect, getDMRoomFor, isUserId } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { ErrorCode } from '../../cs-errorcode'; @@ -66,6 +66,16 @@ export function CreateChat({ defaultUserId }: CreateChatProps) { return; } + // Route to an existing DM (encrypted or not) before creating a new room; + // otherwise every submit from the card's "Message" button creates a new + // duplicate room even when a perfectly fine DM already exists. + const existing = getDMRoomFor(mx, userId); + if (existing) { + userIdInput.value = ''; + navigate(getDirectRoomPath(existing.roomId)); + return; + } + create(userId, encryption).then((roomId) => { if (alive()) { userIdInput.value = ''; diff --git a/src/app/hooks/useAppUrlOpen.ts b/src/app/hooks/useAppUrlOpen.ts new file mode 100644 index 00000000..c3ff65ab --- /dev/null +++ b/src/app/hooks/useAppUrlOpen.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { App, type URLOpenListenerEvent } from '@capacitor/app'; +import { isNativePlatform } from '../utils/capacitor'; + +// When Capacitor's WebView is launched via an App Link (or handed a URL while +// already running), its own location is the local bundle URL — it does NOT +// navigate to the incoming https URL. We have to read the URL from the App +// plugin and feed it into react-router ourselves. Covers both cold-launch +// (getLaunchUrl) and warm taps (appUrlOpen). +export const useAppUrlOpen = (): void => { + const navigate = useNavigate(); + + useEffect(() => { + if (!isNativePlatform()) return undefined; + + let active = true; + + const route = (rawUrl: string | undefined) => { + if (!active || !rawUrl) return; + try { + const u = new URL(rawUrl); + if (u.hostname !== 'vojo.chat') return; + // Strip any ?web=1 fallback marker and forward path+hash to the SPA + // router, which owns the /u/:user route. + if (!u.pathname.startsWith('/u/')) return; + navigate(u.pathname + u.hash, { replace: true }); + } catch { + // malformed URL — ignore + } + }; + + App.getLaunchUrl().then((r) => route(r?.url)); + const listenerPromise = App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => + route(event.url) + ); + + return () => { + active = false; + listenerPromise.then((l) => l.remove()); + }; + }, [navigate]); +}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 31ef2e98..1097d8ac 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { + Navigate, Outlet, Route, createBrowserRouter, createHashRouter, createRoutesFromElements, redirect, + useParams, } from 'react-router-dom'; import { ClientConfig } from '../hooks/useClientConfig'; @@ -29,16 +31,22 @@ import { _SEARCH_PATH, _SERVER_PATH, CREATE_PATH, + USER_LINK_HOST, + USER_LINK_PATH, + DirectCreateSearchParams, } from './paths'; import { getAppPathFromHref, + getDirectCreatePath, getExploreFeaturedPath, getHomePath, getInboxNotificationsPath, getLoginPath, getOriginBaseUrl, getSpaceLobbyPath, + withSearchParam, } from './pathUtils'; +import { getMxIdServer, isUserId } from '../utils/matrix'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; @@ -75,15 +83,35 @@ import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup'; import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer'; import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications'; import { IncomingCallStripRenderer } from './IncomingCallStripRenderer'; +import { useAppUrlOpen } from '../hooks/useAppUrlOpen'; function IncomingCallsFeature() { useIncomingRtcNotifications(); useCallerAutoHangup(); usePendingCallActionConsumer(); useDismissNativeCallNotifications(); + useAppUrlOpen(); return null; } +// Deep-link entry for /u/. `` is either a bare localpart or a +// full MXID; we normalize to an MXID using USER_LINK_HOST (the deep-link's own +// host, NOT the logged-in user's homeserver — a user signed into matrix.org +// opening vojo.chat/u/test3 still means @test3:vojo.chat) and forward to the +// existing DirectCreate flow, which itself dedupes to any existing DM via +// getDMRoomFor. +function UserLinkRedirect() { + const { userIdOrLocalPart } = useParams(); + if (!userIdOrLocalPart) return ; + + const raw = decodeURIComponent(userIdOrLocalPart); + const mxid = isUserId(raw) && getMxIdServer(raw) ? raw : `@${raw}:${USER_LINK_HOST}`; + if (!isUserId(mxid)) return ; + + const params: DirectCreateSearchParams = { userId: mxid }; + return ; +} + export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; const mobile = screenSize === ScreenSize.Mobile; @@ -286,6 +314,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> + } /> . `` is either a localpart +// (test3) or a full MXID (@test3:vojo.chat); the redirect route normalizes it +// and forwards to /direct/create?userId=. The implicit homeserver for a +// bare localpart is the link's own host (vojo.chat) — NOT the logged-in user's +// homeserver: a user signed into matrix.org opening vojo.chat/u/test3 means +// @test3:vojo.chat, otherwise we'd send messages to the wrong MXID entirely. +// Keep this in sync with the App Links intent-filter in AndroidManifest.xml. +export const USER_LINK_HOST = 'vojo.chat'; +export const USER_LINK_PATH = '/u/:userIdOrLocalPart'; + export const _NOTIFICATIONS_PATH = 'notifications/'; export const _INVITES_PATH = 'invites/'; export const INBOX_PATH = '/inbox/'; diff --git a/src/app/utils/capacitor.ts b/src/app/utils/capacitor.ts index 759a6ee2..f3f24e68 100644 --- a/src/app/utils/capacitor.ts +++ b/src/app/utils/capacitor.ts @@ -22,6 +22,12 @@ export const setupExternalLinkHandler = (): (() => void) | undefined => { const handler = (e: MouseEvent) => { const anchor = (e.target as HTMLElement).closest?.('a[target="_blank"]') as HTMLAnchorElement | null; if (!anchor?.href) return; + // Mention pills are rendered as matrix.to links with data-mention-id; they + // have their own React onClick that opens an in-app profile card. Letting + // this capture-phase handler preventDefault + Browser.open() would race + // React's synthetic click and jerk the user to matrix.to in the system + // browser on native. + if (anchor.dataset.mentionId) return; e.preventDefault(); Browser.open({ url: anchor.href }); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e2..64dc68b7 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -179,17 +179,44 @@ export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) => export const eventWithShortcode = (ev: MatrixEvent) => typeof ev.getContent().shortcode === 'string'; +// Canonical DM lookup per Matrix spec: m.direct account data is the source +// of truth, not member-count heuristics and not encryption state. Mirrors +// Element's findDMForUser (matrix-react-sdk/src/utils/dm/findDMForUser.ts) +// including the cross-user fallback scan for stale peer state (PR #10127). export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => { - const dmLikeRooms = mx - .getRooms() - .filter( - (room) => - room.getMyMembership() === Membership.Join && - room.hasEncryptionStateEvent() && - room.getMembers().length <= 2 - ); + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + const mDirect = (mDirectsEvent?.getContent() ?? {}) as Record; - return dmLikeRooms.find((room) => room.getMember(userId)); + const myUserId = mx.getUserId(); + const pickSuitable = (roomIds: string[]): Room | undefined => + roomIds + .map((rid) => mx.getRoom(rid)) + .filter((room): room is Room => { + if (!room) return false; + if (room.getMyMembership() !== Membership.Join) return false; + if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return false; + // Strict 1-on-1: the set of active (joined or invited) members must be + // exactly {me, userId}. Just checking getJoinedMembers().length <= 2 + // plus getMember(userId) !== undefined is unsafe — the cross-user + // fallback below scans rooms registered under OTHER users, so a DM + // with Alice where Bob is invited-but-not-joined would match a + // getDMRoomFor(Bob) call and silently route messages to Alice's chat. + const active = room + .getMembers() + .filter( + (m) => m.membership === Membership.Join || m.membership === Membership.Invite + ); + if (active.length !== 2) return false; + const peer = active.find((m) => m.userId !== myUserId); + return !!peer && peer.userId === userId; + }) + .sort((a, b) => b.getLastActiveTimestamp() - a.getLastActiveTimestamp())[0]; + + const allDirectRoomIds = Object.keys(mDirect).reduce( + (acc, k) => acc.concat(mDirect[k] ?? []), + [] + ); + return pickSuitable(mDirect[userId] ?? []) ?? pickSuitable(allDirectRoomIds); }; export const guessDmRoomUserId = (room: Room, myUserId: string): string => {