user dm links

This commit is contained in:
heaven 2026-04-21 21:11:32 +03:00
parent 13ec1e0054
commit 6b8228cdca
8 changed files with 177 additions and 10 deletions

View file

@ -29,6 +29,23 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- App Links for https://vojo.chat/u/<user>. autoVerify=true lets
Chrome/Gmail/SMS hand the tap straight to this activity; the
server must publish a matching /.well-known/assetlinks.json
over HTTPS with the installed APK's signing SHA-256. Telegram
and other in-app browsers ignore verification and load the
URL in their own webview — the intent-URL redirect injected
in index.html covers that case. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="vojo.chat"
android:pathPrefix="/u/" />
</intent-filter>
</activity> </activity>
<provider <provider

View file

@ -89,6 +89,31 @@
<script> <script>
window.global ||= window; window.global ||= window;
</script> </script>
<script>
// Deep-link handoff for https://vojo.chat/u/<user> clicked from in-app
// browsers (Telegram etc.) that don't honor verified App Links. Fires
// BEFORE the React bundle: we try to pop out of the webview via an
// intent:// URL that includes a browser_fallback_url back to the SPA
// with ?web=1 so users without the app land on the normal page and we
// don't loop. Guards: only on vojo.chat (not Capacitor's localhost),
// only on Android, only for /u/ paths, and not when ?web=1 is set.
(function () {
try {
if (location.hostname !== 'vojo.chat') return;
if (!location.pathname.indexOf || location.pathname.indexOf('/u/') !== 0) return;
if (location.search.indexOf('web=1') !== -1) return;
if (!/Android/.test(navigator.userAgent || '')) return;
if (window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform()) return;
var pathAndQuery = location.pathname + location.search + location.hash;
var fallback = 'https://vojo.chat' + location.pathname +
(location.search ? location.search + '&web=1' : '?web=1') + location.hash;
var intent = 'intent://vojo.chat' + pathAndQuery +
'#Intent;scheme=https;package=chat.vojo.app;S.browser_fallback_url=' +
encodeURIComponent(fallback) + ';end';
location.replace(intent);
} catch (e) {}
})();
</script>
<div id="root"></div> <div id="root"></div>
<div id="portalContainer"></div> <div id="portalContainer"></div>
<script type="module" src="./src/index.tsx"></script> <script type="module" src="./src/index.tsx"></script>

View file

@ -5,7 +5,7 @@ import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-j
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SettingTile } from '../../components/setting-tile'; import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card'; 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 { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
@ -66,6 +66,16 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
return; 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) => { create(userId, encryption).then((roomId) => {
if (alive()) { if (alive()) {
userIdInput.value = ''; userIdInput.value = '';

View file

@ -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]);
};

View file

@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { import {
Navigate,
Outlet, Outlet,
Route, Route,
createBrowserRouter, createBrowserRouter,
createHashRouter, createHashRouter,
createRoutesFromElements, createRoutesFromElements,
redirect, redirect,
useParams,
} from 'react-router-dom'; } from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig'; import { ClientConfig } from '../hooks/useClientConfig';
@ -29,16 +31,22 @@ import {
_SEARCH_PATH, _SEARCH_PATH,
_SERVER_PATH, _SERVER_PATH,
CREATE_PATH, CREATE_PATH,
USER_LINK_HOST,
USER_LINK_PATH,
DirectCreateSearchParams,
} from './paths'; } from './paths';
import { import {
getAppPathFromHref, getAppPathFromHref,
getDirectCreatePath,
getExploreFeaturedPath, getExploreFeaturedPath,
getHomePath, getHomePath,
getInboxNotificationsPath, getInboxNotificationsPath,
getLoginPath, getLoginPath,
getOriginBaseUrl, getOriginBaseUrl,
getSpaceLobbyPath, getSpaceLobbyPath,
withSearchParam,
} from './pathUtils'; } from './pathUtils';
import { getMxIdServer, isUserId } from '../utils/matrix';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
@ -75,15 +83,35 @@ import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer'; import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications'; import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications';
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer'; import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
function IncomingCallsFeature() { function IncomingCallsFeature() {
useIncomingRtcNotifications(); useIncomingRtcNotifications();
useCallerAutoHangup(); useCallerAutoHangup();
usePendingCallActionConsumer(); usePendingCallActionConsumer();
useDismissNativeCallNotifications(); useDismissNativeCallNotifications();
useAppUrlOpen();
return null; return null;
} }
// Deep-link entry for /u/<user>. `<user>` 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 <Navigate to={getHomePath()} replace />;
const raw = decodeURIComponent(userIdOrLocalPart);
const mxid = isUserId(raw) && getMxIdServer(raw) ? raw : `@${raw}:${USER_LINK_HOST}`;
if (!isUserId(mxid)) return <Navigate to={getHomePath()} replace />;
const params: DirectCreateSearchParams = { userId: mxid };
return <Navigate to={withSearchParam(getDirectCreatePath(), params)} replace />;
}
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig; const { hashRouter } = clientConfig;
const mobile = screenSize === ScreenSize.Mobile; const mobile = screenSize === ScreenSize.Mobile;
@ -286,6 +314,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_SERVER_PATH} element={<PublicRooms />} /> <Route path={_SERVER_PATH} element={<PublicRooms />} />
</Route> </Route>
<Route path={CREATE_PATH} element={<Create />} /> <Route path={CREATE_PATH} element={<Create />} />
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
<Route <Route
path={INBOX_PATH} path={INBOX_PATH}
element={ element={

View file

@ -75,6 +75,16 @@ export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
export const CREATE_PATH = '/create'; export const CREATE_PATH = '/create';
// Deep-link path for https://vojo.chat/u/<user>. `<user>` is either a localpart
// (test3) or a full MXID (@test3:vojo.chat); the redirect route normalizes it
// and forwards to /direct/create?userId=<mxid>. 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 _NOTIFICATIONS_PATH = 'notifications/';
export const _INVITES_PATH = 'invites/'; export const _INVITES_PATH = 'invites/';
export const INBOX_PATH = '/inbox/'; export const INBOX_PATH = '/inbox/';

View file

@ -22,6 +22,12 @@ export const setupExternalLinkHandler = (): (() => void) | undefined => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest?.('a[target="_blank"]') as HTMLAnchorElement | null; const anchor = (e.target as HTMLElement).closest?.('a[target="_blank"]') as HTMLAnchorElement | null;
if (!anchor?.href) return; 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(); e.preventDefault();
Browser.open({ url: anchor.href }); Browser.open({ url: anchor.href });

View file

@ -179,17 +179,44 @@ export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
export const eventWithShortcode = (ev: MatrixEvent) => export const eventWithShortcode = (ev: MatrixEvent) =>
typeof ev.getContent().shortcode === 'string'; 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 => { export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => {
const dmLikeRooms = mx const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
.getRooms() const mDirect = (mDirectsEvent?.getContent() ?? {}) as Record<string, string[]>;
.filter(
(room) =>
room.getMyMembership() === Membership.Join &&
room.hasEncryptionStateEvent() &&
room.getMembers().length <= 2
);
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<string[]>(
(acc, k) => acc.concat(mDirect[k] ?? []),
[]
);
return pickSuitable(mDirect[userId] ?? []) ?? pickSuitable(allDirectRoomIds);
}; };
export const guessDmRoomUserId = (room: Room, myUserId: string): string => { export const guessDmRoomUserId = (room: Room, myUserId: string): string => {