user dm links
This commit is contained in:
parent
13ec1e0054
commit
6b8228cdca
8 changed files with 177 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
25
index.html
25
index.html
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
|
|
|
||||||
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 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={
|
||||||
|
|
|
||||||
|
|
@ -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/';
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue