diff --git a/web/src/components/friends/FriendRow.tsx b/web/src/components/friends/FriendRow.tsx
index e1dac59..79e472b 100644
--- a/web/src/components/friends/FriendRow.tsx
+++ b/web/src/components/friends/FriendRow.tsx
@@ -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 }) {
{hint}
+ {openDm.isError || remove.isError ? (
+
+
+
+ ) : null}
+
{direct.map((channel) => (
diff --git a/web/src/pages/app/GuildRail.tsx b/web/src/pages/app/GuildRail.tsx
index 4a91b07..ddd07ec 100644
--- a/web/src/pages/app/GuildRail.tsx
+++ b/web/src/pages/app/GuildRail.tsx
@@ -52,7 +52,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
-
+
{guilds.map((guild) => {
diff --git a/web/src/pages/app/NoGuildView.tsx b/web/src/pages/app/NoGuildView.tsx
index f62546e..f47ce09 100644
--- a/web/src/pages/app/NoGuildView.tsx
+++ b/web/src/pages/app/NoGuildView.tsx
@@ -22,7 +22,7 @@ export default function NoGuildView() {
{t('guilds.empty.body')}
diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts
index cfe61f8..9eeb7a5 100644
--- a/web/src/stores/gateway.ts
+++ b/web/src/stores/gateway.ts
@@ -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;
}
diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts
index 55ffee4..73b6eb5 100644
--- a/web/src/stores/session.ts
+++ b/web/src/stores/session.ts
@@ -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;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
@@ -269,6 +271,19 @@ export const useSessionStore = create((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() });
diff --git a/web/tests/friends.test.tsx b/web/tests/friends.test.tsx
new file mode 100644
index 0000000..3b9ef76
--- /dev/null
+++ b/web/tests/friends.test.tsx
@@ -0,0 +1,802 @@
+import { describe, expect, it } from 'vitest';
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import type { RelationshipUser, Relationships } from '@/api/friends';
+import type { User } from '@/api/types';
+import {
+ findRequest,
+ installFetch,
+ installGatewaySocket,
+ json,
+ makeMessage,
+ makeReadySnapshot,
+ makeUser,
+ messagesRoutes,
+ recordedRequests,
+ renderApp,
+ type FetchRoute,
+} from './helpers';
+
+/**
+ * Раздел «Друзья» и личные беседы: рейка, пустое состояние, поиск, заявки,
+ * статусы, поповер последнего входа, DM-чат, приглашения и события шлюза.
+ */
+
+const user = makeUser();
+
+/** Инстанс с «главным» сервером: автовступления в него больше быть не должно. */
+const instance = {
+ instance: {
+ name: 'glchat-test',
+ registration_enabled: true,
+ allow_guild_creation: true,
+ max_guilds_per_user: 5,
+ main_guild_id: 'g-main',
+ },
+};
+
+const empty: Relationships = { friends: [], incoming: [], outgoing: [], blocked: [] };
+
+/** Пользователь со связью: значения по умолчанию — «друг в сети». */
+function relationship(
+ overrides: Partial & { user_id: string },
+): RelationshipUser {
+ const username = overrides.username ?? overrides.user_id;
+ return {
+ username,
+ display_name: username,
+ status: 'online',
+ visible_status: 'online',
+ is_instance_admin: false,
+ badges: [],
+ timezone: 'Europe/Moscow',
+ relationship: 'friend',
+ ...overrides,
+ };
+}
+
+const bob = relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' });
+
+/** Базовые ответы оболочки: профиль, инстанс, серверы, связи. */
+function baseRoutes(
+ relationships: Relationships | (() => Relationships) = empty,
+ extra: FetchRoute[] = [],
+): FetchRoute[] {
+ return [
+ ...extra,
+ {
+ match: '/api/v1/users/@me/relationships',
+ response: () => json(typeof relationships === 'function' ? relationships() : relationships),
+ },
+ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
+ { match: '/api/v1/users/@me/channels', response: () => json({ channels: [] }) },
+ { match: '/api/v1/users/@me', response: () => json({ user }) },
+ { match: '/api/v1/instance', response: () => json(instance) },
+ ];
+}
+
+/** Личная беседа в REST-форме (`GET /users/@me/channels`). */
+function directChannel(id: string, displayName = 'Боб', status = 'online') {
+ return {
+ id,
+ type: 'dm',
+ can_send: true,
+ can_view: true,
+ recipient: {
+ user_id: 'user-2',
+ username: 'bob',
+ display_name: displayName,
+ status,
+ visible_status: status,
+ timezone: 'Europe/Moscow',
+ },
+ };
+}
+
+/**
+ * Событие шлюза внутри `act`: обработчик обновляет сторы, а часть работы
+ * (перечитывание списков) завершается промисами.
+ */
+async function dispatchEvent(
+ gateway: ReturnType,
+ type: string,
+ payload: unknown,
+): Promise {
+ await act(async () => {
+ gateway.dispatch(type, payload);
+ await Promise.resolve();
+ });
+}
+
+describe('раздел друзей', () => {
+ it('первой иконкой рейки открывает раздел друзей', async () => {
+ const visitor = userEvent.setup();
+ const snapshot = makeReadySnapshot([
+ { id: 'g-1', name: 'Сервер', channels: [{ id: 'c-1', name: 'общий' }] },
+ ]);
+ installFetch(baseRoutes(empty, messagesRoutes('c-1', [])));
+ const gateway = installGatewaySocket(snapshot);
+
+ const { router } = renderApp('/app/g-1/c-1');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+ await screen.findByLabelText('Открыть комнату общий');
+
+ // Друзья — выше списка серверов.
+ const friendsIcon = screen.getByTestId('friends-rail-link');
+ expect(friendsIcon.compareDocumentPosition(screen.getByTestId('guild-rail'))).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(friendsIcon).toHaveAccessibleName('Друзья');
+
+ await visitor.click(friendsIcon);
+
+ await waitFor(() => {
+ expect(router.state.location.pathname).toBe('/app/friends');
+ });
+ // Сайдбар раздела: заголовок «Друзья».
+ expect(await screen.findByRole('heading', { level: 1, name: 'Друзья' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('без друзей и бесед показывает пустое состояние с кнопкой поиска', async () => {
+ installFetch(baseRoutes(empty));
+
+ renderApp('/app/friends');
+
+ expect(await screen.findByText('Добавь друзей для общения')).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Найти друзей' })).toBeVisible();
+ expect(screen.queryByTestId('dm-list')).toBeNull();
+ expect(screen.getByTestId('dm-sidebar-empty')).toBeVisible();
+ });
+
+ it('ищет пользователей по логину и отправляет заявку в друзья', async () => {
+ const visitor = userEvent.setup();
+ let requested = false;
+ const fetchMock = installFetch(
+ baseRoutes(
+ () => (requested ? { ...empty, outgoing: [bob] } : empty),
+ [
+ {
+ match: '/api/v1/users/search',
+ response: () =>
+ json({
+ users: [
+ relationship({
+ user_id: 'user-2',
+ username: 'bob',
+ display_name: 'Боб',
+ relationship: 'none',
+ }),
+ ],
+ }),
+ },
+ {
+ match: '/api/v1/users/@me/relationships',
+ method: 'POST',
+ response: () => {
+ requested = true;
+ return json({ ok: true });
+ },
+ },
+ ],
+ ),
+ );
+
+ renderApp('/app/friends');
+ await visitor.click(await screen.findByRole('button', { name: 'Найти друзей' }));
+
+ const input = await screen.findByLabelText('Поиск по логину');
+ await visitor.type(input, 'bob');
+
+ // Debounce 400 мс: запрос уходит после паузы в наборе.
+ expect(await screen.findByText('Боб', {}, { timeout: 3000 })).toBeVisible();
+ expect(screen.getByText('@bob')).toBeVisible();
+ expect(findRequest(fetchMock, { url: '/users/search' })?.url).toContain('q=bob');
+
+ await visitor.click(screen.getByRole('button', { name: 'Добавить в друзья' }));
+
+ expect(await screen.findByText('Заявка отправлена')).toBeVisible();
+ expect(
+ findRequest(fetchMock, { url: '/users/@me/relationships', method: 'POST' })?.body,
+ ).toEqual({ username: 'bob' });
+ });
+
+ it('принимает входящую заявку и отменяет исходящую', async () => {
+ const visitor = userEvent.setup();
+ let accepted = false;
+ const fetchMock = installFetch(
+ baseRoutes(
+ () => ({
+ friends: accepted
+ ? [relationship({ user_id: 'user-3', username: 'carol', display_name: 'Кэрол' })]
+ : [],
+ incoming: accepted
+ ? []
+ : [
+ relationship({
+ user_id: 'user-3',
+ username: 'carol',
+ display_name: 'Кэрол',
+ relationship: 'incoming',
+ }),
+ ],
+ outgoing: [
+ relationship({
+ user_id: 'user-4',
+ username: 'dave',
+ display_name: 'Дэйв',
+ relationship: 'outgoing',
+ }),
+ ],
+ blocked: [],
+ }),
+ [
+ {
+ match: '/api/v1/users/@me/relationships/user-3/accept',
+ method: 'POST',
+ response: () => {
+ accepted = true;
+ return json({ ok: true });
+ },
+ },
+ {
+ match: '/api/v1/users/@me/relationships/user-4',
+ method: 'DELETE',
+ response: () => json({ ok: true }),
+ },
+ ],
+ ),
+ );
+
+ renderApp('/app/friends');
+
+ const incoming = await screen.findByTestId('friends-incoming');
+ expect(incoming).toHaveTextContent('@carol');
+ await visitor.click(within(incoming).getByRole('button', { name: 'Принять' }));
+
+ expect(
+ findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
+ ).toBeDefined();
+ expect(await screen.findByTestId('friends-list')).toHaveTextContent('Кэрол');
+
+ const outgoing = screen.getByTestId('friends-outgoing');
+ await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
+
+ await waitFor(() => {
+ expect(
+ findRequest(fetchMock, { url: '/users/@me/relationships/user-4', method: 'DELETE' }),
+ ).toBeDefined();
+ });
+ });
+
+ it('отклоняет входящую заявку тем же DELETE', async () => {
+ const visitor = userEvent.setup();
+ const fetchMock = installFetch(
+ baseRoutes(
+ {
+ ...empty,
+ incoming: [
+ relationship({
+ user_id: 'user-3',
+ username: 'carol',
+ display_name: 'Кэрол',
+ relationship: 'incoming',
+ }),
+ ],
+ },
+ [
+ {
+ match: '/api/v1/users/@me/relationships/user-3',
+ method: 'DELETE',
+ response: () => json({ ok: true }),
+ },
+ ],
+ ),
+ );
+
+ renderApp('/app/friends');
+ const incoming = await screen.findByTestId('friends-incoming');
+ await visitor.click(within(incoming).getByRole('button', { name: 'Отклонить' }));
+
+ await waitFor(() => {
+ expect(
+ findRequest(fetchMock, { url: '/users/@me/relationships/user-3', method: 'DELETE' }),
+ ).toBeDefined();
+ });
+ });
+
+ it('рисует цветные точки статусов в списке друзей', async () => {
+ installFetch(
+ baseRoutes({
+ friends: [
+ relationship({ user_id: 'user-2', username: 'bob', display_name: 'Боб' }),
+ relationship({
+ user_id: 'user-3',
+ username: 'carol',
+ display_name: 'Кэрол',
+ status: 'idle',
+ visible_status: 'idle',
+ }),
+ relationship({
+ user_id: 'user-4',
+ username: 'dave',
+ display_name: 'Дэйв',
+ status: 'dnd',
+ visible_status: 'dnd',
+ }),
+ relationship({
+ user_id: 'user-5',
+ username: 'erin',
+ display_name: 'Эрин',
+ status: 'offline',
+ visible_status: 'offline',
+ }),
+ relationship({
+ user_id: 'user-6',
+ username: 'frank',
+ display_name: 'Фрэнк',
+ status: 'invisible',
+ visible_status: 'invisible',
+ }),
+ ],
+ incoming: [],
+ outgoing: [],
+ blocked: [],
+ }),
+ );
+
+ renderApp('/app/friends');
+ const list = await screen.findByTestId('friends-list');
+
+ const dots: [string, string, string][] = [
+ ['user-2', 'online', 'bg-success'],
+ ['user-3', 'idle', 'bg-warning'],
+ ['user-4', 'dnd', 'bg-fg-muted'],
+ ['user-5', 'offline', 'bg-danger'],
+ ['user-6', 'invisible', 'bg-info'],
+ ];
+ for (const [id, status, className] of dots) {
+ const dot = within(list).getByTestId(`friend-status-${id}`);
+ expect(dot).toHaveAttribute('data-status', status);
+ expect(dot).toHaveClass(className);
+ }
+ });
+
+ it('показывает последний вход в часовом поясе пользователя (МСК по умолчанию)', async () => {
+ const visitor = userEvent.setup();
+ installFetch(
+ baseRoutes({
+ friends: [
+ relationship({
+ user_id: 'user-2',
+ username: 'bob',
+ display_name: 'Боб',
+ status: 'offline',
+ visible_status: 'offline',
+ last_seen_at: '2026-09-19T10:00:00Z',
+ }),
+ relationship({
+ user_id: 'user-5',
+ username: 'erin',
+ display_name: 'Эрин',
+ status: 'offline',
+ visible_status: 'offline',
+ }),
+ relationship({ user_id: 'user-6', username: 'frank', display_name: 'Фрэнк' }),
+ ],
+ incoming: [],
+ outgoing: [],
+ blocked: [],
+ }),
+ );
+
+ renderApp('/app/friends');
+ await screen.findByTestId('friends-list');
+
+ // Наведение на имя: 10:00 UTC — это 13:00 в Москве.
+ await visitor.hover(screen.getByTestId('friend-name-user-2'));
+ expect(await screen.findByText('Был(а) в сети: 19.09.2026, 13:00')).toBeVisible();
+ await visitor.unhover(screen.getByTestId('friend-name-user-2'));
+
+ // Подсказка доступна и с клавиатуры: фокус на имени открывает поповер.
+ const erin = screen.getByTestId('friend-name-user-5');
+ act(() => {
+ erin.focus();
+ });
+ expect(await screen.findByText('Не в сети давно')).toBeVisible();
+ act(() => {
+ erin.blur();
+ });
+
+ // Пользователь в сети — время последнего входа не показываем.
+ await visitor.hover(screen.getByTestId('friend-name-user-6'));
+ expect(await screen.findByText('Сейчас в сети')).toBeVisible();
+ });
+
+ it('переводит время последнего входа в пояс из настроек профиля', async () => {
+ const visitor = userEvent.setup();
+ const ekbUser: User = makeUser({ timezone: 'Asia/Yekaterinburg' });
+ installFetch(
+ baseRoutes(
+ {
+ friends: [
+ relationship({
+ user_id: 'user-2',
+ username: 'bob',
+ display_name: 'Боб',
+ status: 'offline',
+ visible_status: 'offline',
+ last_seen_at: '2026-09-19T10:00:00Z',
+ }),
+ ],
+ incoming: [],
+ outgoing: [],
+ blocked: [],
+ },
+ // Профиль с поясом из настроек: маршруты из `extra` проверяются раньше.
+ [{ match: '/api/v1/users/@me', response: () => json({ user: ekbUser }) }],
+ ),
+ );
+
+ renderApp('/app/friends');
+ await screen.findByTestId('friends-list');
+
+ await visitor.hover(screen.getByTestId('friend-name-user-2'));
+ // 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
+ expect(await screen.findByText('Был(а) в сети: 19.09.2026, 15:00')).toBeVisible();
+ });
+
+ it('открывает личную беседу и отправляет сообщение', async () => {
+ const visitor = userEvent.setup();
+ const fetchMock = installFetch(
+ baseRoutes({ ...empty, friends: [bob] }, [
+ {
+ match: '/api/v1/users/@me/channels',
+ method: 'POST',
+ response: () => json({ channel: directChannel('dm-1') }),
+ },
+ ...messagesRoutes(
+ 'dm-1',
+ [],
+ [
+ {
+ match: '/api/v1/channels/dm-1/messages',
+ method: 'POST',
+ response: () =>
+ json({
+ message: makeMessage({
+ id: 'm-1',
+ channel_id: 'dm-1',
+ author_id: 'user-1',
+ content: 'привет',
+ }),
+ }),
+ },
+ ],
+ ),
+ ]),
+ );
+
+ const { router } = renderApp('/app/friends');
+ const list = await screen.findByTestId('friends-list');
+ await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
+
+ await waitFor(() => {
+ expect(router.state.location.pathname).toBe('/app/friends/dm-1');
+ });
+ expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
+ recipient_id: 'user-2',
+ });
+
+ // Шапка беседы: имя собеседника и точка статуса.
+ expect(await screen.findByRole('heading', { name: 'Боб' })).toBeVisible();
+ expect(screen.getByTestId('dm-status')).toHaveAttribute('data-status', 'online');
+
+ const composer = await screen.findByTestId('composer');
+ await visitor.type(within(composer).getByRole('textbox'), 'привет');
+ await visitor.click(within(composer).getByRole('button', { name: 'Отправить' }));
+
+ await waitFor(() => {
+ expect(
+ findRequest(fetchMock, { url: '/api/v1/channels/dm-1/messages', method: 'POST' })?.body,
+ ).toMatchObject({ content: 'привет' });
+ });
+ expect(await screen.findByTestId('message-list')).toHaveTextContent('привет');
+ // Композер в личной беседе работает без прав сервера.
+ expect(within(composer).getByRole('textbox')).toHaveAttribute('placeholder', 'Написать Боб');
+ });
+
+ it('открывает беседу по прямой ссылке, если её нет в снапшоте', async () => {
+ installFetch(
+ baseRoutes({ ...empty, friends: [bob] }, [
+ {
+ match: '/api/v1/users/@me/channels',
+ response: () => json({ channels: [directChannel('dm-7', 'Кэрол')] }),
+ },
+ ...messagesRoutes('dm-7', []),
+ ]),
+ );
+
+ renderApp('/app/friends/dm-7');
+
+ // Список бесед подтягивается по REST — беседа открывается.
+ expect(await screen.findByRole('heading', { name: 'Кэрол' })).toBeVisible();
+ expect(await screen.findByTestId('composer')).toBeVisible();
+ });
+
+ it('присоединяется по приглашению: предпросмотр и сервер в рейке', async () => {
+ const visitor = userEvent.setup();
+ const guildDetail = {
+ id: 'g-inv',
+ name: 'Приглашённые',
+ description: '',
+ owner_id: 'user-9',
+ is_main: false,
+ member_count: 3,
+ roles: [],
+ my_role_ids: [],
+ my_permissions: ['SEND_MESSAGES'],
+ };
+ const fetchMock = installFetch(
+ baseRoutes(empty, [
+ {
+ match: '/api/v1/invites/ab12cd',
+ response: () =>
+ json({
+ invite: { code: 'ab12cd', guild_id: 'g-inv' },
+ guild: { id: 'g-inv', name: 'Приглашённые', member_count: 3, is_member: false },
+ }),
+ },
+ {
+ match: '/api/v1/invites/ab12cd',
+ method: 'POST',
+ response: () => json({ guild_id: 'g-inv' }),
+ },
+ {
+ match: '/api/v1/guilds/g-inv/channels',
+ response: () =>
+ json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
+ },
+ { match: '/api/v1/guilds/g-inv/members', response: () => json({ members: [] }) },
+ { match: '/api/v1/guilds/g-inv/roles', response: () => json({ roles: [] }) },
+ { match: '/api/v1/guilds/g-inv', response: () => json({ guild: guildDetail }) },
+ ...messagesRoutes('c-1', []),
+ ]),
+ );
+
+ const { router } = renderApp('/app/friends');
+
+ const input = await screen.findByLabelText('Код или ссылка приглашения');
+ await visitor.type(input, 'https://gl.localhost/invite/ab12cd');
+
+ const preview = await screen.findByTestId('invite-preview', {}, { timeout: 3000 });
+ expect(preview).toHaveTextContent('Приглашённые');
+ expect(preview).toHaveTextContent('Участников: 3');
+
+ await visitor.click(screen.getByRole('button', { name: 'Присоединиться' }));
+
+ await waitFor(() => {
+ expect(findRequest(fetchMock, { url: '/invites/ab12cd', method: 'POST' })).toBeDefined();
+ });
+ // Сервер появился в рейке, открылся его первый канал.
+ await waitFor(() => {
+ expect(router.state.location.pathname).toBe('/app/g-inv/c-1');
+ });
+ const rail = screen.getByTestId('guild-rail');
+ expect(await within(rail).findByLabelText('Открыть сервер Приглашённые')).toBeVisible();
+ });
+
+ it('сообщает о недействительном приглашении', async () => {
+ const visitor = userEvent.setup();
+ installFetch(
+ baseRoutes(empty, [
+ {
+ match: '/api/v1/invites/dead',
+ response: () =>
+ json({ error: { code: 'invite.expired', message: 'invite has expired' } }, 410),
+ },
+ ]),
+ );
+
+ renderApp('/app/friends');
+ await visitor.type(await screen.findByLabelText('Код или ссылка приглашения'), 'dead');
+
+ expect(
+ await screen.findByText('Срок действия приглашения истёк.', {}, { timeout: 3000 }),
+ ).toBeVisible();
+ });
+
+ it('не вступает ни в какой сервер автоматически', async () => {
+ const fetchMock = installFetch(baseRoutes(empty));
+ const gateway = installGatewaySocket(makeReadySnapshot([]));
+
+ renderApp('/app/friends');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+
+ expect(await screen.findByTestId('friends-empty')).toBeVisible();
+ expect(screen.getByTestId('guild-rail').children).toHaveLength(0);
+ // «Главного сервера» в рейке больше нет: только приглашение.
+ expect(recordedRequests(fetchMock).some((request) => request.url.includes('/join'))).toBe(
+ false,
+ );
+ expect(screen.getByTestId('invite-join')).toBeVisible();
+ });
+});
+
+describe('события шлюза о друзьях', () => {
+ it('DM_CHANNEL_CREATE перечитывает список бесед', async () => {
+ let created = false;
+ installFetch(
+ baseRoutes(empty, [
+ {
+ match: '/api/v1/users/@me/channels',
+ response: () => json({ channels: created ? [directChannel('dm-1')] : [] }),
+ },
+ ]),
+ );
+ const gateway = installGatewaySocket(makeReadySnapshot([]));
+
+ renderApp('/app/friends');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+ expect(await screen.findByText('Личных бесед пока нет.')).toBeVisible();
+
+ created = true;
+ await dispatchEvent(gateway, 'DM_CHANNEL_CREATE', { channel_id: 'dm-1' });
+
+ expect(await screen.findByTestId('dm-link-dm-1')).toBeVisible();
+ expect(screen.getByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'online');
+ });
+
+ it('RELATIONSHIP_UPDATE перечитывает друзей', async () => {
+ let hasFriend = false;
+ installFetch(baseRoutes(() => (hasFriend ? { ...empty, friends: [bob] } : empty)));
+ const gateway = installGatewaySocket(makeReadySnapshot([]));
+
+ renderApp('/app/friends');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+ expect(await screen.findByTestId('friends-empty')).toBeVisible();
+
+ hasFriend = true;
+ await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
+
+ const list = await screen.findByTestId('friends-list');
+ expect(list).toHaveTextContent('Боб');
+ });
+
+ it('PRESENCE_UPDATE обновляет статус друга без перезагрузки', async () => {
+ const fetchMock = installFetch(
+ baseRoutes({ ...empty, friends: [bob] }, [
+ {
+ match: '/api/v1/users/@me/channels',
+ response: () => json({ channels: [directChannel('dm-1')] }),
+ },
+ ]),
+ );
+ const gateway = installGatewaySocket(
+ makeReadySnapshot(
+ [],
+ [{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', status: 'online' }],
+ ),
+ );
+
+ renderApp('/app/friends');
+ const list = await screen.findByTestId('friends-list');
+ await gateway.greet();
+ expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
+ 'data-status',
+ 'online',
+ );
+ const before = recordedRequests(fetchMock).filter(
+ (request) => request.url.includes('/users/@me/relationships') && request.method === 'GET',
+ ).length;
+
+ await dispatchEvent(gateway, 'PRESENCE_UPDATE', {
+ user_id: 'user-2',
+ status: 'dnd',
+ visible_status: 'dnd',
+ custom_status: 'занят',
+ last_seen_at: '2026-09-19T10:00:00Z',
+ });
+
+ const dot = await within(list).findByTestId('friend-status-user-2');
+ expect(dot).toHaveAttribute('data-status', 'dnd');
+ expect(dot).toHaveClass('bg-fg-muted');
+ // Статус собеседника обновился и в сайдбаре личных бесед.
+ expect(await screen.findByTestId('dm-status-dm-1')).toHaveAttribute('data-status', 'dnd');
+
+ // Перезагрузки списка друзей не было: событие применено точечно.
+ const after = recordedRequests(fetchMock).filter(
+ (request) => request.url.includes('/users/@me/relationships') && request.method === 'GET',
+ ).length;
+ expect(after).toBe(before);
+ });
+
+ it('MESSAGE_CREATE в беседе обновляет бейдж непрочитанного', async () => {
+ installFetch(
+ baseRoutes({ ...empty, friends: [bob] }, [
+ {
+ match: '/api/v1/users/@me/channels',
+ response: () => json({ channels: [directChannel('dm-1')] }),
+ },
+ ]),
+ );
+ const gateway = installGatewaySocket(
+ makeReadySnapshot(
+ [],
+ [{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-1' }],
+ ),
+ );
+
+ const { router } = renderApp('/app/friends');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+
+ // Отметки чтения нет: беседа не читалась — в сайдбаре точка непрочитанного.
+ expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible();
+
+ await dispatchEvent(gateway, 'MESSAGE_CREATE', {
+ id: 'm-2',
+ channel_id: 'dm-1',
+ author_id: 'user-2',
+ content: 'эй',
+ created_at: '2026-09-19T10:05:00Z',
+ });
+
+ // Новое сообщение сдвигает «последнее» в беседе — точка остаётся.
+ expect(screen.getByTestId('dm-unread-dm-1')).toBeVisible();
+ expect(router.state.location.pathname).toBe('/app/friends');
+ });
+
+ it('открытая беседа отмечается прочитанной через ack', async () => {
+ const visitor = userEvent.setup();
+ const fetchMock = installFetch(
+ baseRoutes({ ...empty, friends: [bob] }, [
+ {
+ match: '/api/v1/users/@me/channels',
+ response: () => json({ channels: [directChannel('dm-1')] }),
+ },
+ ...messagesRoutes('dm-1', [
+ makeMessage({
+ id: 'm-5',
+ channel_id: 'dm-1',
+ author_id: 'user-2',
+ content: 'привет',
+ created_at: '2026-09-19T10:05:00Z',
+ }),
+ ]),
+ ]),
+ );
+ const gateway = installGatewaySocket(
+ makeReadySnapshot(
+ [],
+ [{ id: 'dm-1', name: 'Боб', recipient_id: 'user-2', last_message_id: 'm-5' }],
+ ),
+ );
+
+ renderApp('/app/friends');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+
+ // Беседа не читалась (последнее сообщение новее отметки) — есть бейдж.
+ expect(await screen.findByTestId('dm-unread-dm-1')).toBeVisible();
+
+ await visitor.click(screen.getByTestId('dm-link-dm-1'));
+ expect(await screen.findByTestId('message-list')).toHaveTextContent('привет');
+
+ // Беседа открыта и окно активно — уходит POST /ack с последним сообщением.
+ await waitFor(
+ () => {
+ expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })).toBeDefined();
+ },
+ { timeout: 3000 },
+ );
+ expect(findRequest(fetchMock, { url: '/channels/dm-1/ack', method: 'POST' })?.body).toEqual({
+ last_message_id: 'm-5',
+ });
+ });
+});
diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx
index 5de4c13..61b566f 100644
--- a/web/tests/helpers.tsx
+++ b/web/tests/helpers.tsx
@@ -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,
diff --git a/web/tests/i18n.test.ts b/web/tests/i18n.test.ts
new file mode 100644
index 0000000..6830ff3
--- /dev/null
+++ b/web/tests/i18n.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+
+import en from '@/i18n/locales/en.json';
+import ru from '@/i18n/locales/ru.json';
+
+/** Плоский список пар «ключ → значение». */
+function flatEntries(value: unknown, prefix = ''): [string, unknown][] {
+ if (typeof value !== 'object' || value === null) {
+ return [];
+ }
+ const entries: [string, unknown][] = [];
+ for (const [key, nested] of Object.entries(value)) {
+ const path = prefix === '' ? key : `${prefix}.${key}`;
+ if (typeof nested === 'object' && nested !== null) {
+ entries.push(...flatEntries(nested, path));
+ } else {
+ entries.push([path, nested]);
+ }
+ }
+ return entries;
+}
+
+/** Плоский список ключей вида `friends.empty.title`. */
+function flatKeys(value: unknown): string[] {
+ return flatEntries(value)
+ .map(([key]) => key)
+ .sort();
+}
+
+/**
+ * Паритет переводов: русский — основной, английский должен повторять его
+ * ключ в ключ, иначе часть интерфейса останется без строки.
+ */
+describe('переводы', () => {
+ it('ключи ru и en совпадают', () => {
+ expect(flatKeys(en)).toEqual(flatKeys(ru));
+ });
+
+ it('нет пустых строк', () => {
+ const empty = flatEntries(ru)
+ .filter(([, value]) => typeof value === 'string' && value.trim() === '')
+ .map(([key]) => key);
+ expect(empty).toEqual([]);
+ });
+});
diff --git a/web/tests/login.test.tsx b/web/tests/login.test.tsx
index a866a21..35d1e06 100644
--- a/web/tests/login.test.tsx
+++ b/web/tests/login.test.tsx
@@ -76,9 +76,9 @@ describe('страница входа', () => {
await waitFor(() => {
expect(router.state.location.pathname).toBe('/app/empty');
});
- expect(
- await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
- ).toBeVisible();
+ expect(await screen.findByRole('heading', { name: 'У вас пока нет серверов' })).toBeVisible();
+ // Автовступления в «главный сервер» больше нет: вход ведёт на пустой экран.
+ expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
expect(findRequest(fetchMock, { url: '/auth/login', method: 'POST' })?.body).toEqual({
email: 'alice@example.com',
diff --git a/web/tests/shell.test.tsx b/web/tests/shell.test.tsx
index 40ac23e..4a44811 100644
--- a/web/tests/shell.test.tsx
+++ b/web/tests/shell.test.tsx
@@ -4,6 +4,7 @@ import { vi } from 'vitest';
import { GatewayOp } from '@/api/gateway';
import {
+ findRequest,
installFetch,
json,
makeReadySnapshot,
@@ -227,8 +228,8 @@ describe('основной экран', () => {
expect(screen.queryByLabelText('Создать комнату')).toBeNull();
});
- it('пустой список серверов предлагает главный сервер и создание своего', async () => {
- installFetch([
+ it('пустой список серверов предлагает создать свой и вступить по приглашению', async () => {
+ const fetchMock = installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{
match: '/api/v1/instance',
@@ -249,69 +250,34 @@ describe('основной экран', () => {
renderApp('/app/empty');
- expect(
- await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
- ).toBeVisible();
+ expect(await screen.findByText('У вас пока нет серверов')).toBeVisible();
expect(screen.getByLabelText('Создать сервер')).toBeVisible();
+ expect(await screen.findByTestId('invite-join')).toBeVisible();
+ // Автовступления в «главный сервер» больше нет: только по приглашению.
+ expect(findRequest(fetchMock, { url: '/guilds/g-main/join', method: 'POST' })).toBeUndefined();
});
- it('после вступления в главный сервер открывает его в URL', async () => {
- const { default: userEvent } = await import('@testing-library/user-event');
- const visitor = userEvent.setup();
- const mainGuild = {
- id: 'g-main',
- name: 'Главный',
- owner_id: 'user-1',
- is_main: true,
- member_count: 5,
- my_role_ids: [],
- my_permissions: [],
- };
- let joined = false;
+ it('друзья — первая иконка рейки, выше серверов', async () => {
installFetch([
{ match: '/api/v1/users/@me', response: () => json({ user }) },
- {
- match: '/api/v1/instance',
- response: () =>
- json({
- instance: {
- name: 'glchat-test',
- registration_enabled: true,
- allow_guild_creation: true,
- max_guilds_per_user: 5,
- main_guild_id: 'g-main',
- },
- }),
- },
- {
- match: '/api/v1/guilds/g-main/join',
- method: 'POST',
- response: () => {
- joined = true;
- return json({ ok: true });
- },
- },
- {
- match: '/api/v1/users/@me/guilds',
- response: () => json({ guilds: joined ? [mainGuild] : [] }),
- },
- {
- match: '/api/v1/guilds/g-main/channels',
- response: () =>
- json({ channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }] }),
- },
+ { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
+ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
]);
+ const gateway = installGatewaySocket(snapshot);
- const { router } = renderApp('/app/empty');
- await visitor.click(
- await screen.findByRole('button', { name: 'Присоединиться к главному серверу' }),
+ renderApp('/app/g-1/c-general');
+ await screen.findByTestId('guild-rail');
+ await gateway.greet();
+
+ // Друзья идут выше списка серверов, между ними — разделитель.
+ const rail = screen.getByTestId('guild-rail');
+ expect(screen.getByTestId('friends-rail-link').compareDocumentPosition(rail)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
);
-
- // В снапшоте сервера может не быть — REST-подстраховка обновляет список,
- // после чего пользователь попадает в первую комнату сервера.
- await waitFor(() => {
- expect(router.state.location.pathname).toStrictEqual(expect.stringContaining('/app/g-main'));
- });
+ expect(within(rail).getAllByRole('link')).toHaveLength(2);
+ expect(screen.getByRole('separator')).toBeVisible();
+ // Личные беседы в рейку не попадают: там только раздел друзей и серверы.
+ expect(screen.getByRole('link', { name: 'Друзья' })).toHaveAttribute('href', '/app/friends');
});
it('создаёт сервер через модальное окно', async () => {