feat(web): друзья, личные беседы и вход только по приглашению

Раздел «Друзья» по запросу пользователя:

- первая иконка рейки — «Друзья», серверы ниже за разделителем; личные беседы
  не засоряют список серверов;
- сайдбар друзей: беседы с точками статуса и бейджами непрочитанного,
  список друзей с кликом на беседу;
- контент: пустое состояние «Добавь друзей для общения» с поиском, поиск по
  логину (debounce), добавление в друзья, входящие и исходящие заявки,
  меню «…» (удалить/заблокировать), поповер последнего входа в часовом поясе
  пользователя (по умолчанию МСК), блок «Присоединиться по приглашению»
  с предпросмотром сервера;
- личный чат на существующих компонентах: права по can_send канала, заголовок
  с именем и статусом собеседника, поиск, пины, ack и typing работают;
- realtime: READY dm_channels, события DM_CHANNEL_CREATE, RELATIONSHIP_UPDATE,
  PRESENCE_UPDATE и MESSAGE_CREATE в беседах;
- новый пользователь: «У вас пока нет серверов», создание сервера и вход по
  приглашению (автовступления нет);
- 239 тестов Vitest (было 219), тест паритета ключей i18n (527 ключей).
This commit is contained in:
2026-09-20 01:52:27 +03:00
parent d02ef7527a
commit bcfc275bb3
15 changed files with 967 additions and 87 deletions
+39 -9
View File
@@ -7,6 +7,7 @@ import { GatewayOp } from '@/api/gateway';
import type { Message } from '@/api/messages';
import { routes } from '@/router';
import { setQueryClient } from '@/lib/queryClient';
import { useFriendsStore } from '@/stores/friends';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
@@ -120,19 +121,17 @@ export function resetStores(): void {
useSessionStore.getState().reset();
useMessagesStore.getState().reset();
useVoiceStore.getState().reset();
useFriendsStore.getState().reset();
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
useUiStore.setState({
guildCreateOpen: false,
settingsOpen: false,
settingsSection: 'profile',
settingsProfileTab: 'main',
});
useGatewayStore.setState({
status: 'idle',
heartbeatIntervalMs: null,
sessionId: null,
attempts: 0,
invalidated: false,
});
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
// (переподключение, статус) меняли бы стор уже следующего.
useGatewayStore.getState().reset();
}
/** Рендер приложения на заданном маршруте с изолированными сторами. */
@@ -206,8 +205,24 @@ interface SnapshotGuildInput {
}[];
}
/** Личная беседа в снапшоте READY. */
export interface SnapshotDmInput {
id: string;
/** Имя собеседника (сервер отдаёт display_name в поле `name`). */
name: string;
recipient_id?: string;
icon_file_id?: string;
status?: string;
last_message_id?: string;
last_message_at?: string;
can_send?: boolean;
}
/** Снапшот READY в формате шлюза. */
export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
export function makeReadySnapshot(
guilds: SnapshotGuildInput[] = [],
dmChannels: SnapshotDmInput[] = [],
) {
return {
user: {
id: 'user-1',
@@ -237,7 +252,22 @@ export function makeReadySnapshot(guilds: SnapshotGuildInput[] = []) {
my_role_ids: [],
my_permissions: guild.my_permissions ?? [],
})),
dm_channels: [],
dm_channels: dmChannels.map((channel) => ({
id: channel.id,
type: 'dm',
name: channel.name,
position: 0,
...(channel.recipient_id === undefined ? {} : { recipient_id: channel.recipient_id }),
...(channel.icon_file_id === undefined ? {} : { icon_file_id: channel.icon_file_id }),
...(channel.status === undefined ? {} : { status: channel.status }),
...(channel.last_message_id === undefined
? {}
: { last_message_id: channel.last_message_id }),
...(channel.last_message_at === undefined
? {}
: { last_message_at: channel.last_message_at }),
can_send: channel.can_send ?? true,
})),
read_states: [],
session_id: 'session-1',
heartbeat_interval_ms: 45_000,