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
+7
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { removeRelationship, type RelationshipUser } from '@/api/friends';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { Popover } from '@/components/ui/Popover';
import { Button } from '@/components/ui/primitives';
@@ -131,6 +132,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
</Popover>
{openDm.isError || remove.isError ? (
<span className="absolute right-2 top-full z-10 w-64">
<ErrorNotice error={openDm.error ?? remove.error} />
</span>
) : null}
<Menu
open={menuOpen}
label={t('friends.list.menu', { name: displayName })}
+1 -1
View File
@@ -64,7 +64,7 @@ export function FriendsView() {
aria-expanded={searchOpen}
onClick={() => setSearchOpen((value) => !value)}
>
{t('friends.empty.search')}
{t('friends.search.open')}
</Button>
</header>
+17 -10
View File
@@ -6,10 +6,14 @@ import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stor
/**
* Непрочитанные личной беседы.
*
* Для беседы с загруженной историей счётчик считается как у комнат
* (`unreadCount`). Пока история не загружена, сравниваем `last_message_id`
* канала с отметкой чтения из READY/`READ_STATE_UPDATE`: точного числа в этом
* случае нет, поэтому `exact = false` — в сайдбаре рисуем точку, а не цифру.
* Два сигнала, как у комнат:
* - загруженная история — точное число (`unreadCount`);
* - `last_message_id` канала против отметки чтения из READY /
* `READ_STATE_UPDATE` — когда история ещё не загружена, точного числа нет,
* поэтому в сайдбаре рисуем точку (`exact = false`), а не цифру.
*
* Сигналы объединяются через максимум: сообщение в уже открытой беседе могло
* прийти событием, когда история загружена, а отметки чтения ещё нет.
*/
export function useDmUnread(channel: Channel): {
unread: number;
@@ -22,14 +26,17 @@ export function useDmUnread(channel: Channel): {
[channel.id],
),
);
// Отметки чтения известны после снапшота READY: отсутствие записи для
// беседы означает «не читалась».
const snapshotApplied = useMessagesStore((state) => state.snapshotApplied);
const mentions = record?.mentionCount ?? 0;
if (record !== undefined && record.messages.length > 0) {
return { unread: unreadCount(record), mentions, exact: true };
}
const known = record?.readStateKnown === true;
const hasHistory = record !== undefined && record.messages.length > 0;
const fromHistory = hasHistory ? unreadCount(record) : 0;
const known = record?.readStateKnown === true || snapshotApplied;
const marker = record?.lastReadMessageId ?? null;
const hasNewer = channel.last_message_id !== undefined && channel.last_message_id !== marker;
return { unread: known && hasNewer ? 1 : 0, mentions, exact: false };
const unread = Math.max(fromHistory, known && hasNewer ? 1 : 0);
return { unread, mentions, exact: fromHistory > 0 };
}