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,