feat(dm): групповые личные беседы (AGENT.md 7.8, Фаза 7)
Беседа на несколько участников живёт в тех же таблицах, что и 1:1
(channels.type = 'dm' + dm_participants), добавляется только владелец
(channels.dm_owner_id, миграция 00020).
Сервер: POST /users/@me/channels/group (2–9 приглашённых, имя необязательно —
собирается из имён), PUT/DELETE /channels/{id}/recipients/{user_id}
(добавляет любой участник, удаляет других и переименовывает только владелец,
выйти может каждый сам), PATCH /channels/{id}. Правила состава: группа — это
3+ участника; при двух беседа снова обычная личная (имя и владелец
сбрасываются), при одном — удаляется вместе с перепиской. Заблокированного
нельзя ни пригласить, ни добавить; посторонним беседа не видна (404).
READY и REST отдают is_group, name, member_count, owner_id и состав;
участники получают DM_CHANNEL_CREATE/DM_CHANNEL_UPDATE, удалённый —
DM_CHANNEL_DELETE.
Клиент: группа в сайдбаре с числом участников (без точки чужого статуса),
шапка беседы с числом участников и именами в подсказке, создание группы из
списка друзей (минимум двое) с переходом в новую беседу.
Тесты: Go — полный жизненный цикл (создание, состав у каждого участника,
добавление, запрет посторонним, удаление владельцем, выход, превращение в
1:1, удаление последней беседы), валидация (меньше трёх, дубликаты,
заблокированный) и группа в READY; web — 4 vitest (сайдбар, шапка, создание,
выключенная кнопка).
This commit is contained in:
+108
-4
@@ -1,5 +1,5 @@
|
||||
import { request } from './client';
|
||||
import type { MemberCosmetics } from './types';
|
||||
import type { DmRecipient, MemberCosmetics } from './types';
|
||||
import { userStatuses, type Channel, type UserStatus } from './types';
|
||||
|
||||
/**
|
||||
@@ -273,9 +273,113 @@ export async function fetchDirectChannels(signal?: AbortSignal): Promise<Channel
|
||||
'/users/@me/channels',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
return Array.isArray(payload.channels)
|
||||
? payload.channels.map(parseDirectChannel).filter((item): item is Channel => item !== null)
|
||||
: [];
|
||||
if (!Array.isArray(payload.channels)) {
|
||||
return [];
|
||||
}
|
||||
return payload.channels
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
const channel = parseDirectChannel(item);
|
||||
// Групповая беседа: имя и состав приходят полями канала, а не recipient.
|
||||
return record === null || channel === null ? null : withGroupFields(channel, record);
|
||||
})
|
||||
.filter((item): item is Channel => item !== null);
|
||||
}
|
||||
|
||||
/** Разбор участника групповой беседы из ответа API. */
|
||||
function parseDmRecipient(value: unknown): DmRecipient | null {
|
||||
const record = asRecord(value);
|
||||
const userId = asString(record?.['user_id']);
|
||||
if (record === null || userId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const recipient: DmRecipient = {
|
||||
user_id: userId,
|
||||
username: asString(record['username']) ?? '',
|
||||
display_name: asString(record['display_name']) ?? '',
|
||||
status: asStatus(record['status']) ?? 'offline',
|
||||
visible_status: asStatus(record['visible_status']) ?? asStatus(record['status']) ?? 'offline',
|
||||
is_owner: record['is_owner'] === true,
|
||||
is_self: record['is_self'] === true,
|
||||
};
|
||||
const avatar = asString(record['avatar_file_id']);
|
||||
if (avatar !== undefined) {
|
||||
recipient.avatar_file_id = avatar;
|
||||
}
|
||||
return recipient;
|
||||
}
|
||||
|
||||
/** Поля групповой беседы из ответа API (общие для создания и обновления). */
|
||||
function withGroupFields(channel: Channel, record: Record<string, unknown>): Channel {
|
||||
if (record['is_group'] !== true) {
|
||||
return channel;
|
||||
}
|
||||
channel.is_group = true;
|
||||
channel.name = asString(record['name']) ?? channel.name;
|
||||
const memberCount = record['member_count'];
|
||||
if (typeof memberCount === 'number') {
|
||||
channel.member_count = memberCount;
|
||||
}
|
||||
const ownerId = asString(record['owner_id']);
|
||||
if (ownerId !== undefined) {
|
||||
channel.owner_id = ownerId;
|
||||
}
|
||||
if (Array.isArray(record['recipients'])) {
|
||||
channel.recipients = record['recipients']
|
||||
.map(parseDmRecipient)
|
||||
.filter((item): item is DmRecipient => item !== null);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** `POST /users/@me/channels/group` — создать групповую беседу (7.8). */
|
||||
export async function createGroupDirectChannel(
|
||||
name: string,
|
||||
recipientIds: string[],
|
||||
): Promise<Channel> {
|
||||
const payload = await request<{ channel?: unknown }>('/users/@me/channels/group', {
|
||||
method: 'POST',
|
||||
body: { name, recipient_ids: recipientIds },
|
||||
});
|
||||
const record = asRecord(payload.channel);
|
||||
const channel = parseDirectChannel(payload.channel);
|
||||
if (record === null || channel === null) {
|
||||
throw new Error('malformed group channel payload');
|
||||
}
|
||||
return withGroupFields(channel, record);
|
||||
}
|
||||
|
||||
/** `PUT /channels/{id}/recipients/{user_id}` — добавить участника в группу. */
|
||||
export async function addGroupRecipient(channelId: string, userId: string): Promise<Channel> {
|
||||
const payload = await request<{ channel?: unknown }>(
|
||||
`/channels/${channelId}/recipients/${userId}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
const record = asRecord(payload.channel);
|
||||
const channel = parseDirectChannel(payload.channel);
|
||||
if (record === null || channel === null) {
|
||||
throw new Error('malformed group channel payload');
|
||||
}
|
||||
return withGroupFields(channel, record);
|
||||
}
|
||||
|
||||
/** `DELETE /channels/{id}/recipients/{user_id}` — выйти или удалить участника. */
|
||||
export function removeGroupRecipient(channelId: string, userId: string): Promise<{ ok: true }> {
|
||||
return request<{ ok: true }>(`/channels/${channelId}/recipients/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** `PATCH /channels/{id}` — переименовать групповую беседу (владелец). */
|
||||
export async function renameGroupDirectChannel(channelId: string, name: string): Promise<Channel> {
|
||||
const payload = await request<{ channel?: unknown }>(`/channels/${channelId}`, {
|
||||
method: 'PATCH',
|
||||
body: { name },
|
||||
});
|
||||
const record = asRecord(payload.channel);
|
||||
const channel = parseDirectChannel(payload.channel);
|
||||
if (record === null || channel === null) {
|
||||
throw new Error('malformed group channel payload');
|
||||
}
|
||||
return withGroupFields(channel, record);
|
||||
}
|
||||
|
||||
/** `POST /users/@me/channels` — открыть (или создать) беседу с пользователем. */
|
||||
|
||||
@@ -401,6 +401,23 @@ export function parseChannelPayload(value: unknown): Channel | null {
|
||||
if (lastMessageAt !== undefined) {
|
||||
channel.last_message_at = lastMessageAt;
|
||||
}
|
||||
// Групповая личная беседа (AGENT.md 7.8, Фаза 7): имя, состав и владелец.
|
||||
if (record['is_group'] === true) {
|
||||
channel.is_group = true;
|
||||
const memberCount = record['member_count'];
|
||||
if (typeof memberCount === 'number') {
|
||||
channel.member_count = memberCount;
|
||||
}
|
||||
const ownerId = asString(record['owner_id']);
|
||||
if (ownerId !== undefined) {
|
||||
channel.owner_id = ownerId;
|
||||
}
|
||||
if (Array.isArray(record['member_ids'])) {
|
||||
channel.member_ids = record['member_ids'].filter(
|
||||
(item): item is string => typeof item === 'string',
|
||||
);
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,27 @@ export interface Channel {
|
||||
status?: UserStatus;
|
||||
last_message_id?: string;
|
||||
last_message_at?: string;
|
||||
/**
|
||||
* Групповая личная беседа (AGENT.md 7.8, Фаза 7): участников больше двух.
|
||||
* У 1:1 `is_group` не задан, а собеседник лежит в `recipient_id`.
|
||||
*/
|
||||
is_group?: boolean;
|
||||
member_count?: number;
|
||||
member_ids?: string[];
|
||||
owner_id?: string;
|
||||
recipients?: DmRecipient[];
|
||||
}
|
||||
|
||||
/** Участник групповой беседы. */
|
||||
export interface DmRecipient {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
status: UserStatus;
|
||||
visible_status: UserStatus;
|
||||
is_owner: boolean;
|
||||
is_self: boolean;
|
||||
}
|
||||
|
||||
export interface Guild {
|
||||
|
||||
@@ -71,10 +71,20 @@ export function DirectMessageView({ channelId }: { channelId: string }) {
|
||||
|
||||
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
|
||||
const status = channel.status ?? 'offline';
|
||||
const isGroup = channel.is_group === true;
|
||||
const memberCount = channel.member_count ?? channel.recipients?.length ?? 0;
|
||||
const memberNames = (channel.recipients ?? [])
|
||||
.map((recipient) =>
|
||||
recipient.display_name === '' ? recipient.username : recipient.display_name,
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<section id="app-content" className="absolute inset-0 flex flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-2">
|
||||
<header
|
||||
className="flex items-center gap-2 border-b border-border/40 px-4 py-2"
|
||||
data-testid={isGroup ? 'dm-group-header' : 'dm-header'}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Avatar
|
||||
name={name}
|
||||
@@ -82,17 +92,28 @@ export function DirectMessageView({ channelId }: { channelId: string }) {
|
||||
fileId={channel.icon_file_id}
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="dm-status"
|
||||
data-status={status}
|
||||
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
|
||||
/>
|
||||
{isGroup ? null : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="dm-status"
|
||||
data-status={status}
|
||||
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<h2 className="truncate text-sm font-semibold" title={name}>
|
||||
<h2
|
||||
className="truncate text-sm font-semibold"
|
||||
title={memberNames === '' ? name : memberNames}
|
||||
>
|
||||
{name}
|
||||
</h2>
|
||||
<span className="sr-only">{t(`user.status.${status}`)}</span>
|
||||
{isGroup ? (
|
||||
<span className="shrink-0 text-xs text-fg-muted" data-testid="dm-member-count">
|
||||
{t('friends.group.members', { count: memberCount })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="sr-only">{t(`user.status.${status}`)}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { createGroupDirectChannel } from '@/api/friends';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { useFriendsStore } from '@/stores/friends';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Создание групповой беседы (AGENT.md 7.8, Фаза 7): выбрать друзей, задать
|
||||
* имя (необязательно — сервер соберёт его из имён участников) и открыть
|
||||
* беседу. Группа — это минимум три человека, поэтому меньше двух друзей
|
||||
* выбрать нельзя.
|
||||
*/
|
||||
export function GroupDmDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const friends = useFriendsStore((state) => state.friends);
|
||||
const upsertDmChannel = useSessionStore((state) => state.upsertDmChannel);
|
||||
const [name, setName] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => createGroupDirectChannel(name.trim(), selected),
|
||||
onSuccess: (channel) => {
|
||||
upsertDmChannel(channel);
|
||||
onClose();
|
||||
void navigate(`/app/friends/${channel.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = (userId: string): void => {
|
||||
setSelected((current) =>
|
||||
current.includes(userId) ? current.filter((id) => id !== userId) : [...current, userId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-2 flex flex-col gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-2"
|
||||
data-testid="group-dm-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (selected.length >= 2) {
|
||||
create.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={t('friends.group.name')}
|
||||
name="group_name"
|
||||
maxLength={64}
|
||||
placeholder={t('friends.group.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="text-xs text-fg-muted">{t('friends.group.membersHint')}</legend>
|
||||
{friends.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t('friends.friendsEmpty')}</p>
|
||||
) : (
|
||||
<ul
|
||||
className="flex max-h-40 flex-col gap-0.5 overflow-y-auto"
|
||||
data-testid="group-friends"
|
||||
>
|
||||
{friends.map((friend) => {
|
||||
const label = friend.display_name === '' ? friend.username : friend.display_name;
|
||||
return (
|
||||
<li key={friend.user_id}>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-1 py-0.5 text-sm hover:bg-surface-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(friend.user_id)}
|
||||
onChange={() => toggle(friend.user_id)}
|
||||
aria-label={label}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</fieldset>
|
||||
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={selected.length < 2 || create.isPending}>
|
||||
{t(create.isPending ? 'common.saving' : 'friends.group.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -423,6 +423,15 @@
|
||||
"sidebarHint": "Direct messages live here — they are not in the server rail.",
|
||||
"dmSection": "Direct messages",
|
||||
"dmEmpty": "No direct messages yet.",
|
||||
"group": {
|
||||
"title": "Create a group conversation",
|
||||
"name": "Group name",
|
||||
"namePlaceholder": "For example “Game night”",
|
||||
"membersHint": "Pick at least two friends:",
|
||||
"create": "Create",
|
||||
"members": "Members: {{count}}",
|
||||
"open": "Open the group “{{name}}”, members: {{count}}"
|
||||
},
|
||||
"friendsSection": "Friends",
|
||||
"friendsEmpty": "Your friend list is empty.",
|
||||
"open": "Open a conversation with {{name}}",
|
||||
|
||||
@@ -423,6 +423,15 @@
|
||||
"sidebarHint": "Личные беседы живут здесь — в рейке серверов их нет.",
|
||||
"dmSection": "Личные беседы",
|
||||
"dmEmpty": "Личных бесед пока нет.",
|
||||
"group": {
|
||||
"title": "Создать групповую беседу",
|
||||
"name": "Название группы",
|
||||
"namePlaceholder": "Например, «Игровая»",
|
||||
"membersHint": "Выберите минимум двух друзей:",
|
||||
"create": "Создать",
|
||||
"members": "Участников: {{count}}",
|
||||
"open": "Открыть группу «{{name}}», участников: {{count}}"
|
||||
},
|
||||
"friendsSection": "Друзья",
|
||||
"friendsEmpty": "Список друзей пуст.",
|
||||
"open": "Открыть беседу с {{name}}",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
import type { Channel, UserStatus } from '@/api/types';
|
||||
import { GroupDmDialog } from '@/components/friends/GroupDmDialog';
|
||||
import { useDmUnread } from '@/components/friends/useDmUnread';
|
||||
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
@@ -32,12 +33,15 @@ function DirectChannelLink({ channel, selected }: { channel: Channel; selected:
|
||||
const { unread, mentions, exact } = useDmUnread(channel);
|
||||
const highlighted = !selected && (unread > 0 || mentions > 0);
|
||||
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
|
||||
const memberCount = channel.member_count ?? channel.recipients?.length ?? 0;
|
||||
const label =
|
||||
mentions > 0
|
||||
? t('channels.selectMentions', { name, count: mentions })
|
||||
: unread > 0
|
||||
? t('channels.selectUnread', { name, count: unread })
|
||||
: t('friends.open', { name });
|
||||
: channel.is_group === true
|
||||
? t('friends.group.open', { name, count: memberCount })
|
||||
: t('friends.open', { name });
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -59,9 +63,19 @@ function DirectChannelLink({ channel, selected }: { channel: Channel; selected:
|
||||
fileId={channel.icon_file_id}
|
||||
size="sm"
|
||||
/>
|
||||
<StatusDot status={channel.status ?? 'offline'} testId={`dm-status-${channel.id}`} />
|
||||
{channel.is_group === true ? null : (
|
||||
<StatusDot status={channel.status ?? 'offline'} testId={`dm-status-${channel.id}`} />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
{channel.is_group === true ? (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-fg-muted"
|
||||
data-testid={`dm-members-${channel.id}`}
|
||||
>
|
||||
{channel.member_count ?? channel.recipients?.length ?? 0}
|
||||
</span>
|
||||
) : null}
|
||||
{selected ? null : mentions > 0 ? (
|
||||
<span
|
||||
data-testid={`dm-mentions-${channel.id}`}
|
||||
@@ -119,6 +133,7 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const [groupOpen, setGroupOpen] = useState(false);
|
||||
const direct = useMemo(() => [...dmChannels].sort(byRecent), [dmChannels]);
|
||||
const friendsSorted = useMemo(() => [...friends].sort(byPresence), [friends]);
|
||||
|
||||
@@ -133,9 +148,22 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
<section aria-label={t('friends.dmSection')}>
|
||||
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t('friends.dmSection')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t('friends.dmSection')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="group-dm-open"
|
||||
aria-expanded={groupOpen}
|
||||
title={t('friends.group.title')}
|
||||
onClick={() => setGroupOpen((open) => !open)}
|
||||
className="ml-auto rounded-[var(--radius-sm)] px-1.5 text-sm leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{groupOpen ? <GroupDmDialog onClose={() => setGroupOpen(false)} /> : null}
|
||||
{direct.length === 0 ? (
|
||||
<p className="px-2 text-xs text-fg-muted" data-testid="dm-sidebar-empty">
|
||||
{t('friends.dmEmpty')}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import type { Relationships } from '@/api/friends';
|
||||
import {
|
||||
findRequest,
|
||||
installFetch,
|
||||
installGatewaySocket,
|
||||
json,
|
||||
makeReadySnapshot,
|
||||
makeUser,
|
||||
renderApp,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Групповые личные беседы (AGENT.md 7.8, Фаза 7): отображение в сайдбаре,
|
||||
* создание из списка друзей и шапка беседы с числом участников.
|
||||
*/
|
||||
|
||||
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 friends: Relationships = {
|
||||
friends: [
|
||||
{
|
||||
user_id: 'user-2',
|
||||
username: 'bob',
|
||||
display_name: 'Боб',
|
||||
status: 'online',
|
||||
visible_status: 'online',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
timezone: 'Europe/Moscow',
|
||||
relationship: 'friend',
|
||||
},
|
||||
{
|
||||
user_id: 'user-3',
|
||||
username: 'carol',
|
||||
display_name: 'Кэрол',
|
||||
status: 'offline',
|
||||
visible_status: 'offline',
|
||||
is_instance_admin: false,
|
||||
badges: [],
|
||||
timezone: 'Europe/Moscow',
|
||||
relationship: 'friend',
|
||||
},
|
||||
],
|
||||
incoming: [],
|
||||
outgoing: [],
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
/** Групповая беседа в REST-форме (`GET /users/@me/channels`). */
|
||||
function groupChannel(id = 'dm-group-1', memberCount = 3) {
|
||||
return {
|
||||
id,
|
||||
type: 'dm',
|
||||
is_group: true,
|
||||
name: 'Игровая',
|
||||
member_count: memberCount,
|
||||
owner_id: user.id,
|
||||
can_send: true,
|
||||
can_view: true,
|
||||
recipients: [
|
||||
{
|
||||
user_id: user.id,
|
||||
username: 'alice',
|
||||
display_name: 'Alice',
|
||||
status: 'online',
|
||||
visible_status: 'online',
|
||||
is_owner: true,
|
||||
is_self: true,
|
||||
},
|
||||
{
|
||||
user_id: 'user-2',
|
||||
username: 'bob',
|
||||
display_name: 'Боб',
|
||||
status: 'online',
|
||||
visible_status: 'online',
|
||||
is_owner: false,
|
||||
is_self: false,
|
||||
},
|
||||
{
|
||||
user_id: 'user-3',
|
||||
username: 'carol',
|
||||
display_name: 'Кэрол',
|
||||
status: 'offline',
|
||||
visible_status: 'offline',
|
||||
is_owner: false,
|
||||
is_self: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function baseRoutes(channels: unknown[], extra: FetchRoute[] = []): FetchRoute[] {
|
||||
return [
|
||||
...extra,
|
||||
{ match: '/api/v1/users/@me/relationships', response: () => json(friends) },
|
||||
{ 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) },
|
||||
];
|
||||
}
|
||||
|
||||
describe('групповые беседы', () => {
|
||||
it('группа показана в сайдбаре с числом участников и без точки статуса', async () => {
|
||||
const channel = groupChannel();
|
||||
installFetch(
|
||||
baseRoutes(
|
||||
[channel],
|
||||
[
|
||||
{
|
||||
match: `/api/v1/channels/${channel.id}/messages`,
|
||||
response: () => json({ messages: [] }),
|
||||
},
|
||||
{ match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) },
|
||||
{
|
||||
match: `/api/v1/channels/${channel.id}/ack`,
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
const gateway = installGatewaySocket(makeReadySnapshot());
|
||||
|
||||
// Сайдбар раздела «Друзья» живёт в оболочке приложения: открываем беседу,
|
||||
// чтобы он отрисовался (как в реальном переходе).
|
||||
renderApp(`/app/friends/${channel.id}`);
|
||||
await gateway.greet();
|
||||
|
||||
const link = await screen.findByTestId('dm-link-dm-group-1');
|
||||
expect(link).toHaveTextContent('Игровая');
|
||||
expect(screen.getByTestId('dm-members-dm-group-1')).toHaveTextContent('3');
|
||||
// У группы нет статуса собеседника: точки рядом с аватаром быть не должно.
|
||||
expect(screen.queryByTestId('dm-status-dm-group-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('шапка групповой беседы показывает число участников', async () => {
|
||||
const channel = groupChannel();
|
||||
installFetch(
|
||||
baseRoutes(
|
||||
[channel],
|
||||
[
|
||||
{
|
||||
match: `/api/v1/channels/${channel.id}/messages`,
|
||||
response: () => json({ messages: [] }),
|
||||
},
|
||||
{ match: `/api/v1/channels/${channel.id}/pins`, response: () => json({ messages: [] }) },
|
||||
{
|
||||
match: `/api/v1/channels/${channel.id}/ack`,
|
||||
method: 'POST',
|
||||
response: () => json({ ok: true }),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
const gateway = installGatewaySocket(makeReadySnapshot());
|
||||
|
||||
renderApp(`/app/friends/${channel.id}`);
|
||||
await gateway.greet();
|
||||
|
||||
expect(await screen.findByTestId('dm-group-header')).toBeVisible();
|
||||
expect(screen.getByTestId('dm-member-count')).toHaveTextContent('Участников: 3');
|
||||
});
|
||||
|
||||
it('создаёт группу из двух друзей и открывает её', async () => {
|
||||
const created = groupChannel('dm-group-2');
|
||||
const fetchMock = installFetch(
|
||||
baseRoutes(
|
||||
[],
|
||||
[
|
||||
{
|
||||
match: '/api/v1/users/@me/channels/group',
|
||||
method: 'POST',
|
||||
response: () => json({ channel: created }),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
const gateway = installGatewaySocket(makeReadySnapshot());
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
const { router } = renderApp('/app/friends');
|
||||
await gateway.greet();
|
||||
await screen.findByTestId('dm-sidebar-empty');
|
||||
|
||||
await visitor.click(await screen.findByTestId('group-dm-open'));
|
||||
await visitor.type(screen.getByLabelText('Название группы'), 'Игровая');
|
||||
await visitor.click(screen.getByRole('checkbox', { name: 'Боб' }));
|
||||
await visitor.click(screen.getByRole('checkbox', { name: 'Кэрол' }));
|
||||
|
||||
const submit = screen.getByRole('button', { name: 'Создать' });
|
||||
// С одним участником группа не создаётся: кнопка включается со второго.
|
||||
expect(submit).toBeEnabled();
|
||||
await visitor.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/channels/group', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
const request = findRequest(fetchMock, { url: '/channels/group', method: 'POST' });
|
||||
expect(request?.body).toEqual({
|
||||
name: 'Игровая',
|
||||
recipient_ids: ['user-2', 'user-3'],
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/friends/dm-group-2');
|
||||
});
|
||||
// Беседа сразу попала в список — отдельный запрос не нужен.
|
||||
expect(await screen.findByTestId('dm-link-dm-group-2')).toBeVisible();
|
||||
});
|
||||
|
||||
it('без двух выбранных друзей кнопка создания выключена', async () => {
|
||||
installFetch(baseRoutes([]));
|
||||
const gateway = installGatewaySocket(makeReadySnapshot());
|
||||
const visitor = userEvent.setup();
|
||||
|
||||
renderApp('/app/friends');
|
||||
await gateway.greet();
|
||||
await visitor.click(await screen.findByTestId('group-dm-open'));
|
||||
await visitor.click(screen.getByRole('checkbox', { name: 'Боб' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Создать' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user