From 84eeac93d831bb56a636fdc56c95810040cc4dfa Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sat, 25 Apr 2026 12:04:30 +0300 Subject: [PATCH] Fix DM rooms showing as regular rooms for invited users by syncing m.direct on join and routing push navigation correctly. --- docs/ai/desired_features.md | 65 ++++++++++++++++ src/app/hooks/useAutoDirectSync.ts | 39 ++++++++++ src/app/hooks/usePushNotifications.ts | 12 +-- src/app/pages/client/home/RoomProvider.tsx | 12 ++- src/app/pages/client/inbox/Invites.tsx | 9 ++- src/app/state/hooks/useBindAtoms.ts | 2 + src/app/utils/matrix.ts | 86 ++++++++++++---------- 7 files changed, 179 insertions(+), 46 deletions(-) create mode 100644 docs/ai/desired_features.md create mode 100644 src/app/hooks/useAutoDirectSync.ts diff --git a/docs/ai/desired_features.md b/docs/ai/desired_features.md new file mode 100644 index 00000000..5eda4399 --- /dev/null +++ b/docs/ai/desired_features.md @@ -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. \ No newline at end of file diff --git a/src/app/hooks/useAutoDirectSync.ts b/src/app/hooks/useAutoDirectSync.ts new file mode 100644 index 00000000..6ed91c9c --- /dev/null +++ b/src/app/hooks/useAutoDirectSync.ts @@ -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]); +} diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 29cd55e0..dcf3bb22 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -20,8 +20,9 @@ import { unregisterPusher, urlBase64ToUint8Array, } from '../utils/push'; -import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils'; +import { getDirectRoomPath, getInboxInvitesPath } from '../pages/pathUtils'; import { pendingCallActionAtom } from '../state/pendingCallAction'; +import { useRoomNavigate } from './useRoomNavigate'; const noop = (): void => undefined; @@ -274,6 +275,7 @@ export function usePushNotificationsLifecycle(): void { const mx = useMatrixClient(); const clientConfig = useClientConfig(); const navigate = useNavigate(); + const { navigateRoom } = useRoomNavigate(); const setPendingCallAction = useSetAtom(pendingCallActionAtom); useEffect(() => { @@ -300,7 +302,7 @@ export function usePushNotificationsLifecycle(): void { navigate(getInboxInvitesPath()); return; } - if (detail?.roomId) navigate(getHomeRoomPath(detail.roomId)); + if (detail?.roomId) navigateRoom(detail.roomId); }; const onSubChange = () => { if (isPushEnabled()) register().catch(noop); @@ -312,7 +314,7 @@ export function usePushNotificationsLifecycle(): void { window.removeEventListener('vojo:pushNavigate', onNavigate); window.removeEventListener('vojo:pushSubscriptionChange', onSubChange); }; - }, [navigate, register]); + }, [navigate, navigateRoom, register]); useEffect(() => { if (!isNativePlatform()) return undefined; @@ -383,7 +385,7 @@ export function usePushNotificationsLifecycle(): void { 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; cleanups.forEach((c) => c()); }; - }, [navigate, mx, clientConfig, setPendingCallAction]); + }, [navigate, navigateRoom, mx, clientConfig, setPendingCallAction]); } export { isPushEnabled, getPushPlatform } from '../utils/push'; diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 4e16f797..56db4fb8 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -1,21 +1,31 @@ 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 { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { JoinBeforeNavigate } from '../../../features/join-before-navigate'; import { useHomeRooms } from './useHomeRooms'; 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 }) { const mx = useMatrixClient(); const rooms = useHomeRooms(); + const mDirects = useAtomValue(mDirectAtom); const { roomIdOrAlias, eventId } = useParams(); const viaServers = useSearchParamsViaServers(); const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + if (room && mDirects.has(room.roomId)) { + const alias = getCanonicalAliasOrRoomId(mx, room.roomId); + return ; + } + if (!room || !rooms.includes(room.roomId)) { return ( , MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts index d4572ff4..98dc0944 100644 --- a/src/app/state/hooks/useBindAtoms.ts +++ b/src/app/state/hooks/useBindAtoms.ts @@ -5,9 +5,11 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList'; import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread'; import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents'; import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers'; +import { useAutoDirectSync } from '../../hooks/useAutoDirectSync'; export const useBindAtoms = (mx: MatrixClient) => { useBindMDirectAtom(mx, mDirectAtom); + useAutoDirectSync(mx); useBindAllInvitesAtom(mx, allInvitesAtom); useBindAllRoomsAtom(mx, allRoomsAtom); useBindRoomToParentsAtom(mx, roomToParentsAtom); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 64dc68b7..9ad41da0 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -252,57 +252,65 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -export const addRoomIdToMDirect = async ( +const mDirectQueues = new WeakMap>(); +const enqueueMDirectWrite = (mx: MatrixClient, fn: () => Promise): Promise => { + const prev = mDirectQueues.get(mx) ?? Promise.resolve(); + const next = prev.then(fn, fn); + mDirectQueues.set(mx, next); + return next; +}; + +export const addRoomIdToMDirect = ( mx: MatrixClient, roomId: string, userId: string -): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; +): Promise => + enqueueMDirectWrite(mx, async () => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + if (typeof mDirectsEvent !== 'undefined') + 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) => { - const roomIds = userIdToRoomIds[targetUserId]; + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; - if (targetUserId !== userId) { + if (targetUserId !== userId) { + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } + } + }); + + const roomIds = userIdToRoomIds[userId] || []; + if (roomIds.indexOf(roomId) === -1) { + roomIds.push(roomId); + } + userIdToRoomIds[userId] = roomIds; + + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); + }); + +export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => + enqueueMDirectWrite(mx, async () => { + const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); + let userIdToRoomIds: Record = {}; + + if (typeof mDirectsEvent !== 'undefined') + userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); + + Object.keys(userIdToRoomIds).forEach((targetUserId) => { + const roomIds = userIdToRoomIds[targetUserId]; const indexOfRoomId = roomIds.indexOf(roomId); if (indexOfRoomId > -1) { roomIds.splice(indexOfRoomId, 1); } - } + }); + + await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); }); - const roomIds = userIdToRoomIds[userId] || []; - if (roomIds.indexOf(roomId) === -1) { - roomIds.push(roomId); - } - userIdToRoomIds[userId] = roomIds; - - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - -export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } - }); - - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string,