Fix DM rooms showing as regular rooms for invited users by syncing m.direct on join and routing push navigation correctly.

This commit is contained in:
v.lagerev 2026-04-25 12:04:30 +03:00
parent 311ec4e615
commit 84eeac93d8
7 changed files with 179 additions and 46 deletions

View file

@ -0,0 +1,65 @@
1. Звонки по аудиосвязи в чатах
2. Понятное сообщение при регистрации на сервере с забаненным ником
3. Хочу скинуть ник-диплинк и чтобы сразу открылся сразу чат с юзером в приложении
4. Разобраться с текущими доменами и навести там порядок. Сейчас caddy заведен неправильно, web тоже не совсем правильно. Предложение
Тогда структура:
vojo.chat — красивый лендинг с описанием проекта, скриншоты, кнопка "Попробовать"
app.vojo.chat — сам мессенджер (Cinny)
docs.vojo.chat — документация
matrix.vojo.chat — Matrix API (технический поддомен, пользователи туда не ходят)
matrix-rtc.vojo.chat — LiveKit (технический)
5. Связанное ограничение по федерации (сейчас в env lk-jwt-service):
`LIVEKIT_FULL_ACCESS_HOMESERVERS: "vojo.chat"` — инициировать звонки могут только
юзеры `@…:vojo.chat`, федеративные — только join к существующим. При первых
реальных кросс-homeserver кейсах ослабить до `"*"` или whitelist (правка в env,
клиент не меняется).
6. Миграция unstable-префикса MSC4143 → stable, когда спека финализируется.
Сейчас `.well-known/matrix/client` объявляет RTC-фокусы под ключом
`org.matrix.msc4143.rtc_foci` (unstable prefix). Когда MSC4143 примут, ключ
станет `m.rtc_foci`. Что понадобится:
- В `~/vojo/caddy/Caddyfile` блок `.well-known/matrix/client` — добавить
оба ключа на переходный период, потом выпилить старый.
- В клиенте [src/app/hooks/useLivekitSupport.ts](src/app/hooks/useLivekitSupport.ts)
сейчас читается только `org.matrix.msc4143.rtc_foci` — расширить на оба
ключа (или дождаться matrix-js-sdk апдейта, который сделает это сам).
- Этот же паттерн относится к о всем msc-событиям в RTC-стеке: MSC4075
(`org.matrix.msc4075.rtc.notification`), MSC4195, MSC4310
(`org.matrix.msc4310.rtc.decline`). При stable-переходе спек все
префиксы поменяются синхронно — миграцию делать вместе.
7. Пофиксить follow страницы с чатами 1-1. Потому что сейчас они не автоскролятся вниз. Происходит это на нативе когда открываешь клавиатуру (она не сдвигает ui вверх как это делают обычные приложения с клавиатурой, по ощущениям просто перекрывает часть страницы сдвигая просто форму с вводом)
8. **(из Phase 2 DM-звонков, 2026-04-19)** Верифицировать на сервере: шифрует ли
Element Call widget **сам** `m.rtc.notification` при отправке в encrypted room
(ring от A → B). Теоретически SDK шифрует любой `sendEvent` в encrypted room
прозрачно, и widget должен идти через тот же путь — но `sendRtcDecline`
(наш, не widget) делегирует в `client.sendEvent` и это проверено
([client.js:2457](../../node_modules/matrix-js-sdk/lib/client.js#L2457)),
а `m.rtc.notification` widget шлёт через свой postMessage канал — не
перепроверяли. Как проверить: в encrypted DM у A нажать startCall,
в DevTools Network tab смотреть PUT на `/rooms/{roomId}/send/...` — body
должно быть encrypted (ключи `algorithm`, `ciphertext`, `sender_key`,
`session_id`), не plain JSON с `"m.rtc.notification"`. Если plain —
widget игнорит encryption, нужен апстрим-фикс Element Call либо наша
обёртка. См. [docs/plans/dm_calls_techdebt.md](../plans/dm_calls_techdebt.md) 5.9.
9. Когда открываешь чат уведомления из пуш бара не исчезают
10. Пофиксить цвет полос снизу и сверху при разных бекграундах цветовых (Сейчас они не синхронны на темной теме)
11. Как то по умнее схлопывать переходы назад. Возможно уникальные переходы схлопывать. Контекст такой: приложение сохраняет все походы юзера по экрану и так можно навигироваться без конца. Особенно это странно работает при входящих уведомлениях из одного чата: можно положить в этот стеш очень много ивентов этого окна и сидеть жать кнопку назад очень долго
12. Не очень понятно что делать со звонками в пуше на андроиде. Если пришел пуш-звонок, надо показывать на экране чата внизу сам эвент со звонком как на вебе?
13. UI friendly боты (в т.ч. телеграм бот)
14. Убрать странное читат чат
15. ~~Баг с присоединться в комнату, перекидывает не в чат, а в комнаты. При чем у одного юзера отображается чат, а у другого комната. Из-за этого нельзя позовнить например~~ — пофикшено: глобальный `useAutoDirectSync` listener, прямая навигация в Invites, smart push routing, HomeRouteRoomProvider redirect.
16. **Тех-долг:** one-shot repair для исторически сломанных DM-комнат. После фикса #15 новые join-ы корректно обновляют `m.direct`, но комнаты у юзеров, которые уже приняли инвайт до фикса, остаются в "Комнатах". Нужна миграция или команда `/repairDMs`, которая пройдёт по joined rooms, проверит `getDMInviter()`, и добавит в `m.direct`. Осложнение: `prev_content.is_direct` не исчезает после `/converttoroom`, поэтому наивный sweep откатит ручную конвертацию — нужен tombstone-маркер или whitelist.

View file

@ -0,0 +1,39 @@
import { MatrixClient, Room, RoomEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { Membership } from '../../types/matrix/room';
import { getAccountData, getMDirects } from '../utils/room';
import { addRoomIdToMDirect } from '../utils/matrix';
export function useAutoDirectSync(mx: MatrixClient): void {
useEffect(() => {
const handleMembership = (
room: Room,
_membership: string,
prevMembership?: string
) => {
if (prevMembership !== Membership.Invite) return;
if (room.getMyMembership() !== Membership.Join) return;
const dmInviter = room.getDMInviter();
if (!dmInviter) return;
const joinedAndInvited =
room.getJoinedMemberCount() + room.getInvitedMemberCount();
if (joinedAndInvited > 2) return;
const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct);
if (mDirectEvent) {
const directs = getMDirects(mDirectEvent);
if (directs.has(room.roomId)) return;
}
addRoomIdToMDirect(mx, room.roomId, dmInviter).catch(() => undefined);
};
mx.on(RoomEvent.MyMembership, handleMembership);
return () => {
mx.removeListener(RoomEvent.MyMembership, handleMembership);
};
}, [mx]);
}

View file

@ -20,8 +20,9 @@ import {
unregisterPusher, unregisterPusher,
urlBase64ToUint8Array, urlBase64ToUint8Array,
} from '../utils/push'; } from '../utils/push';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils'; import { getDirectRoomPath, getInboxInvitesPath } from '../pages/pathUtils';
import { pendingCallActionAtom } from '../state/pendingCallAction'; import { pendingCallActionAtom } from '../state/pendingCallAction';
import { useRoomNavigate } from './useRoomNavigate';
const noop = (): void => undefined; const noop = (): void => undefined;
@ -274,6 +275,7 @@ export function usePushNotificationsLifecycle(): void {
const mx = useMatrixClient(); const mx = useMatrixClient();
const clientConfig = useClientConfig(); const clientConfig = useClientConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate();
const setPendingCallAction = useSetAtom(pendingCallActionAtom); const setPendingCallAction = useSetAtom(pendingCallActionAtom);
useEffect(() => { useEffect(() => {
@ -300,7 +302,7 @@ export function usePushNotificationsLifecycle(): void {
navigate(getInboxInvitesPath()); navigate(getInboxInvitesPath());
return; return;
} }
if (detail?.roomId) navigate(getHomeRoomPath(detail.roomId)); if (detail?.roomId) navigateRoom(detail.roomId);
}; };
const onSubChange = () => { const onSubChange = () => {
if (isPushEnabled()) register().catch(noop); if (isPushEnabled()) register().catch(noop);
@ -312,7 +314,7 @@ export function usePushNotificationsLifecycle(): void {
window.removeEventListener('vojo:pushNavigate', onNavigate); window.removeEventListener('vojo:pushNavigate', onNavigate);
window.removeEventListener('vojo:pushSubscriptionChange', onSubChange); window.removeEventListener('vojo:pushSubscriptionChange', onSubChange);
}; };
}, [navigate, register]); }, [navigate, navigateRoom, register]);
useEffect(() => { useEffect(() => {
if (!isNativePlatform()) return undefined; if (!isNativePlatform()) return undefined;
@ -383,7 +385,7 @@ export function usePushNotificationsLifecycle(): void {
return; return;
} }
if (data.room_id) navigate(getHomeRoomPath(data.room_id)); if (data.room_id) navigateRoom(data.room_id);
} }
); );
}) })
@ -473,7 +475,7 @@ export function usePushNotificationsLifecycle(): void {
cancelled = true; cancelled = true;
cleanups.forEach((c) => c()); cleanups.forEach((c) => c());
}; };
}, [navigate, mx, clientConfig, setPendingCallAction]); }, [navigate, navigateRoom, mx, clientConfig, setPendingCallAction]);
} }
export { isPushEnabled, getPushPlatform } from '../utils/push'; export { isPushEnabled, getPushPlatform } from '../utils/push';

View file

@ -1,21 +1,31 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useHomeRooms } from './useHomeRooms'; import { useHomeRooms } from './useHomeRooms';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers'; import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
import { mDirectAtom } from '../../../state/mDirectList';
import { getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const rooms = useHomeRooms(); const rooms = useHomeRooms();
const mDirects = useAtomValue(mDirectAtom);
const { roomIdOrAlias, eventId } = useParams(); const { roomIdOrAlias, eventId } = useParams();
const viaServers = useSearchParamsViaServers(); const viaServers = useSearchParamsViaServers();
const roomId = useSelectedRoom(); const roomId = useSelectedRoom();
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (room && mDirects.has(room.roomId)) {
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
}
if (!room || !rooms.includes(room.roomId)) { if (!room || !rooms.includes(room.roomId)) {
return ( return (
<JoinBeforeNavigate <JoinBeforeNavigate

View file

@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next';
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types'; import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk'; import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
import { useNavigate } from 'react-router-dom';
import { import {
Page, Page,
PageContent, PageContent,
@ -48,10 +49,12 @@ import { nameInitials } from '../../../utils/common';
import { RoomAvatar } from '../../../components/room-avatar'; import { RoomAvatar } from '../../../components/room-avatar';
import { import {
addRoomIdToMDirect, addRoomIdToMDirect,
getCanonicalAliasOrRoomId,
getMxIdLocalPart, getMxIdLocalPart,
guessDmRoomUserId, guessDmRoomUserId,
rateLimitedActions, rateLimitedActions,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
import { getDirectRoomPath } from '../../pathUtils';
import { Time } from '../../../components/message'; import { Time } from '../../../components/message';
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
@ -162,6 +165,7 @@ function InviteCard({
}: InviteCardProps) { }: InviteCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate();
const userId = mx.getSafeUserId(); const userId = mx.getSafeUserId();
const [viewTopic, setViewTopic] = useState(false); const [viewTopic, setViewTopic] = useState(false);
@ -177,9 +181,12 @@ function InviteCard({
await mx.joinRoom(invite.roomId); await mx.joinRoom(invite.roomId);
if (dmUserId) { if (dmUserId) {
await addRoomIdToMDirect(mx, invite.roomId, dmUserId); await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
const alias = getCanonicalAliasOrRoomId(mx, invite.roomId);
navigate(getDirectRoomPath(alias));
return;
} }
onNavigate(invite.roomId, invite.isSpace); onNavigate(invite.roomId, invite.isSpace);
}, [mx, invite, userId, onNavigate]) }, [mx, invite, userId, onNavigate, navigate])
); );
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>( const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
useCallback(() => mx.leave(invite.roomId), [mx, invite]) useCallback(() => mx.leave(invite.roomId), [mx, invite])

View file

@ -5,9 +5,11 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread'; import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { useAutoDirectSync } from '../../hooks/useAutoDirectSync';
export const useBindAtoms = (mx: MatrixClient) => { export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom); useBindMDirectAtom(mx, mDirectAtom);
useAutoDirectSync(mx);
useBindAllInvitesAtom(mx, allInvitesAtom); useBindAllInvitesAtom(mx, allInvitesAtom);
useBindAllRoomsAtom(mx, allRoomsAtom); useBindAllRoomsAtom(mx, allRoomsAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom); useBindRoomToParentsAtom(mx, roomToParentsAtom);

View file

@ -252,19 +252,26 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => {
return member1?.userId ?? myUserId; return member1?.userId ?? myUserId;
}; };
export const addRoomIdToMDirect = async ( const mDirectQueues = new WeakMap<MatrixClient, Promise<void>>();
const enqueueMDirectWrite = (mx: MatrixClient, fn: () => Promise<void>): Promise<void> => {
const prev = mDirectQueues.get(mx) ?? Promise.resolve();
const next = prev.then(fn, fn);
mDirectQueues.set(mx, next);
return next;
};
export const addRoomIdToMDirect = (
mx: MatrixClient, mx: MatrixClient,
roomId: string, roomId: string,
userId: string userId: string
): Promise<void> => { ): Promise<void> =>
enqueueMDirectWrite(mx, async () => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {}; let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined') if (typeof mDirectsEvent !== 'undefined')
userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
// remove it from the lists of any others users
// (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((targetUserId) => { Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId]; const roomIds = userIdToRoomIds[targetUserId];
@ -283,9 +290,10 @@ export const addRoomIdToMDirect = async (
userIdToRoomIds[userId] = roomIds; userIdToRoomIds[userId] = roomIds;
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
}; });
export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise<void> => { export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise<void> =>
enqueueMDirectWrite(mx, async () => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any);
let userIdToRoomIds: Record<string, string[]> = {}; let userIdToRoomIds: Record<string, string[]> = {};
@ -301,7 +309,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
}); });
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any);
}; });
export const mxcUrlToHttp = ( export const mxcUrlToHttp = (
mx: MatrixClient, mx: MatrixClient,