diff --git a/web/src/components/friends/DirectMessageView.tsx b/web/src/components/friends/DirectMessageView.tsx index 1bbbc89..9e38dd9 100644 --- a/web/src/components/friends/DirectMessageView.tsx +++ b/web/src/components/friends/DirectMessageView.tsx @@ -12,6 +12,7 @@ import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory'; import { DmCallHistoryPanel } from '@/components/friends/DmCallHistoryPanel'; import { DmCallMenu } from '@/components/friends/DmCallMenu'; import { DmCallPanel } from '@/components/friends/DmCallPanel'; +import { GroupRenameDialog } from '@/components/friends/GroupRenameDialog'; import { Avatar } from '@/components/ui/Avatar'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Menu, MenuItem } from '@/components/ui/Menu'; @@ -40,18 +41,19 @@ export function DirectMessageView({ channelId }: { channelId: string }) { const upsertDmChannel = useSessionStore((state) => state.upsertDmChannel); const currentUser = useCurrentUser(); - // Иконку групповой беседы меняет только владелец (D-070): у 1:1 своей - // иконки нет — там показывается аватар собеседника. + // Иконку и имя групповой беседы меняет только владелец (D-070, D-075): у 1:1 + // своей иконки нет — там показывается аватар собеседника. const isOwner = channel?.is_group === true && channel.owner_id === currentUser.data?.id; - const [iconMenuOpen, setIconMenuOpen] = useState(false); + const [ownerMenuOpen, setOwnerMenuOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); const [iconError, setIconError] = useState(null); - const iconMenuRef = useRef(null); + const ownerMenuRef = useRef(null); const iconInput = useRef(null); - // Ответ сервера кладём в стор бесед: иконка появляется сразу и у владельца, - // а остальным участникам приходит DM_CHANNEL_UPDATE (AGENT.md 11.6). Список - // перечитываем ещё и запросом: снятая иконка приходит пустым полем, а - // слияние в сторе пустое поле не затирает. + // Ответ сервера кладём в стор бесед: новое имя или иконка появляются сразу и + // у владельца, а остальным участникам приходит DM_CHANNEL_UPDATE (AGENT.md + // 11.6). Список перечитываем ещё и запросом: снятая иконка приходит пустым + // полем, а слияние в сторе пустое поле не затирает. const applyUpdated = (updated: Channel): void => { setIconError(null); upsertDmChannel(updated); @@ -178,17 +180,17 @@ export function DirectMessageView({ channelId }: { channelId: string }) { {isOwner ? ( <> setIconMenuOpen(false)} + onClose={() => setOwnerMenuOpen(false)} > + { + setOwnerMenuOpen(false); + setRenameOpen(true); + }} + > + {t('friends.group.rename')} + { - setIconMenuOpen(false); + setOwnerMenuOpen(false); iconInput.current?.click(); }} > @@ -256,7 +268,7 @@ export function DirectMessageView({ channelId }: { channelId: string }) { data-testid="dm-icon-remove" disabled={removeIcon.isPending} onClick={() => { - setIconMenuOpen(false); + setOwnerMenuOpen(false); removeIcon.mutate(); }} > @@ -266,6 +278,15 @@ export function DirectMessageView({ channelId }: { channelId: string }) { ) : null} + {isOwner ? ( + setRenameOpen(false)} + onRenamed={applyUpdated} + /> + ) : null} + {iconError === null ? null : (
diff --git a/web/src/components/friends/GroupRenameDialog.tsx b/web/src/components/friends/GroupRenameDialog.tsx new file mode 100644 index 0000000..6b1234d --- /dev/null +++ b/web/src/components/friends/GroupRenameDialog.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { renameGroupDirectChannel } from '@/api/friends'; +import type { Channel } from '@/api/types'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Field } from '@/components/ui/Field'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/primitives'; + +/** + * Переименование групповой беседы (AGENT.md 7.8, Фаза 7): имя задаёт владелец + * через `PATCH /channels/{id}`, остальным участникам его приносит + * `DM_CHANNEL_UPDATE`. Сервер требует 1–64 символа, поэтому пустое имя + * отправить нельзя — кнопка сохранения заблокирована. + */ +export function GroupRenameDialog({ + channel, + open, + onClose, + onRenamed, +}: { + channel: Channel; + open: boolean; + onClose: () => void; + /** Ответ сервера: беседа с новым именем — кладём в стор и список бесед. */ + onRenamed: (updated: Channel) => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(channel.name); + const formRef = useRef(null); + + const rename = useMutation({ + mutationFn: (value: string) => renameGroupDirectChannel(channel.id, value), + onSuccess: (updated) => { + onRenamed(updated); + onClose(); + }, + }); + const { reset } = rename; + + // Каждое открытие начинается с текущего имени: подстройка состояния под + // изменившийся проп во время рендера (тот же приём, что в `Modal`), эффект + // дал бы лишний кадр со старым черновиком. + const [wasOpen, setWasOpen] = useState(open); + if (wasOpen !== open) { + setWasOpen(open); + if (open) { + setName(channel.name); + } + } + + // Фокус ставим в поле: окно открывают ровно за тем, чтобы набрать имя. + useEffect(() => { + if (open) { + formRef.current?.querySelector('input')?.focus(); + } + }, [open]); + + const trimmed = name.trim(); + + /** Закрытие гасит отказ прошлой попытки: он не должен встречать при открытии. */ + const close = (): void => { + reset(); + onClose(); + }; + + const submit = (): void => { + if (trimmed !== '' && !rename.isPending) { + rename.mutate(trimmed); + } + }; + + return ( + + + + + } + > +
{ + event.preventDefault(); + submit(); + }} + > + setName(event.target.value)} + /> + {rename.isError ? : null} + +
+ ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 97faa95..85d0daf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -445,8 +445,10 @@ "create": "Create", "members": "Members: {{count}}", "open": "Open the group “{{name}}”, members: {{count}}", + "menu": "Conversation menu", + "rename": "Rename", + "renameTitle": "Rename conversation", "icon": { - "menu": "Conversation icon", "upload": "Upload icon", "remove": "Remove icon" } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f744578..424f42d 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -445,8 +445,10 @@ "create": "Создать", "members": "Участников: {{count}}", "open": "Открыть группу «{{name}}», участников: {{count}}", + "menu": "Меню беседы", + "rename": "Переименовать", + "renameTitle": "Переименовать беседу", "icon": { - "menu": "Иконка беседы", "upload": "Загрузить иконку", "remove": "Удалить иконку" } diff --git a/web/tests/dmIcon.test.tsx b/web/tests/dmIcon.test.tsx index c11d6fd..dcee215 100644 --- a/web/tests/dmIcon.test.tsx +++ b/web/tests/dmIcon.test.tsx @@ -154,25 +154,25 @@ describe('иконка групповой беседы', () => { ); }); - it('меню иконки есть у владельца и отсутствует у участника', async () => { + it('меню владельца с иконкой есть у владельца и отсутствует у участника', async () => { const channels = [groupChannel({ iconFileId: '777' })]; installFetch(dmRoutes(() => channels)); await openGroup(); - const menu = await screen.findByTestId('dm-icon-menu'); + const menu = await screen.findByTestId('dm-owner-menu'); await userEvent.click(menu); expect(await screen.findByTestId('dm-icon-upload')).toBeVisible(); // Иконка уже есть: пункт удаления тоже доступен владельцу. expect(screen.getByTestId('dm-icon-remove')).toBeVisible(); }); - it('участник-не-владелец не видит меню иконки, но видит саму иконку', async () => { + it('участник-не-владелец не видит меню владельца, но видит саму иконку', async () => { // Владелец беседы — Боб, текущий пользователь (Alice) только участник. const channels = [groupChannel({ ownerId: 'user-2', iconFileId: '777' })]; installFetch(dmRoutes(() => channels)); await openGroup(); - expect(screen.queryByTestId('dm-icon-menu')).toBeNull(); + expect(screen.queryByTestId('dm-owner-menu')).toBeNull(); expect(screen.getByTestId('dm-group-header').querySelector('img')).toHaveAttribute( 'src', '/files/777', @@ -203,7 +203,7 @@ describe('иконка групповой беседы', () => { const link = await screen.findByTestId('dm-link-dm-group-1'); expect(link.querySelector('img')).toBeNull(); - await userEvent.click(await screen.findByTestId('dm-icon-menu')); + await userEvent.click(await screen.findByTestId('dm-owner-menu')); await userEvent.click(screen.getByTestId('dm-icon-upload')); // Диалог выбора файла в jsdom не открывается: файл кладём в поле напрямую. await userEvent.upload(screen.getByTestId('dm-icon-input'), imageFile()); @@ -255,7 +255,7 @@ describe('иконка групповой беседы', () => { expect(link.querySelector('img')).toHaveAttribute('src', '/files/777'); }); - await userEvent.click(await screen.findByTestId('dm-icon-menu')); + await userEvent.click(await screen.findByTestId('dm-owner-menu')); await userEvent.click(screen.getByTestId('dm-icon-remove')); await waitFor(() => { diff --git a/web/tests/dmRename.test.tsx b/web/tests/dmRename.test.tsx new file mode 100644 index 0000000..080d0f2 --- /dev/null +++ b/web/tests/dmRename.test.tsx @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { Relationships } from '@/api/friends'; +import { + apiError, + bodyOf, + findRequest, + installFetch, + installGatewaySocket, + json, + makeReadySnapshot, + makeUser, + recordedRequests, + renderApp, + type FetchRoute, +} from './helpers'; + +/** + * Переименование групповой беседы (AGENT.md 7.8, D-075): пункт «Переименовать» + * в меню владельца, диалог с текущим именем и `PATCH /channels/{id}`. У + * остальных участников имя обновляется событием `DM_CHANNEL_UPDATE` — без + * перезагрузки и повторного входа (AGENT.md 11.6). + */ + +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: [], incoming: [], outgoing: [], blocked: [] }; + +/** Групповая беседа в REST-форме: владелец настраивается. */ +function groupChannel(options: { ownerId?: string; name?: string } = {}) { + return { + id: 'dm-group-1', + type: 'dm', + is_group: true, + name: options.name ?? 'Игровая', + member_count: 3, + owner_id: options.ownerId ?? 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: options.ownerId === undefined, + 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 dmRoutes(current: () => 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/channels/dm-group-1/messages', response: () => json({ messages: [] }) }, + { match: '/api/v1/channels/dm-group-1/pins', response: () => json({ messages: [] }) }, + { + match: '/api/v1/channels/dm-group-1/ack', + method: 'POST', + response: () => json({ ok: true }), + }, + { match: '/api/v1/users/@me/channels', response: () => json({ channels: current() }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json(instance) }, + ]; +} + +/** Открывает беседу и ждёт её отрисовки в сайдбаре. */ +async function openGroup() { + renderApp('/app/friends/dm-group-1'); + const gateway = installGatewaySocket(makeReadySnapshot()); + await gateway.greet(); + await screen.findByTestId('dm-group-header'); + return gateway; +} + +/** Открывает диалог переименования из меню владельца. */ +async function openRenameDialog() { + await userEvent.click(await screen.findByTestId('dm-owner-menu')); + await userEvent.click(await screen.findByTestId('dm-group-rename')); + return screen.findByRole('dialog', { name: 'Переименовать беседу' }); +} + +describe('переименование групповой беседы', () => { + it('владелец переименовывает беседу: имя меняется в шапке и в списке', async () => { + let channels = [groupChannel()]; + const fetchMock = installFetch( + dmRoutes( + () => channels, + [ + { + match: '/api/v1/channels/dm-group-1', + method: 'PATCH', + response: () => { + const updated = groupChannel({ name: 'Кино по пятницам' }); + channels = [updated]; + return json({ channel: updated }); + }, + }, + ], + ), + ); + await openGroup(); + + const dialog = await openRenameDialog(); + // Поле уже заполнено текущим именем: переименование начинается с правки. + const field = within(dialog).getByLabelText('Название группы'); + expect(field).toHaveValue('Игровая'); + await userEvent.clear(field); + await userEvent.type(field, 'Кино по пятницам'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' })); + + await waitFor(() => { + expect( + bodyOf(findRequest(fetchMock, { url: '/channels/dm-group-1', method: 'PATCH' })), + ).toEqual({ name: 'Кино по пятницам' }); + }); + // Диалог закрылся, а новое имя видно и в шапке, и в списке бесед. + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Переименовать беседу' })).toBeNull(); + }); + expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Кино по пятницам'); + expect(await screen.findByTestId('dm-link-dm-group-1')).toHaveTextContent('Кино по пятницам'); + }); + + it('пустое имя отправить нельзя: кнопка сохранения заблокирована', async () => { + const fetchMock = installFetch(dmRoutes(() => [groupChannel()])); + await openGroup(); + + const dialog = await openRenameDialog(); + await userEvent.clear(within(dialog).getByLabelText('Название группы')); + + expect(within(dialog).getByRole('button', { name: 'Сохранить' })).toBeDisabled(); + // Пробелы именем не считаются: сервер обрезает их и отвечает отказом. + await userEvent.type(within(dialog).getByLabelText('Название группы'), ' '); + expect(within(dialog).getByRole('button', { name: 'Сохранить' })).toBeDisabled(); + expect( + findRequest(fetchMock, { url: '/channels/dm-group-1', method: 'PATCH' }), + ).toBeUndefined(); + }); + + it('отмена закрывает диалог без запроса и не запоминает черновик', async () => { + const fetchMock = installFetch(dmRoutes(() => [groupChannel()])); + await openGroup(); + + const dialog = await openRenameDialog(); + await userEvent.clear(within(dialog).getByLabelText('Название группы')); + await userEvent.type(within(dialog).getByLabelText('Название группы'), 'Черновик'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' })); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Переименовать беседу' })).toBeNull(); + }); + expect( + findRequest(fetchMock, { url: '/channels/dm-group-1', method: 'PATCH' }), + ).toBeUndefined(); + expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Игровая'); + + // Второе открытие снова показывает текущее имя сервера, а не черновик. + const reopened = await openRenameDialog(); + expect(within(reopened).getByLabelText('Название группы')).toHaveValue('Игровая'); + }); + + it('отказ сервера показывается в диалоге, имя остаётся прежним', async () => { + installFetch( + dmRoutes( + () => [groupChannel()], + [ + { + match: '/api/v1/channels/dm-group-1', + method: 'PATCH', + response: () => + apiError('validation.failed', 422, 'group name must be 1-64 characters'), + }, + ], + ), + ); + await openGroup(); + + const dialog = await openRenameDialog(); + const field = within(dialog).getByLabelText('Название группы'); + await userEvent.clear(field); + await userEvent.type(field, 'Слишком длинное имя'); + await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' })); + + // Окно не закрывается: пользователь видит причину и может исправить имя. + expect(await within(dialog).findByRole('alert')).toBeVisible(); + expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Игровая'); + }); + + it('участнику-не-владельцу пункт переименования недоступен', async () => { + installFetch(dmRoutes(() => [groupChannel({ ownerId: 'user-2' })])); + await openGroup(); + + expect(screen.queryByTestId('dm-group-rename')).toBeNull(); + expect(screen.queryByTestId('dm-owner-menu')).toBeNull(); + }); + + it('событие DM_CHANNEL_UPDATE доносит новое имя без перезагрузки', async () => { + let channels = [groupChannel()]; + const fetchMock = installFetch(dmRoutes(() => channels)); + const gateway = await openGroup(); + + // Беседу переименовал владелец (возможно, с другого устройства): сервер + // сообщает об изменении события, а список перечитывается по REST. + channels = [groupChannel({ name: 'Новое имя' })]; + gateway.dispatch('DM_CHANNEL_UPDATE', { channel_id: 'dm-group-1' }); + + await waitFor(() => { + expect(screen.getByTestId('dm-group-header')).toHaveTextContent('Новое имя'); + }); + expect(await screen.findByTestId('dm-link-dm-group-1')).toHaveTextContent('Новое имя'); + // Имя пришло событием: своих PATCH-запросов тест не делал. + expect(recordedRequests(fetchMock).filter((request) => request.method === 'PATCH')).toEqual([]); + }); +});