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 => {