user dm links
This commit is contained in:
parent
6000596c54
commit
5adbe294ef
8 changed files with 177 additions and 10 deletions
|
|
@ -29,6 +29,23 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<provider
|
||||
|
|
|
|||
25
index.html
25
index.html
|
|
@ -89,6 +89,31 @@
|
|||
<script>
|
||||
window.global ||= window;
|
||||
</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="portalContainer"></div>
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
43
src/app/hooks/useAppUrlOpen.ts
Normal file
43
src/app/hooks/useAppUrlOpen.ts
Normal 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]);
|
||||
};
|
||||
|
|
@ -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/<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) => {
|
||||
const { hashRouter } = clientConfig;
|
||||
const mobile = screenSize === ScreenSize.Mobile;
|
||||
|
|
@ -286,6 +314,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<Route path={_SERVER_PATH} element={<PublicRooms />} />
|
||||
</Route>
|
||||
<Route path={CREATE_PATH} element={<Create />} />
|
||||
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
|
||||
<Route
|
||||
path={INBOX_PATH}
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -75,6 +75,16 @@ export const EXPLORE_SERVER_PATH = `/explore/${_SERVER_PATH}`;
|
|||
|
||||
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 _INVITES_PATH = 'invites/';
|
||||
export const INBOX_PATH = '/inbox/';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<string, string[]>;
|
||||
|
||||
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 => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue