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
+2
View File
@@ -199,6 +199,8 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
const message = parseMessagePayload(event.d);
if (message !== null) {
messages.applyMessageCreate(message);
// В личной беседе «последнее сообщение» — источник бейджа и порядка.
session.touchDmChannel(message.channel_id, message.id, message.created_at);
}
return;
}
+15
View File
@@ -47,6 +47,8 @@ interface SessionState {
upsertDmChannel: (channel: Channel) => void;
/** PRESENCE_UPDATE: статус собеседника в списке бесед и шапке чата. */
patchDmPresence: (userId: string, status: UserStatus | undefined) => void;
/** MESSAGE_CREATE в беседе: сдвигаем «последнее сообщение» (бейджи и порядок). */
touchDmChannel: (channelId: string, messageId: string, at: string) => void;
/** DM_CHANNEL_CREATE: перечитываем беседы по REST. */
reloadDmChannels: () => Promise<void>;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
@@ -269,6 +271,19 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
touchDmChannel: (channelId, messageId, at) => {
if (!get().dmChannels.some((channel) => channel.id === channelId)) {
return;
}
set({
dmChannels: get().dmChannels.map((channel) =>
channel.id === channelId
? { ...channel, last_message_id: messageId, last_message_at: at }
: channel,
),
});
},
reloadDmChannels: async () => {
try {
set({ dmChannels: await fetchDirectChannels() });