From 20eed227cac008bf5642b094c47aa1d4ddbdef92 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 01:32:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=B2=D1=85=D0=BE=D0=B4=20=D1=81?= =?UTF-8?q?=202FA=20=D1=81=D1=80=D0=B0=D0=B7=D1=83,=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F,=20=D0=BC=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8,=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные. --- web/src/api/client.ts | 10 +- web/src/api/types.ts | 2 + web/src/api/users.ts | 28 ++ web/src/components/chat/MessageContent.tsx | 6 +- web/src/components/ui/Menu.tsx | 8 +- web/src/components/ui/Modal.tsx | 60 ++- web/src/components/voice/VoiceControls.tsx | 112 +++++ web/src/i18n/locales/en.json | 107 ++++- web/src/i18n/locales/ru.json | 107 ++++- web/src/lib/timezones.ts | 68 ++++ web/src/lib/useMediaDevices.ts | 33 +- web/src/pages/app/ChannelSidebar.tsx | 40 +- web/src/pages/app/UserPanel.tsx | 334 +++++++++++++-- .../pages/settings/GuildNicknameSection.tsx | 54 +++ .../pages/settings/LanguageRegionSection.tsx | 87 ++++ .../settings/NotificationsSettingsSection.tsx | 22 + .../pages/settings/ProfileSettingsPage.tsx | 384 +++++++++++++----- .../pages/settings/ServersSettingsPage.tsx | 252 +++++++----- web/src/pages/settings/SettingsLayout.tsx | 41 +- web/src/pages/settings/SettingsModal.tsx | 131 ++++++ .../pages/settings/VoiceSettingsSection.tsx | 64 +++ web/src/pages/settings/settingsRoute.ts | 42 ++ web/tests/chat.test.tsx | 55 +++ web/tests/helpers.tsx | 9 + web/tests/login.test.tsx | 117 +++++- web/tests/markdown.test.tsx | 161 ++++++++ web/tests/servers.test.tsx | 167 +++++--- web/tests/settings.test.tsx | 378 ++++++++++++++--- web/tests/shell.test.tsx | 23 +- web/tests/userMenu.test.tsx | 78 ++-- web/tests/userPanel.test.tsx | 229 +++++++++++ 31 files changed, 2731 insertions(+), 478 deletions(-) create mode 100644 web/src/components/voice/VoiceControls.tsx create mode 100644 web/src/lib/timezones.ts create mode 100644 web/src/pages/settings/LanguageRegionSection.tsx create mode 100644 web/src/pages/settings/NotificationsSettingsSection.tsx create mode 100644 web/src/pages/settings/SettingsModal.tsx create mode 100644 web/src/pages/settings/VoiceSettingsSection.tsx create mode 100644 web/src/pages/settings/settingsRoute.ts create mode 100644 web/tests/markdown.test.tsx create mode 100644 web/tests/userPanel.test.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 872d241..c0c2d70 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -68,6 +68,8 @@ export function apiErrorField(error: unknown, key: string): unknown { export interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; body?: unknown; + /** Тело multipart (загрузка файлов): заменяет JSON-тело. */ + form?: FormData; signal?: AbortSignal; headers?: Record; } @@ -87,16 +89,20 @@ export function parsePayload(text: string, contentType: string | null): unknown } export async function request(path: string, options: RequestOptions = {}): Promise { + // Multipart отдаёт браузер: Content-Type с boundary выставляет он сам. + const isForm = options.form !== undefined; const init: RequestInit = { method: options.method ?? 'GET', credentials: 'same-origin', headers: { Accept: 'application/json', - ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(options.body === undefined || isForm ? {} : { 'Content-Type': 'application/json' }), ...options.headers, }, }; - if (options.body !== undefined) { + if (isForm) { + init.body = options.form ?? null; + } else if (options.body !== undefined) { init.body = JSON.stringify(options.body); } if (options.signal !== undefined) { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 4296cd8..732c83a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -25,6 +25,8 @@ export interface User { is_instance_admin: boolean; badges: string[]; locale: string; + /** Часовой пояс в формате IANA; сервер может его не отдавать. */ + timezone?: string; onboarding_completed: boolean; /** Включена ли 2FA: от неё зависит требование кода при step-up. */ totp_enabled: boolean; diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 538fe2c..0b37998 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -8,6 +8,8 @@ export interface UpdateProfileInput { custom_status?: string; custom_status_emoji?: string; locale?: string; + /** Часовой пояс в формате IANA (например `Europe/Moscow`). */ + timezone?: string; } export interface OnboardingInput { @@ -44,6 +46,32 @@ export async function completeOnboarding(input: OnboardingInput): Promise return payload.user; } +/** Максимальный размер аватара: сервер отвечает `file.too_large` выше лимита. */ +export const AVATAR_MAX_BYTES = 8 * 1024 * 1024; + +/** + * Загрузка аватара: `POST /users/@me/avatar` multipart с полем `file`. + * + * Сервер может ответить как полным профилем, так и только идентификатором + * файла, поэтому возвращаем профиль, если он есть, иначе `null` — вызывающий + * код в этом случае перечитывает `GET /users/@me`. + */ +export async function uploadAvatar(file: File): Promise { + const form = new FormData(); + form.append('file', file); + const payload = await request<{ user?: User }>('/users/@me/avatar', { + method: 'POST', + form, + }); + return payload.user ?? null; +} + +/** Удаление аватара: `DELETE /users/@me/avatar`. */ +export async function deleteAvatar(): Promise { + const payload = await request<{ user?: User }>('/users/@me/avatar', { method: 'DELETE' }); + return payload.user ?? null; +} + export async function fetchMyGuilds(signal?: AbortSignal): Promise { const payload = await request<{ guilds: GuildSummary[] }>( '/users/@me/guilds', diff --git a/web/src/components/chat/MessageContent.tsx b/web/src/components/chat/MessageContent.tsx index f739e79..4496e66 100644 --- a/web/src/components/chat/MessageContent.tsx +++ b/web/src/components/chat/MessageContent.tsx @@ -26,10 +26,12 @@ export interface MessageContentProps { /** * Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**` * и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался - * ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются. + * ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек + * (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри + * слова (`foo_bar_baz`). */ const INLINE_PATTERN = - /(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|(\*[^*\n]+\*)|((?)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu; + /(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu; /** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */ function splitLink(raw: string): { href: string; trailing: string } { diff --git a/web/src/components/ui/Menu.tsx b/web/src/components/ui/Menu.tsx index f638cf3..8a05fe8 100644 --- a/web/src/components/ui/Menu.tsx +++ b/web/src/components/ui/Menu.tsx @@ -168,6 +168,11 @@ interface MenuItemProps extends ButtonHTMLAttributes { /** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */ selected?: boolean; danger?: boolean; + /** + * Показывать иконку и у выбранного пункта (галочка не заменяет её). + * Нужно для цветных точек статуса: цвет важнее отметки выбора. + */ + alwaysShowIcon?: boolean; } /** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */ @@ -175,6 +180,7 @@ export function MenuItem({ icon, selected = false, danger = false, + alwaysShowIcon = false, className = '', children, ...rest @@ -192,7 +198,7 @@ export function MenuItem({ } ${className}`} > {children} diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx index a4adcf0..8c47cde 100644 --- a/web/src/components/ui/Modal.tsx +++ b/web/src/components/ui/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { motionDelay } from '@/lib/motion'; @@ -9,20 +10,53 @@ interface ModalProps { onClose: () => void; children: ReactNode; footer?: ReactNode; + /** Ширина панели: обычные окна — `md`, настройки — `xl`. */ + size?: 'sm' | 'md' | 'lg' | 'xl'; + /** Дополнительные классы панели (например, ограничение высоты). */ + panelClassName?: string; + /** Дополнительные классы области содержимого (прокрутка длинных форм). */ + bodyClassName?: string; + /** + * Рендерить окно в `document.body`. Нужно, когда модалка открывается из + * узкой колонки (панель пользователя): портал снимает вопросы к вложенным + * `overflow` и трансформациям родителей. + */ + portal?: boolean; } /** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */ const EXIT_MS = 140; +const sizes = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-2xl', + xl: 'max-w-4xl', +} as const; + +/** Открытые окна в порядке появления: последнее получает Escape. */ +const openModals: symbol[] = []; + /** - * Простое модальное окно: закрывается по Escape и по клику на подложку, - * focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса. + * Модальное окно: закрывается по Escape, по клику на подложку и по крестику + * в правом верхнем углу. Escape обрабатывает только верхнее окно — вложенное + * подтверждение (например, «выйти с сервера») не закрывает родителя. * * Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`): * при закрытии узел остаётся в DOM до конца обратной анимации, а при * `prefers-reduced-motion` удаляется сразу. */ -export function Modal({ open, title, onClose, children, footer }: ModalProps) { +export function Modal({ + open, + title, + onClose, + children, + footer, + size = 'md', + panelClassName = '', + bodyClassName = '', + portal = false, +}: ModalProps) { const { t } = useTranslation(); const panelRef = useRef(null); const [mounted, setMounted] = useState(open); @@ -64,8 +98,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { if (!open) { return; } + // Стек открытых окон: Escape закрывает только верхнее. + const token = Symbol('modal'); + openModals.push(token); const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { + if (event.key === 'Escape' && openModals[openModals.length - 1] === token) { onCloseRef.current(); } }; @@ -77,6 +114,10 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { firstField?.focus(); return () => { document.removeEventListener('keydown', onKeyDown); + const index = openModals.indexOf(token); + if (index !== -1) { + openModals.splice(index, 1); + } // Возвращаем фокус туда, откуда окно открыли. previous?.focus(); }; @@ -86,8 +127,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { return null; } - return ( + const node = (

{title}

@@ -117,9 +159,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) { ×
-
{children}
+
{children}
{footer === undefined ? null :
{footer}
}
); + + return portal ? createPortal(node, document.body) : node; } diff --git a/web/src/components/voice/VoiceControls.tsx b/web/src/components/voice/VoiceControls.tsx new file mode 100644 index 0000000..3bf5619 --- /dev/null +++ b/web/src/components/voice/VoiceControls.tsx @@ -0,0 +1,112 @@ +import { useTranslation } from 'react-i18next'; + +import { useMediaDevices } from '@/lib/useMediaDevices'; +import type { VoiceDeviceKind } from '@/stores/voice'; + +/** + * Список устройств ввода/вывода: используется и в поповерах панели + * пользователя, и в разделе настроек «Аудио-видео». + * + * Если API недоступно (старый браузер), показываем «Устройства недоступны»; + * пустой список без разрешения на доступ тоже честнее показать текстом. + */ +export function VoiceDeviceList({ + kind, + label, + value, + onChange, +}: { + kind: VoiceDeviceKind; + label: string; + value: string; + onChange: (deviceId: string) => void; +}) { + const { t } = useTranslation(); + const { devices, supported } = useMediaDevices(kind); + + const optionClass = (selected: boolean): string => + `w-full truncate rounded-[var(--radius-sm)] px-2 py-1 text-left text-sm transition-colors duration-[var(--duration-fast)] ${ + selected ? 'bg-accent/15 text-fg' : 'text-fg-muted hover:bg-surface-3 hover:text-fg' + }`; + + if (!supported) { + return ( +
+

{label}

+

{t('user.devices.unavailable')}

+
+ ); + } + + return ( +
+

{label}

+ {devices.length === 0 ? ( +

{t('user.devices.empty')}

+ ) : ( +
    +
  • + +
  • + {devices.map((device, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +/** Слайдер громкости 0–100: значение уходит в zustand-стор и localStorage. */ +export function VolumeSlider({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (value: number) => void; +}) { + return ( + + ); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4327dcc..31306cf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -100,15 +100,16 @@ "email": "Email", "emailPlaceholder": "you@example.com", "password": "Password", - "totp": "2FA code", - "totpHint": "Six digits from your authenticator app.", + "totp": "2FA or recovery code", + "totpHint": "If 2FA is enabled; a recovery code like xxxx-xxxx-xxxx also works.", "submit": "Sign in", "submitting": "Signing in…", "noAccount": "No account yet?", "registerLink": "Create one", "forgot": "Forgot your password? Ask the instance admin.", "instanceStatus": "Instance status", - "registrationDisabled": "Registration on this instance is disabled by the admin." + "registrationDisabled": "Registration on this instance is disabled by the admin.", + "totpRequired": "Enter the 2FA or recovery code — sign-in needs it." }, "register": { "title": "Create account", @@ -219,7 +220,8 @@ }, "selectUnread": "Open channel {{name}}, {{count}} unread", "selectMentions": "Open channel {{name}}, {{count}} mentions", - "slowmode": "slowmode {{seconds}}s" + "slowmode": "slowmode {{seconds}}s", + "leaveHint": "You can leave the server in server settings → “Danger zone”." }, "chat": { "log": "Message log of {{name}}", @@ -309,7 +311,11 @@ "empty": "Nothing found." }, "unread": "Unread messages: {{count}}", - "mentions": "Mentions: {{count}}" + "mentions": "Mentions: {{count}}", + "spoiler": { + "show": "Reveal spoiler", + "hide": "Hide spoiler" + } }, "user": { "panelLabel": "User panel", @@ -319,7 +325,8 @@ "open": "Open the user menu", "account": "Account settings", "guilds": "Server settings", - "status": "Status" + "status": "Status", + "noBio": "No bio yet." }, "logout": "Sign out", "loggingOut": "Signing out…", @@ -331,6 +338,29 @@ "dnd": "Do not disturb", "invisible": "Invisible", "offline": "Offline" + }, + "voice": { + "input": "Input device", + "output": "Output device", + "camera": "Camera", + "micVolume": "Microphone volume", + "outputVolume": "Sound volume", + "micOn": "Unmute microphone", + "micOff": "Mute microphone", + "cameraOn": "Turn camera on", + "cameraOff": "Turn camera off", + "soundOn": "Undeafen", + "soundOff": "Deafen", + "micSettings": "Microphone settings", + "cameraSettings": "Camera settings", + "soundSettings": "Sound settings", + "audioSettings": "Audio & video settings" + }, + "devices": { + "default": "Default device", + "unavailable": "Devices are unavailable", + "empty": "No devices found", + "unnamed": "Device {{index}}" } }, "gateway": { @@ -357,6 +387,9 @@ "profile": "Profile", "security": "Security", "appearance": "Appearance", + "notifications": "Notifications", + "voice": "Audio & video", + "language": "Language & region", "instance": "Instance" }, "profile": { @@ -375,7 +408,31 @@ "username": "Username", "userId": "User ID", "badges": "Badges", - "noBadges": "none" + "noBadges": "none", + "tabsLabel": "Profile tabs", + "tabs": { + "main": "General", + "server": "Server" + }, + "timezone": "Time zone", + "timezoneHint": "Used to show message times.", + "timezoneDefault": "default (MSK)", + "avatar": { + "title": "Avatar", + "hint": "Everyone can see this picture.", + "preview": "Avatar preview", + "choose": "Choose an avatar file", + "upload": "Upload avatar", + "remove": "Remove avatar", + "limit": "Up to {{limit}}; PNG, JPEG, WebP or GIF.", + "tooLarge": "The file is larger than {{limit}} — pick a smaller image." + }, + "decorations": { + "title": "Decorations", + "hint": "Profile badges and frames.", + "empty": "No decorations yet.", + "stub": "Badges, frames and profile effects are coming later." + } }, "security": { "title": "Security", @@ -467,7 +524,13 @@ "placeholder": "Leave empty to reset the nickname", "current": "Current nickname:", "none": "not set", - "saved": "Nickname saved." + "saved": "Nickname saved.", + "applyAll": "Apply to all servers", + "applyAllHint": "One nickname for every server you are in.", + "applyAllPending": "Applying…", + "applyAllProgress": "Servers updated: {{done}} of {{total}}", + "applyAllDone": "Nickname applied on all servers ({{total}}).", + "applyAllFailed": "Failed to update servers: {{failed}}." }, "members": { "title": "Members", @@ -497,7 +560,33 @@ "deleteConfirmTitle": "Delete the server?", "deleteConfirmBody": "“{{name}}” will be deleted permanently.", "deleteConfirm": "Delete" - } + }, + "selectLabel": "Choose a server" + }, + "navLabel": "Settings sections", + "notifications": { + "title": "Notifications", + "description": "Sounds, mentions and web push.", + "stub": "Notification settings are coming later." + }, + "voice": { + "title": "Audio & video", + "description": "Input and output devices and volume.", + "input": "Microphone", + "camera": "Camera", + "output": "Sound", + "micVolume": "Microphone volume", + "outputVolume": "Sound volume", + "stub": "Voice channels arrive in the next phase — for now these settings are only stored." + }, + "language": { + "title": "Language & region", + "description": "Time zone and interface language.", + "timezone": "Time zone", + "timezoneHint": "Used for message times.", + "locale": "Interface language", + "localeHint": "Applies immediately.", + "saved": "Region settings saved." } } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 6d968ee..99b4dc6 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -100,15 +100,16 @@ "email": "Почта", "emailPlaceholder": "you@example.com", "password": "Пароль", - "totp": "Код 2FA", - "totpHint": "Шесть цифр из приложения-аутентификатора.", + "totp": "Код 2FA или резервный код", + "totpHint": "Если 2FA включена; можно ввести резервный код вида xxxx-xxxx-xxxx.", "submit": "Войти", "submitting": "Входим…", "noAccount": "Ещё нет аккаунта?", "registerLink": "Зарегистрироваться", "forgot": "Пароль забыт? Обратитесь к администратору инстанса.", "instanceStatus": "Состояние инстанса", - "registrationDisabled": "Регистрация на этом инстансе выключена администратором." + "registrationDisabled": "Регистрация на этом инстансе выключена администратором.", + "totpRequired": "Введите код 2FA или резервный код — без него вход невозможен." }, "register": { "title": "Регистрация", @@ -219,7 +220,8 @@ }, "selectUnread": "Открыть комнату {{name}}, непрочитанных: {{count}}", "selectMentions": "Открыть комнату {{name}}, упоминаний: {{count}}", - "slowmode": "медленный режим {{seconds}} с" + "slowmode": "медленный режим {{seconds}} с", + "leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»." }, "chat": { "log": "Лента сообщений комнаты {{name}}", @@ -309,7 +311,11 @@ "empty": "Ничего не найдено." }, "unread": "Непрочитанных сообщений: {{count}}", - "mentions": "Упоминаний: {{count}}" + "mentions": "Упоминаний: {{count}}", + "spoiler": { + "show": "Показать спойлер", + "hide": "Скрыть спойлер" + } }, "user": { "panelLabel": "Панель пользователя", @@ -319,7 +325,8 @@ "open": "Открыть меню пользователя", "account": "Настройки аккаунта", "guilds": "Настройки серверов", - "status": "Статус" + "status": "Статус", + "noBio": "О себе пока не заполнено." }, "logout": "Выйти", "loggingOut": "Выходим…", @@ -331,6 +338,29 @@ "dnd": "Не беспокоить", "invisible": "Невидимый", "offline": "Не в сети" + }, + "voice": { + "input": "Устройство ввода", + "output": "Устройство вывода", + "camera": "Камера", + "micVolume": "Громкость микрофона", + "outputVolume": "Громкость звука", + "micOn": "Включить микрофон", + "micOff": "Выключить микрофон", + "cameraOn": "Включить камеру", + "cameraOff": "Выключить камеру", + "soundOn": "Включить звук", + "soundOff": "Выключить звук", + "micSettings": "Настройки микрофона", + "cameraSettings": "Настройки камеры", + "soundSettings": "Настройки звука", + "audioSettings": "Аудио-видео настройки" + }, + "devices": { + "default": "Устройство по умолчанию", + "unavailable": "Устройства недоступны", + "empty": "Устройства не найдены", + "unnamed": "Устройство {{index}}" } }, "gateway": { @@ -357,6 +387,9 @@ "profile": "Профиль", "security": "Безопасность", "appearance": "Внешний вид", + "notifications": "Уведомления", + "voice": "Аудио-видео", + "language": "Язык и регион", "instance": "Инстанс" }, "profile": { @@ -375,7 +408,31 @@ "username": "Имя пользователя", "userId": "ID пользователя", "badges": "Значки", - "noBadges": "нет" + "noBadges": "нет", + "tabsLabel": "Вкладки профиля", + "tabs": { + "main": "Основной", + "server": "Сервер" + }, + "timezone": "Часовой пояс", + "timezoneHint": "По нему показывается время сообщений.", + "timezoneDefault": "по умолчанию (МСК)", + "avatar": { + "title": "Аватар", + "hint": "Картинку видят все участники.", + "preview": "Предпросмотр аватара", + "choose": "Выбрать файл аватара", + "upload": "Загрузить аватар", + "remove": "Удалить аватар", + "limit": "До {{limit}}; PNG, JPEG, WebP или GIF.", + "tooLarge": "Файл больше {{limit}} — выберите картинку поменьше." + }, + "decorations": { + "title": "Украшения", + "hint": "Бейджи и рамки профиля.", + "empty": "Украшений пока нет.", + "stub": "Бейджи, рамки и эффекты профиля появятся позже." + } }, "security": { "title": "Безопасность", @@ -467,7 +524,13 @@ "placeholder": "Оставьте пустым, чтобы сбросить ник", "current": "Текущий никнейм:", "none": "не задан", - "saved": "Никнейм сохранён." + "saved": "Никнейм сохранён.", + "applyAll": "Применить ко всем серверам", + "applyAllHint": "Один ник для всех серверов, где вы состоите.", + "applyAllPending": "Применяем…", + "applyAllProgress": "Обновлено серверов: {{done}} из {{total}}", + "applyAllDone": "Никнейм применён на всех серверах ({{total}}).", + "applyAllFailed": "Не удалось обновить серверов: {{failed}}." }, "members": { "title": "Участники", @@ -497,7 +560,33 @@ "deleteConfirmTitle": "Удалить сервер?", "deleteConfirmBody": "Сервер «{{name}}» будет удалён без возможности восстановления.", "deleteConfirm": "Удалить" - } + }, + "selectLabel": "Выбрать сервер" + }, + "navLabel": "Пункты настроек", + "notifications": { + "title": "Уведомления", + "description": "Звуки, упоминания и веб-пуш.", + "stub": "Настройки уведомлений появятся позже." + }, + "voice": { + "title": "Аудио-видео", + "description": "Устройства ввода и вывода и громкость.", + "input": "Микрофон", + "camera": "Камера", + "output": "Звук", + "micVolume": "Громкость микрофона", + "outputVolume": "Громкость звука", + "stub": "Голосовые комнаты появятся в следующей фазе — пока настройки только сохраняются." + }, + "language": { + "title": "Язык и регион", + "description": "Часовой пояс и язык интерфейса.", + "timezone": "Часовой пояс", + "timezoneHint": "Используется для времени сообщений.", + "locale": "Язык интерфейса", + "localeHint": "Переключается сразу.", + "saved": "Настройки региона сохранены." } } } diff --git a/web/src/lib/timezones.ts b/web/src/lib/timezones.ts new file mode 100644 index 0000000..a68f153 --- /dev/null +++ b/web/src/lib/timezones.ts @@ -0,0 +1,68 @@ +/** + * Часовые пояса для настроек профиля и раздела «Язык и регион». + * + * Значение — идентификатор IANA, как его принимает сервер; подпись собирается + * из названия зоны и текущего смещения (смещение считает `Intl`, поэтому + * переход на летнее время учитывается автоматически). + */ + +/** Пояс по умолчанию: Москва (МСК). */ +export const DEFAULT_TIMEZONE = 'Europe/Moscow'; + +export const timezoneOptions = [ + 'Europe/Kaliningrad', + 'Europe/Moscow', + 'Europe/Samara', + 'Asia/Yekaterinburg', + 'Asia/Almaty', + 'Asia/Tashkent', + 'Asia/Novosibirsk', + 'Asia/Krasnoyarsk', + 'Asia/Irkutsk', + 'Asia/Yakutsk', + 'Asia/Vladivostok', + 'Asia/Magadan', + 'Asia/Kamchatka', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Paris', + 'Asia/Tbilisi', + 'Asia/Yerevan', + 'Asia/Dubai', + 'Asia/Tokyo', + 'Asia/Shanghai', + 'America/New_York', + 'America/Los_Angeles', + 'UTC', +] as const; + +/** Смещение вида `UTC+03:00` (для зоны, которой нет в ICU, — пустая строка). */ +export function timezoneOffset(timeZone: string, locale = 'ru'): string { + try { + const parts = new Intl.DateTimeFormat(locale, { + timeZone, + timeZoneName: 'shortOffset', + }).formatToParts(new Date()); + return parts.find((part) => part.type === 'timeZoneName')?.value ?? ''; + } catch { + return ''; + } +} + +/** Подпись пункта списка: `Europe/Moscow (UTC+03:00)`. */ +export function timezoneLabel(timeZone: string, locale = 'ru'): string { + const offset = timezoneOffset(timeZone, locale); + return offset === '' ? timeZone : `${timeZone} (${offset})`; +} + +/** + * Все зоны списка плюс значение из профиля, если его там ещё нет: селект не + * должен «терять» часовой пояс, выставленный с другого клиента. + */ +export function timezoneChoices(value: string): string[] { + const options: string[] = [...timezoneOptions]; + if (value !== '' && !options.includes(value)) { + options.unshift(value); + } + return options; +} diff --git a/web/src/lib/useMediaDevices.ts b/web/src/lib/useMediaDevices.ts index 7643d0b..c1adac6 100644 --- a/web/src/lib/useMediaDevices.ts +++ b/web/src/lib/useMediaDevices.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import type { VoiceDeviceKind } from '@/stores/voice'; +import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice'; /** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */ export interface MediaDevice { @@ -30,13 +30,15 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult { useEffect(() => { if (!supported) { - setDevices([]); + // API нет — список остаётся пустым, интерфейс покажет «недоступны». return; } + // Ссылку запоминаем: к моменту очистки эффекта API может быть уже недоступно. + const mediaDevices = navigator.mediaDevices; let cancelled = false; const load = (): void => { - void navigator.mediaDevices + void mediaDevices .enumerateDevices() .then((list) => { if (cancelled) { @@ -57,12 +59,33 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult { load(); // Список меняется при подключении гарнитуры — обновляем его на лету. - navigator.mediaDevices.addEventListener?.('devicechange', load); + mediaDevices.addEventListener?.('devicechange', load); return () => { cancelled = true; - navigator.mediaDevices.removeEventListener?.('devicechange', load); + mediaDevices.removeEventListener?.('devicechange', load); }; }, [kind, supported]); return { devices, supported }; } + +/** + * Выбранное устройство нужного вида из стора предпочтений. + * Одна обёртка на три вида — чтобы не дублировать селекторы в компонентах. + */ +export function useDeviceChoice(kind: VoiceDeviceKind): { + value: string; + select: (deviceId: string) => void; +} { + const value = useVoiceStore((state) => { + if (kind === 'audioinput') { + return state.micDeviceId; + } + if (kind === 'videoinput') { + return state.cameraDeviceId; + } + return state.outputDeviceId; + }); + const selectDevice = useVoiceStore((state) => state.selectDevice); + return { value, select: (deviceId: string) => selectDevice(kind, deviceId) }; +} diff --git a/web/src/pages/app/ChannelSidebar.tsx b/web/src/pages/app/ChannelSidebar.tsx index cc821f3..49704d5 100644 --- a/web/src/pages/app/ChannelSidebar.tsx +++ b/web/src/pages/app/ChannelSidebar.tsx @@ -1,13 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Link, NavLink, useNavigate } from 'react-router'; +import { NavLink, useNavigate } from 'react-router'; -import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds'; +import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds'; import type { Channel } from '@/api/types'; import { ChannelBadge } from '@/components/chat/ChannelBadge'; import { useChannelUnread } from '@/components/chat/useChannelUnread'; -import { myGuildsQueryKey } from '@/api/users'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field, SelectField } from '@/components/ui/Field'; import { Modal } from '@/components/ui/Modal'; @@ -17,6 +16,7 @@ import { groupChannels } from '@/lib/channels'; import { canManageGuild, canManageGuildSettings } from '@/lib/identity'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; +import { useUiStore } from '@/stores/ui'; interface ChannelSidebarProps { guildId: string | null; @@ -79,7 +79,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { ); const setGuildChannels = useSessionStore((state) => state.setGuildChannels); const upsertChannel = useSessionStore((state) => state.upsertChannel); - const removeGuild = useSessionStore((state) => state.removeGuild); + const openSettings = useUiStore((state) => state.openSettings); const [createOpen, setCreateOpen] = useState(false); const [channelName, setChannelName] = useState(''); @@ -121,17 +121,6 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) { }, }); - const leave = useMutation({ - mutationFn: () => leaveGuild(guildId ?? ''), - onSuccess: () => { - if (guildId !== null) { - removeGuild(guildId); - } - void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey }); - void navigate('/app/empty', { replace: true }); - }, - }); - const groups = useMemo(() => groupChannels(channels), [channels]); const categories = useMemo( () => channels.filter((channel) => channel.type === 'category'), @@ -163,14 +152,15 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
{canManageSettings ? ( - openSettings({ section: 'profile', tab: 'server', guildId })} > ⚙ - + ) : null} {canManage ? ( - {leave.isError ? : null} +

{t('channels.leaveHint')}

value !== 'offline'); +/** Цвет точки статуса — тот же, что в списках участников. */ +const statusDotClass: Record = { + online: 'bg-success', + idle: 'bg-warning', + dnd: 'bg-danger', + invisible: 'bg-fg-muted', + offline: 'bg-fg-muted', +}; + +/** Иконка с перечёркиванием для выключенного состояния (как в мессенджерах). */ +function PanelIcon({ glyph, off }: { glyph: string; off: boolean }) { + return ( +
+ + {applyAll === undefined ? null : ( +
+
+

+ {t('settings.servers.nickname.applyAllHint')} +

+ +
+ + {applyAll.progress === null ? null : ( +

+ {t('settings.servers.nickname.applyAllProgress', { + done: applyAll.progress.done, + total: applyAll.progress.total, + })} +

+ )} + {applyAll.result === null ? null : applyAll.result.failed === 0 ? ( +

+ {t('settings.servers.nickname.applyAllDone', { total: applyAll.result.total })} +

+ ) : ( +

+ {t('settings.servers.nickname.applyAllFailed', { + failed: applyAll.result.failed, + })} +

+ )} +
+ )} ); } diff --git a/web/src/pages/settings/LanguageRegionSection.tsx b/web/src/pages/settings/LanguageRegionSection.tsx new file mode 100644 index 0000000..1ca401f --- /dev/null +++ b/web/src/pages/settings/LanguageRegionSection.tsx @@ -0,0 +1,87 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { currentUserQueryKey, updateProfile } from '@/api/users'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { SelectField } from '@/components/ui/Field'; +import { SkeletonLines } from '@/components/ui/Skeleton'; +import { supportedLanguages, type SupportedLanguage } from '@/i18n'; +import { useCurrentUser } from '@/lib/hooks'; +import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones'; +import { useSessionStore } from '@/stores/session'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +/** + * «Язык и регион»: часовой пояс (уходит на сервер сразу) и язык интерфейса + * (локально + в профиль, чтобы выбор пережил переустановку браузера). + */ +export function LanguageRegionSection() { + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); + const currentUser = useCurrentUser(); + const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru'; + + const save = useMutation({ + mutationFn: (input: { timezone?: string; locale?: string }) => updateProfile(input), + onSuccess: (updated) => { + queryClient.setQueryData(currentUserQueryKey, updated); + useSessionStore.getState().patchUser(updated); + }, + }); + + if (currentUser.isPending) { + return ( + + + + ); + } + + const timezone = currentUser.data?.timezone ?? DEFAULT_TIMEZONE; + const language: SupportedLanguage = currentUser.data?.locale === 'en' ? 'en' : 'ru'; + + const changeLanguage = (value: SupportedLanguage): void => { + window.localStorage.setItem('glchat.language', value); + void i18n.changeLanguage(value); + save.mutate({ locale: value }); + }; + + return ( + +
+ save.mutate({ timezone: value })} + options={timezoneChoices(timezone).map((zone) => ({ + value: zone, + label: + zone === DEFAULT_TIMEZONE + ? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}` + : timezoneLabel(zone, locale), + }))} + /> + changeLanguage(value === 'en' ? 'en' : 'ru')} + options={supportedLanguages.map((value) => ({ + value, + label: t(`language.${value}`), + }))} + /> + {save.isError ? : null} + {save.isSuccess ? ( +

+ {t('settings.language.saved')} +

+ ) : null} +
+
+ ); +} diff --git a/web/src/pages/settings/NotificationsSettingsSection.tsx b/web/src/pages/settings/NotificationsSettingsSection.tsx new file mode 100644 index 0000000..e976c4b --- /dev/null +++ b/web/src/pages/settings/NotificationsSettingsSection.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; + +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +/** + * «Уведомления»: пункт уже есть в списке настроек, но сами уведомления + * (веб-пуш и звуки) появятся в следующих фазах — показываем заглушку. + */ +export function NotificationsSettingsSection() { + const { t } = useTranslation(); + + return ( + +

+ {t('settings.notifications.stub')} +

+
+ ); +} diff --git a/web/src/pages/settings/ProfileSettingsPage.tsx b/web/src/pages/settings/ProfileSettingsPage.tsx index 2f24fe4..2139686 100644 --- a/web/src/pages/settings/ProfileSettingsPage.tsx +++ b/web/src/pages/settings/ProfileSettingsPage.tsx @@ -1,47 +1,159 @@ -import { useState, type FormEvent } from 'react'; +import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users'; +import { + AVATAR_MAX_BYTES, + deleteAvatar, + updateProfile, + uploadAvatar, + currentUserQueryKey, + type UpdateProfileInput, +} from '@/api/users'; import { userStatuses, type User } from '@/api/types'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field, SelectField, TextAreaField } from '@/components/ui/Field'; -import { Button, Card } from '@/components/ui/primitives'; +import { Avatar } from '@/components/ui/Avatar'; +import { Badge, Button, Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; import { supportedLanguages, type SupportedLanguage } from '@/i18n'; +import { formatBytes } from '@/lib/format'; import { useCurrentUser } from '@/lib/hooks'; +import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones'; import { useSessionStore } from '@/stores/session'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +/** Ссылка на локальный файл: недоступна в части окружений (и в jsdom). */ +function safeObjectUrl(file: File): string | null { + if (typeof URL.createObjectURL !== 'function') { + return null; + } + try { + return URL.createObjectURL(file); + } catch { + return null; + } +} + +function revokeObjectUrl(url: string): void { + try { + URL.revokeObjectURL(url); + } catch { + // Ссылка уже недействительна — освобождать нечего. + } +} + +/** Показываем выбранный файл до ответа сервера. */ +function useAvatarPreview() { + const [preview, setPreview] = useState(null); + const show = (file: File): void => { + const url = safeObjectUrl(file); + if (url === null) { + return; + } + setPreview((previous) => { + if (previous !== null) { + revokeObjectUrl(previous); + } + return url; + }); + }; + const clear = (): void => { + setPreview((previous) => { + if (previous !== null) { + revokeObjectUrl(previous); + } + return null; + }); + }; + return { preview, show, clear }; +} /** * Форма профиля. Состояние инициализируется из `user` при монтировании, * а после сохранения кэш React Query обновляется ответом сервера — поэтому * синхронизация через эффект не нужна. */ -function ProfileForm({ user }: { user: User }) { +export function ProfileForm({ user }: { user: User }) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); + const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru'; const [displayName, setDisplayName] = useState(user.display_name); const [bio, setBio] = useState(user.bio); const [customStatus, setCustomStatus] = useState(user.custom_status); const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji); const [status, setStatus] = useState(user.status); - const [locale, setLocale] = useState(user.locale === 'en' ? 'en' : 'ru'); + const [tz, setTz] = useState(user.timezone ?? DEFAULT_TIMEZONE); + const [language, setLanguage] = useState(user.locale === 'en' ? 'en' : 'ru'); const [saved, setSaved] = useState(false); + const [avatarError, setAvatarError] = useState(null); + const fileInputRef = useRef(null); + const avatar = useAvatarPreview(); + + /** Профиль из ответа сервера: кэш, снапшот сессии и язык интерфейса. */ + const applyUser = (updated: User): void => { + queryClient.setQueryData(currentUserQueryKey, updated); + useSessionStore.getState().patchUser(updated); + if (updated.locale === 'en' || updated.locale === 'ru') { + window.localStorage.setItem('glchat.language', updated.locale); + void i18n.changeLanguage(updated.locale); + } + }; const save = useMutation({ mutationFn: (input: UpdateProfileInput) => updateProfile(input), onSuccess: (updated) => { - queryClient.setQueryData(currentUserQueryKey, updated); - useSessionStore.getState().patchUser(updated); + applyUser(updated); setSaved(true); - if (updated.locale === 'en' || updated.locale === 'ru') { - window.localStorage.setItem('glchat.language', updated.locale); - void i18n.changeLanguage(updated.locale); - } }, }); + const upload = useMutation({ + mutationFn: (file: File) => uploadAvatar(file), + onSuccess: (updated) => { + avatar.clear(); + setAvatarError(null); + if (updated === null) { + // Сервер ответил без профиля — перечитываем его запросом. + void queryClient.invalidateQueries({ queryKey: currentUserQueryKey }); + return; + } + applyUser(updated); + }, + onError: () => avatar.clear(), + }); + + const removeAvatar = useMutation({ + mutationFn: () => deleteAvatar(), + onSuccess: (updated) => { + setAvatarError(null); + avatar.clear(); + if (updated !== null) { + applyUser(updated); + } + void queryClient.invalidateQueries({ queryKey: currentUserQueryKey }); + }, + }); + + const onPickFile = (event: ChangeEvent): void => { + const file = event.target.files?.[0]; + // Поле переиспользуется: иначе повторный выбор того же файла не сработает. + event.target.value = ''; + if (file === undefined) { + return; + } + setAvatarError(null); + if (file.size > AVATAR_MAX_BYTES) { + setAvatarError( + t('settings.profile.avatar.tooLarge', { limit: formatBytes(AVATAR_MAX_BYTES, locale) }), + ); + return; + } + avatar.show(file); + upload.mutate(file); + }; + const onSubmit = (event: FormEvent): void => { event.preventDefault(); setSaved(false); @@ -51,100 +163,188 @@ function ProfileForm({ user }: { user: User }) { custom_status: customStatus.trim(), custom_status_emoji: customEmoji.trim(), status, - locale, + locale: language, + timezone: tz, }); }; + const avatarName = displayName === '' ? user.username : displayName; + return ( - -

{t('settings.profile.title')}

-

{t('settings.profile.description')}

+
+ +

{t('settings.profile.title')}

+

{t('settings.profile.description')}

-
- setDisplayName(event.target.value)} - /> - setBio(event.target.value)} - /> -
+ setCustomStatus(event.target.value)} + label={t('settings.profile.displayName')} + name="display_name" + value={displayName} + maxLength={64} + onChange={(event) => setDisplayName(event.target.value)} /> - setCustomEmoji(event.target.value)} + setBio(event.target.value)} /> +
+ setCustomStatus(event.target.value)} + /> + setCustomEmoji(event.target.value)} + /> +
+ ({ + value, + label: t(`user.status.${value}`), + }))} + /> + ({ + value: zone, + label: + zone === DEFAULT_TIMEZONE + ? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}` + : timezoneLabel(zone, locale), + }))} + /> + setLanguage(value === 'en' ? 'en' : 'ru')} + options={supportedLanguages.map((value) => ({ + value, + label: t(`language.${value}`), + }))} + /> + +
+
+
{t('settings.profile.username')}
+
{user.username}
+
+
+
{t('settings.profile.userId')}
+
{user.id}
+
+
+
{t('settings.profile.badges')}
+
+ {user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')} +
+
+
+ + {save.isError ? : null} + {saved && !save.isError ? ( +

+ {t('settings.profile.saved')} +

+ ) : null} + +
+ +
+ + + + +
+ {avatar.preview === null ? ( + + ) : ( + {t('settings.profile.avatar.preview')} + )} +
+ + + {user.avatar_file_id === undefined ? null : ( + + )} +

+ {t('settings.profile.avatar.limit', { + limit: formatBytes(AVATAR_MAX_BYTES, locale), + })} +

+
- ({ - value, - label: t(`user.status.${value}`), - }))} - /> - setLocale(value === 'en' ? 'en' : 'ru')} - options={supportedLanguages.map((language) => ({ - value: language, - label: t(`language.${language}`), - }))} - /> -
-
-
{t('settings.profile.username')}
-
{user.username}
-
-
-
{t('settings.profile.userId')}
-
{user.id}
-
-
-
{t('settings.profile.badges')}
-
- {user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')} -
-
-
- - {save.isError ? : null} - {saved && !save.isError ? ( -

- {t('settings.profile.saved')} + {avatarError === null ? null : ( +

+ {avatarError}

- ) : null} + )} + {upload.isError ? : null} + {removeAvatar.isError ? : null} +
-
- + +
+ {user.badges.length === 0 ? ( +

{t('settings.profile.decorations.empty')}

+ ) : ( + user.badges.map((badge) => {badge}) + )}
- - +

{t('settings.profile.decorations.stub')}

+
+
); } diff --git a/web/src/pages/settings/ServersSettingsPage.tsx b/web/src/pages/settings/ServersSettingsPage.tsx index f021814..4ebda46 100644 --- a/web/src/pages/settings/ServersSettingsPage.tsx +++ b/web/src/pages/settings/ServersSettingsPage.tsx @@ -1,12 +1,18 @@ -import { useEffect, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { NavLink, useNavigate, useParams } from 'react-router'; -import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds'; +import { + fetchGuild, + fetchMembers, + guildMembersQueryKey, + guildQueryKey, + updateGuildMember, +} from '@/api/guilds'; import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users'; import { Avatar } from '@/components/ui/Avatar'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu'; import { Card } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; import { canManageGuildSettings } from '@/lib/identity'; @@ -20,21 +26,21 @@ import { GuildRolesSection } from '@/pages/settings/GuildRolesSection'; import { SettingsSection } from '@/pages/settings/SettingsSection'; /** - * Настройки пользовательских серверов (`/settings/servers/:guildId`). + * Вкладка «Профиль → Сервер»: мой профиль на конкретном сервере и (для + * владельца и прав управления) администрирование сервера. * * Список серверов берём из READY-снапшота, а если шлюз ещё не подключился — - * из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится - * в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает. + * из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же + * сбрасывает «Опасная зона» после выхода с сервера. */ -export default function ServersSettingsPage() { +export function ServersSettingsPage() { const { t } = useTranslation(); - const navigate = useNavigate(); - const params = useParams<{ guildId?: string }>(); - const routeGuildId = params.guildId; + const queryClient = useQueryClient(); const currentUser = useCurrentUser(); const sessionGuilds = useSessionStore((state) => state.guilds); const upsertGuild = useSessionStore((state) => state.upsertGuild); + const selectedGuildId = useSessionStore((state) => state.settingsGuildId); const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild); const myGuilds = useQuery({ @@ -58,25 +64,27 @@ export default function ServersSettingsPage() { () => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])), [sessionGuilds, myGuilds.data], ); - const active = useMemo( - () => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)), - [guilds, routeGuildId], - ); - const activeId = active?.id ?? null; - // URL — источник правды; стор запоминает последний открытый сервер. + const active = useMemo( + () => guilds.find((guild) => guild.id === selectedGuildId) ?? null, + [guilds, selectedGuildId], + ); + + // Первый доступный сервер выбираем сами: список только что загрузился. useEffect(() => { - if (activeId !== null) { - selectSettingsGuild(activeId); + if (active !== null || guilds.length === 0) { return; } - if (routeGuildId === undefined && guilds.length > 0) { - const first = guilds[0]; - if (first !== undefined) { - void navigate(`/settings/servers/${first.id}`, { replace: true }); - } + const first = guilds[0]; + if (first !== undefined) { + selectSettingsGuild(first.id); } - }, [activeId, routeGuildId, guilds, navigate, selectSettingsGuild]); + }, [active, guilds, selectSettingsGuild]); + + const activeId = active?.id ?? null; + + const [pickerOpen, setPickerOpen] = useState(false); + const pickerRef = useRef(null); const detail = useQuery({ queryKey: guildQueryKey(activeId ?? ''), @@ -92,6 +100,35 @@ export default function ServersSettingsPage() { retry: 0, }); + // Прогресс массового применения ника: `done` растёт после каждого сервера. + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + const [applyResult, setApplyResult] = useState<{ failed: number; total: number } | null>(null); + + const userId = currentUser.data?.id; + + const applyAll = useMutation({ + mutationFn: async (nickname: string): Promise<{ failed: number; total: number }> => { + const list = guilds; + let failed = 0; + setApplyResult(null); + setProgress({ done: 0, total: list.length }); + // Последовательно, а не параллельно: серверные лимиты и понятный прогресс. + for (const [index, guild] of list.entries()) { + try { + await updateGuildMember(guild.id, userId ?? '', { nickname }); + useSessionStore.getState().setMyNickname(guild.id, nickname); + } catch { + failed += 1; + } + void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guild.id) }); + setProgress({ done: index + 1, total: list.length }); + } + return { failed, total: list.length }; + }, + onSuccess: (result) => setApplyResult(result), + onSettled: () => setProgress(null), + }); + const guild = detail.data; const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null; const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? []; @@ -102,76 +139,97 @@ export default function ServersSettingsPage() { if (fromSnapshot !== undefined && fromSnapshot !== '') { return fromSnapshot; } - const mine = members.data?.find((member) => member.user_id === currentUser.data?.id); + const mine = members.data?.find((member) => member.user_id === userId); return mine?.nickname ?? ''; - }, [sessionGuild, members.data, currentUser.data?.id]); + }, [sessionGuild, members.data, userId]); + + const onApplyAll = useCallback( + (nickname: string) => { + applyAll.reset(); + applyAll.mutate(nickname); + }, + [applyAll], + ); + + if (guilds.length === 0) { + return myGuilds.isPending ? ( + + + + ) : ( + +

{t('settings.servers.empty')}

+
+ ); + } return (
- -

- {t('settings.servers.pickerLabel')} -

- {guilds.length === 0 ? ( - myGuilds.isPending ? ( - - ) : ( -

{t('settings.servers.empty')}

- ) - ) : ( - - )} -
+
+ - {guilds.length === 0 ? ( - myGuilds.isPending ? ( - - - - ) : ( - -
- - {t('settings.back')} - -
-
- ) - ) : activeId === null ? ( - // Сервер из URL не найден среди доступных пользователю. + setPickerOpen(false)} + > + {t('settings.servers.pickerLabel')} + {guilds.map((item) => ( + + } + onClick={() => { + selectSettingsGuild(item.id); + setPickerOpen(false); + }} + > + {item.name} + + ))} + +
+ + {activeId === null ? (

{t('settings.servers.notFound')}

@@ -185,13 +243,19 @@ export default function ServersSettingsPage() { ) : ( <> - + @@ -211,3 +275,5 @@ export default function ServersSettingsPage() {
); } + +export default ServersSettingsPage; diff --git a/web/src/pages/settings/SettingsLayout.tsx b/web/src/pages/settings/SettingsLayout.tsx index 5ba671f..9d11914 100644 --- a/web/src/pages/settings/SettingsLayout.tsx +++ b/web/src/pages/settings/SettingsLayout.tsx @@ -1,35 +1,24 @@ -import { useTranslation } from 'react-i18next'; -import { NavLink, Outlet, useLocation } from 'react-router'; +import { useEffect, useMemo } from 'react'; +import { Navigate, useLocation } from 'react-router'; -import { SettingsSwitcher } from '@/pages/settings/SettingsSwitcher'; -import { SettingsSuspense } from '@/pages/settings/SettingsSection'; +import { parseSettingsPath } from '@/pages/settings/settingsRoute'; +import { useUiStore } from '@/stores/ui'; /** - * Каркас настроек: две соседние кнопки-раздела («Настройки сервера» и - * «Мои серверы») и содержимое активного раздела. Переключение разделов — - * обычная навигация react-router, без перезагрузки страницы. + * Старые страницы настроек (`/settings/*`) больше не отдельные экраны: + * настройки открываются модальным окном поверх приложения (см. `SettingsModal`). + * + * Маршрут остаётся, чтобы не ломать закладки: определяем нужный пункт по пути, + * открываем окно и заменяем адрес на `/app`. */ export default function SettingsLayout() { - const { t } = useTranslation(); const location = useLocation(); + const openSettings = useUiStore((state) => state.openSettings); + const target = useMemo(() => parseSettingsPath(location.pathname), [location.pathname]); - return ( -
-
-

{t('settings.title')}

- - {t('settings.back')} - -
+ useEffect(() => { + openSettings(target); + }, [openSettings, target]); - - - {/* Ключ по пути: при смене вкладки/раздела контент появляется с анимацией. */} -
- - - -
-
- ); + return ; } diff --git a/web/src/pages/settings/SettingsModal.tsx b/web/src/pages/settings/SettingsModal.tsx new file mode 100644 index 0000000..ee6dc79 --- /dev/null +++ b/web/src/pages/settings/SettingsModal.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from 'react-i18next'; + +import { Modal } from '@/components/ui/Modal'; +import { useCurrentUser } from '@/lib/hooks'; +import { useUiStore, type SettingsSection } from '@/stores/ui'; +import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage'; +import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage'; +import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection'; +import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection'; +import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage'; +import SecuritySettingsPage from '@/pages/settings/SecuritySettingsPage'; +import { ServersSettingsPage } from '@/pages/settings/ServersSettingsPage'; +import { VoiceSettingsSection } from '@/pages/settings/VoiceSettingsSection'; + +/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */ +function ProfileTabs() { + const { t } = useTranslation(); + const tab = useUiStore((state) => state.settingsProfileTab); + const setTab = useUiStore((state) => state.setSettingsProfileTab); + + const tabs = [ + { id: 'main' as const, label: t('settings.profile.tabs.main') }, + { id: 'server' as const, label: t('settings.profile.tabs.server') }, + ]; + + return ( +
+
+ {tabs.map((item) => ( + + ))} +
+ + {tab === 'main' ? : } +
+ ); +} + +/** + * Модальное окно настроек: слева вертикальный список пунктов, справа + * содержимое выбранного. Открывается из панели пользователя (шестерёнка) и из + * меню пользователя, а также при переходе на старые адреса `/settings/*`. + * + * Окно живёт в портале: панель пользователя — узкая колонка, и портал снимает + * вопросы к её `overflow` и анимационным трансформациям. + */ +export function SettingsModal() { + const { t } = useTranslation(); + const open = useUiStore((state) => state.settingsOpen); + const section = useUiStore((state) => state.settingsSection); + const setSection = useUiStore((state) => state.setSettingsSection); + const closeSettings = useUiStore((state) => state.closeSettings); + const currentUser = useCurrentUser(); + const isAdmin = currentUser.data?.is_instance_admin === true; + + const items: { id: SettingsSection; label: string }[] = [ + { id: 'profile', label: t('settings.tabs.profile') }, + { id: 'security', label: t('settings.tabs.security') }, + { id: 'appearance', label: t('settings.tabs.appearance') }, + { id: 'notifications', label: t('settings.tabs.notifications') }, + { id: 'voice', label: t('settings.tabs.voice') }, + { id: 'language', label: t('settings.tabs.language') }, + ...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []), + ]; + + return ( + + + + {/* Ключ по пункту: при переключении прокрутка и локальное состояние + секции (например, шаг настройки 2FA) сбрасываются. */} +
+ {section === 'security' ? : null} + {section === 'appearance' ? : null} + {section === 'notifications' ? : null} + {section === 'voice' ? : null} + {section === 'language' ? : null} + {section === 'instance' ? : null} + {section === 'profile' ? : null} +
+
+ ); +} diff --git a/web/src/pages/settings/VoiceSettingsSection.tsx b/web/src/pages/settings/VoiceSettingsSection.tsx new file mode 100644 index 0000000..eee5772 --- /dev/null +++ b/web/src/pages/settings/VoiceSettingsSection.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from 'react-i18next'; + +import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls'; +import { useDeviceChoice } from '@/lib/useMediaDevices'; +import { useVoiceStore } from '@/stores/voice'; +import { SettingsSection } from '@/pages/settings/SettingsSection'; + +/** + * «Аудио-видео»: выбор устройств ввода/вывода и громкости. + * + * Значения пока только сохраняются локально (zustand + localStorage): + * голосовые комнаты появятся в Фазе 3, тогда их начнёт использовать RTC. + */ +export function VoiceSettingsSection() { + const { t } = useTranslation(); + const mic = useDeviceChoice('audioinput'); + const camera = useDeviceChoice('videoinput'); + const output = useDeviceChoice('audiooutput'); + const micVolume = useVoiceStore((state) => state.micVolume); + const outputVolume = useVoiceStore((state) => state.outputVolume); + const setMicVolume = useVoiceStore((state) => state.setMicVolume); + const setOutputVolume = useVoiceStore((state) => state.setOutputVolume); + + return ( + +
+ + + +
+ + +
+
+

{t('settings.voice.stub')}

+
+ ); +} diff --git a/web/src/pages/settings/settingsRoute.ts b/web/src/pages/settings/settingsRoute.ts new file mode 100644 index 0000000..7663265 --- /dev/null +++ b/web/src/pages/settings/settingsRoute.ts @@ -0,0 +1,42 @@ +import type { SettingsTarget } from '@/stores/ui'; + +/** + * Разбор старых адресов настроек `/settings/*`. + * + * Раньше это были отдельные страницы, теперь они ведут в приложение с + * открытым модальным окном настроек: закладки и внешние ссылки продолжают + * работать, а сам раздел живёт в одном месте. + */ +export function parseSettingsPath(pathname: string): SettingsTarget { + // `/settings` → ['settings'], `/settings/servers/g-1` → ['settings','servers','g-1'] + const segments = pathname.split('/').filter((segment) => segment !== ''); + const rest = segments.slice(1); + + if (rest[0] === 'servers') { + return { + section: 'profile', + tab: 'server', + guildId: rest[1] === undefined ? null : decodeURIComponent(rest[1]), + }; + } + + // `/settings/account/security` и старый `/settings/security` — одно и то же. + const key = rest[0] === 'account' ? (rest[1] ?? 'profile') : (rest[0] ?? 'profile'); + + switch (key) { + case 'security': + return { section: 'security' }; + case 'appearance': + return { section: 'appearance' }; + case 'notifications': + return { section: 'notifications' }; + case 'voice': + return { section: 'voice' }; + case 'language': + return { section: 'language' }; + case 'instance': + return { section: 'instance' }; + default: + return { section: 'profile', tab: 'main' }; + } +} diff --git a/web/tests/chat.test.tsx b/web/tests/chat.test.tsx index 69712b2..50ebab0 100644 --- a/web/tests/chat.test.tsx +++ b/web/tests/chat.test.tsx @@ -517,6 +517,61 @@ describe('чат комнаты: реакции и ответы', () => { expect(screen.queryByText('Ответ Аля')).toBeNull(); }); }); + + it('двойной клик по сообщению ставит его в ответ', async () => { + await boot({ + messages: [message({ id: 'm-1', content: 'оригинал', author_id: 'user-2' })], + }); + + const article = await screen.findByTestId('message-m-1'); + fireEvent.doubleClick(article); + + // Та же плашка, что и у пункта «Ответить» в меню. + expect(await screen.findByText('Ответ Bob')).toBeVisible(); + }); + + it('двойной клик по ссылке не ставит ответ', async () => { + await boot({ + messages: [ + message({ id: 'm-1', content: 'смотри https://example.com/page', author_id: 'user-2' }), + ], + }); + + const article = await screen.findByTestId('message-m-1'); + fireEvent.doubleClick(within(article).getByRole('link')); + + expect(screen.queryByText('Ответ Bob')).toBeNull(); + }); + + it('двойной клик по выделенному тексту не ставит ответ', async () => { + await boot({ + messages: [message({ id: 'm-1', content: 'выделенный текст', author_id: 'user-2' })], + }); + + const article = await screen.findByTestId('message-m-1'); + // Пользователь выделил текст — двойной клик принадлежит выделению. + const range = document.createRange(); + range.selectNodeContents(article); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + fireEvent.doubleClick(article); + + expect(screen.queryByText('Ответ Bob')).toBeNull(); + selection?.removeAllRanges(); + }); + + it('двойной клик по кнопке реакции не ставит ответ', async () => { + await boot({ + messages: [message({ id: 'm-1', content: 'оригинал', author_id: 'user-2' })], + }); + + const article = await screen.findByTestId('message-m-1'); + fireEvent.doubleClick(within(article).getByRole('button', { name: 'Поставить реакцию 🔥' })); + + expect(screen.queryByText('Ответ Bob')).toBeNull(); + }); }); describe('чат комнаты: права и действия', () => { diff --git a/web/tests/helpers.tsx b/web/tests/helpers.tsx index 15b3331..5de4c13 100644 --- a/web/tests/helpers.tsx +++ b/web/tests/helpers.tsx @@ -10,6 +10,8 @@ import { setQueryClient } from '@/lib/queryClient'; import { useGatewayStore } from '@/stores/gateway'; import { useMessagesStore } from '@/stores/messages'; import { useSessionStore } from '@/stores/session'; +import { useUiStore } from '@/stores/ui'; +import { useVoiceStore } from '@/stores/voice'; import type { User } from '@/api/types'; /** Ответ-заглушка с JSON-телом (как у настоящего API). */ @@ -117,6 +119,13 @@ export function findRequest( export function resetStores(): void { useSessionStore.getState().reset(); useMessagesStore.getState().reset(); + useVoiceStore.getState().reset(); + // Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто. + useUiStore.setState({ + settingsOpen: false, + settingsSection: 'profile', + settingsProfileTab: 'main', + }); useGatewayStore.setState({ status: 'idle', heartbeatIntervalMs: null, diff --git a/web/tests/login.test.tsx b/web/tests/login.test.tsx index 06064e7..a866a21 100644 --- a/web/tests/login.test.tsx +++ b/web/tests/login.test.tsx @@ -25,14 +25,44 @@ const instanceOpen = { guild_count: 1, }; +const totpLabel = 'Код 2FA или резервный код'; + +/** Ответы для входа: логин и оболочка приложения. */ +function loginRoutes(extra: Parameters[0] = []) { + const user = makeUser(); + return installFetch([ + { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + { match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + ...extra, + ]); +} + +function loginBodies(fetchMock: ReturnType) { + return recordedRequests(fetchMock).filter( + (request) => request.url.includes('/auth/login') && request.method === 'POST', + ); +} + describe('страница входа', () => { - it('успешный вход переводит в /app', async () => { - const user = makeUser(); - const fetchMock = installFetch([ - { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, - { match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) }, - { match: '/api/v1/users/@me', response: () => json({ user }) }, - { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + it('поле кода 2FA показано сразу и принимает резервные коды', async () => { + installFetch([{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }]); + + renderApp('/login'); + + const field = await screen.findByLabelText(totpLabel); + expect(field).toBeVisible(); + expect(field).toHaveAttribute('autocomplete', 'one-time-code'); + expect(field).toHaveAttribute('inputmode', 'text'); + // Резервный код вида `a8eh-pshp-t8st` должен влезать целиком. + expect(Number(field.getAttribute('maxlength'))).toBeGreaterThanOrEqual(20); + expect(field).not.toHaveAttribute('pattern'); + expect(screen.getByText(/резервный код вида xxxx-xxxx-xxxx/)).toBeVisible(); + }); + + it('успешный вход переводит в /app и уходит без totp_code при пустом поле', async () => { + const fetchMock = loginRoutes([ { match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) }, ]); @@ -56,7 +86,59 @@ describe('страница входа', () => { }); }); - it('при auth.2fa_required показывает поле кода и повторяет вход с totp_code', async () => { + it('код 2FA уходит вместе с логином в первом же запросе', async () => { + const fetchMock = loginRoutes([ + { match: '/api/v1/guilds/g-main/join', method: 'POST', response: () => json({ ok: true }) }, + ]); + + renderApp('/login'); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com'); + await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1'); + await visitor.type(screen.getByLabelText(totpLabel), '123456'); + await visitor.click(screen.getByRole('button', { name: 'Войти' })); + + await waitFor(() => { + expect(loginBodies(fetchMock)).toHaveLength(1); + }); + expect(loginBodies(fetchMock)[0]?.body).toEqual({ + email: 'alice@example.com', + password: 'super-secret-1', + totp_code: '123456', + }); + }); + + it('резервный код из 14 символов уходит на сервер целиком', async () => { + const fetchMock = loginRoutes(); + + const { router } = renderApp('/login'); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Почта'), 'alice@example.com'); + await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1'); + await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st'); + + // Поле не обрезает значение: 14 символов с дефисами видны целиком. + expect(screen.getByLabelText(totpLabel)).toHaveValue('a8eh-pshp-t8st'); + + await visitor.click(screen.getByRole('button', { name: 'Войти' })); + + await waitFor(() => { + expect(loginBodies(fetchMock)[0]?.body).toEqual({ + email: 'alice@example.com', + password: 'super-secret-1', + totp_code: 'a8eh-pshp-t8st', + }); + }); + // Вход не требует предварительной попытки без кода. + expect(loginBodies(fetchMock)).toHaveLength(1); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/empty'); + }); + }); + + it('при auth.2fa_required подсказывает про код и сохраняет логин с паролем', async () => { const user = makeUser(); let loginAttempts = 0; const fetchMock = installFetch([ @@ -80,24 +162,23 @@ describe('страница входа', () => { await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1'); await visitor.click(screen.getByRole('button', { name: 'Войти' })); - const totpField = await screen.findByLabelText('Код 2FA'); - expect(totpField).toBeVisible(); + // Подсказка понятная, а введённые логин и пароль остались на месте. + expect(await screen.findByText(/Введите код 2FA или резервный код/)).toBeVisible(); + expect(screen.getByLabelText('Почта')).toHaveValue('alice@example.com'); + expect(screen.getByLabelText('Пароль')).toHaveValue('super-secret-1'); - await visitor.type(totpField, '123456'); + // Код, введённый после подсказки, не теряется при повторной отправке. + await visitor.type(screen.getByLabelText(totpLabel), 'a8eh-pshp-t8st'); await visitor.click(screen.getByRole('button', { name: 'Войти' })); await waitFor(() => { expect(router.state.location.pathname).toBe('/app/empty'); }); - - const logins = recordedRequests(fetchMock).filter( - (request) => request.url.includes('/auth/login') && request.method === 'POST', - ); - expect(logins).toHaveLength(2); - expect(logins[1]?.body).toEqual({ + expect(loginBodies(fetchMock)).toHaveLength(2); + expect(loginBodies(fetchMock)[1]?.body).toEqual({ email: 'alice@example.com', password: 'super-secret-1', - totp_code: '123456', + totp_code: 'a8eh-pshp-t8st', }); }); }); diff --git a/web/tests/markdown.test.tsx b/web/tests/markdown.test.tsx new file mode 100644 index 0000000..57d12f0 --- /dev/null +++ b/web/tests/markdown.test.tsx @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { MessageContent } from '@/components/chat/MessageContent'; +import { useSessionStore } from '@/stores/session'; +import type { SessionGuild } from '@/stores/session'; + +afterEach(() => { + useSessionStore.getState().reset(); +}); + +/** Разметка сообщения: React-узлы без dangerouslySetInnerHTML. */ +function markup(content: string) { + return render(); +} + +describe('разметка: выделение и жирный курсив', () => { + it('==текст== превращается в mark', () => { + const { container } = markup('важно: ==выделение== да'); + + const mark = container.querySelector('mark'); + expect(mark).toHaveTextContent('выделение'); + expect(container.textContent).toBe('важно: выделение да'); + }); + + it('__жирный__ работает как **жирный**', () => { + const { container } = markup('__жирный__ и **тоже**'); + + const strong = container.querySelectorAll('strong'); + expect(strong).toHaveLength(2); + expect(strong[0]).toHaveTextContent('жирный'); + expect(strong[1]).toHaveTextContent('тоже'); + expect(container.textContent).toBe('жирный и тоже'); + }); + + it('***оба*** — жирный курсив без лишних звёздочек', () => { + const { container } = markup('***оба***'); + + const strong = container.querySelector('strong'); + expect(strong?.querySelector('em')).toHaveTextContent('оба'); + expect(container.textContent).toBe('оба'); + }); + + it('_курсив_ работает, но подчёркивания внутри слова — нет', () => { + const { container } = markup('_курсив_ и foo_bar_baz'); + + expect(container.querySelector('em')).toHaveTextContent('курсив'); + expect(container.textContent).toBe('курсив и foo_bar_baz'); + }); + + it('внутри кода маркеры не разбираются', () => { + const { container } = markup('`==не mark==` и блок:\n```\n**не жирный**\n```'); + + expect(container.querySelector('mark')).toBeNull(); + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('code')).toHaveTextContent('==не mark=='); + expect(container.querySelector('pre')?.textContent).toContain('**не жирный**'); + }); +}); + +describe('разметка: незакрытые маркеры и безопасность', () => { + it('незакрытые маркеры остаются обычным текстом', () => { + const content = '==фыв **жирный *курсив ~~зач _подч ||спойлер `код'; + const { container } = markup(content); + + expect(container.textContent).toBe(content); + expect(container.querySelector('mark')).toBeNull(); + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('em')).toBeNull(); + expect(container.querySelector('s')).toBeNull(); + expect(container.querySelector('button')).toBeNull(); + }); + + it('ссылка с опасным протоколом остаётся текстом', () => { + const { container } = markup('javascript:alert(1) и data:text/html,x'); + + expect(container.querySelector('a')).toBeNull(); + expect(container.textContent).toContain('javascript:alert(1)'); + expect(container.textContent).toContain('data:text/html,x'); + }); + + it('HTML из сообщения не выполняется', () => { + const { container } = markup(' '); + + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('script')).toBeNull(); + expect(container.textContent).toContain(''); + }); + + it('автоссылки http(s) открываются в новой вкладке', () => { + const { container } = markup('смотри https://example.com/a.'); + + const link = container.querySelector('a'); + expect(link).toHaveAttribute('href', 'https://example.com/a'); + expect(link).toHaveAttribute('rel', 'noreferrer noopener'); + expect(link).toHaveAttribute('target', '_blank'); + }); +}); + +describe('разметка: спойлер', () => { + it('скрывает текст до клика и раскрывается', async () => { + const visitor = userEvent.setup(); + const { container } = markup('секрет: ||тайна||'); + + const spoiler = container.querySelector('button'); + expect(spoiler).toHaveTextContent('тайна'); + expect(screen.getByRole('button', { name: 'Показать спойлер' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + + await visitor.click(screen.getByRole('button', { name: 'Показать спойлер' })); + + expect(screen.getByRole('button', { name: 'Скрыть спойлер' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(container.textContent).toBe('секрет: тайна'); + }); + + it('незакрытый спойлер остаётся текстом', () => { + const { container } = markup('||тайна'); + + expect(container.querySelector('button')).toBeNull(); + expect(container.textContent).toBe('||тайна'); + }); +}); + +describe('разметка: упоминания каналов', () => { + const guild: SessionGuild = { + id: 'g-1', + name: 'Сервер', + owner_id: 'user-1', + is_main: false, + member_count: 1, + my_role_ids: [], + my_permissions: [], + channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }], + }; + + it('подсвечивает канал из снапшота и не трогает неизвестный', () => { + useSessionStore.setState({ guilds: [guild] }); + + const { container } = markup('см. #общий и #неттакого'); + + const spans = Array.from(container.querySelectorAll('span')); + const known = spans.filter((span) => span.textContent === '#общий'); + expect(known).toHaveLength(1); + expect(known[0]).toHaveClass('bg-accent/15'); + expect(spans.some((span) => span.textContent === '#неттакого')).toBe(false); + expect(container.textContent).toBe('см. #общий и #неттакого'); + }); + + it('без снапшота #канал остаётся обычным текстом', () => { + const { container } = markup('см. #общий'); + + expect(container.querySelectorAll('span')).toHaveLength(0); + expect(container.textContent).toBe('см. #общий'); + }); +}); diff --git a/web/tests/servers.test.tsx b/web/tests/servers.test.tsx index 9cf8df4..9be0a35 100644 --- a/web/tests/servers.test.tsx +++ b/web/tests/servers.test.tsx @@ -91,6 +91,9 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] { return [ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + { match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) }, + { match: '/api/v1/guilds/g-2/channels', response: () => json({ channels: [] }) }, { match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) }, { match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) }, { match: '/api/v1/guilds/g-1/members', response: () => json({ members }) }, @@ -107,61 +110,70 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] { ]; } -/** Первая кнопка «Сохранить» на странице (секция «Общее»). */ -function saveButton(index = 0): HTMLElement { - const buttons = screen.getAllByRole('button', { name: 'Сохранить' }); - const button = buttons[index]; - if (button === undefined) { - throw new Error('кнопка сохранения не найдена'); +/** Кнопка «Сохранить» внутри формы конкретного поля. */ +function saveNear(label: string): HTMLElement { + const field = screen.getByLabelText(label); + const form = field.closest('form'); + if (form === null) { + throw new Error(`форма для «${label}» не найдена`); } - return button; + return within(form).getByRole('button', { name: 'Сохранить' }); +} + +/** + * Открывает настройки сервера: приложение → мини-профиль → «Настройки серверов». + * Так же их открывает и обычный участник без прав администратора. + */ +async function openServerSettings(path = '/app/empty', guildName?: string) { + const view = renderApp(path); + const trigger = await screen.findByLabelText('Открыть меню пользователя'); + await userEvent.click(trigger); + const menu = await screen.findByRole('menu', { name: 'Меню пользователя' }); + await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })); + await screen.findByRole('dialog', { name: 'Настройки' }); + + if (guildName !== undefined) { + const picker = await openGuildPicker(); + await userEvent.click(within(picker).getByRole('menuitemradio', { name: guildName })); + } + return view; +} + +/** Раскрывает выпадающий список серверов во вкладке «Профиль → Сервер». */ +async function openGuildPicker(): Promise { + await userEvent.click(await screen.findByLabelText('Выбрать сервер')); + return screen.findByRole('menu', { name: 'Мои серверы' }); } describe('настройки серверов', () => { - it('показывает список серверов с иконкой и именем и выбирает первый', async () => { + it('показывает выпадающий список серверов с иконкой и именем и выбирает первый', async () => { installFetch(serversRoutes()); - const { router } = renderApp('/settings/servers'); + await openServerSettings(); - await waitFor(() => { - expect(router.state.location.pathname).toBe('/settings/servers/g-1'); - }); + // Первый сервер выбран по умолчанию. + expect(await screen.findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа'); + expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа'); - // Первый сервер подсвечен. Список запрашиваем заново: после выбора - // страница перерисовывается, и прежний узел может быть уже отсоединён. - await waitFor(() => { - const picker = screen.getByTestId('guild-picker'); - expect( - within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }), - ).toHaveAttribute('aria-current', 'page'); - }); - - const picker = screen.getByTestId('guild-picker'); - const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }); + const picker = await openGuildPicker(); + const first = within(picker).getByRole('menuitemradio', { name: 'Альфа' }); + expect(first).toHaveAttribute('aria-checked', 'true'); // Иконка сервера отдаётся файловым сервисом по icon_file_id. expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1'); - expect( - within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }), - ).toBeVisible(); - - expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа'); + expect(within(picker).getByRole('menuitemradio', { name: 'Бета' })).toBeVisible(); }); it('переключение сервера меняет отображаемые настройки и права', async () => { installFetch(serversRoutes()); - const { router } = renderApp('/settings/servers/g-1'); + await openServerSettings(); expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа'); // Владелец может редактировать. expect(screen.getByLabelText('Название сервера')).toBeEnabled(); - await userEvent.click( - await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }), - ); + const picker = await openGuildPicker(); + await userEvent.click(within(picker).getByRole('menuitemradio', { name: 'Бета' })); - await waitFor(() => { - expect(router.state.location.pathname).toBe('/settings/servers/g-2'); - }); await waitFor(() => { expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета'); }); @@ -181,7 +193,7 @@ describe('настройки серверов', () => { ]), ); - renderApp('/settings/servers/g-1'); + await openServerSettings(); const name = await screen.findByLabelText('Название сервера'); await userEvent.clear(name); @@ -189,7 +201,7 @@ describe('настройки серверов', () => { const description = screen.getByLabelText('Описание'); await userEvent.clear(description); await userEvent.type(description, 'Новое'); - await userEvent.click(saveButton()); + await userEvent.click(saveNear('Название сервера')); expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible(); expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({ @@ -209,7 +221,7 @@ describe('настройки серверов', () => { ]), ); - renderApp('/settings/servers/g-1'); + await openServerSettings(); const nickname = await screen.findByLabelText('Никнейм'); await waitFor(() => { @@ -218,8 +230,7 @@ describe('настройки серверов', () => { await userEvent.clear(nickname); await userEvent.type(nickname, 'Алиса'); - // Вторая кнопка «Сохранить» — секция «Мой профиль на сервере». - await userEvent.click(saveButton(1)); + await userEvent.click(saveNear('Никнейм')); expect(await screen.findByText('Никнейм сохранён.')).toBeVisible(); const patch = recordedRequests(fetchMock).find( @@ -228,10 +239,47 @@ describe('настройки серверов', () => { expect(patch?.body).toEqual({ nickname: 'Алиса' }); }); + it('применяет введённый ник ко всем серверам по очереди', async () => { + const fetchMock = installFetch( + serversRoutes([ + { + match: '/api/v1/guilds/g-1/members/user-1', + method: 'PATCH', + response: () => json({ ok: true }), + }, + { + match: '/api/v1/guilds/g-2/members/user-1', + method: 'PATCH', + response: () => json({ ok: true }), + }, + ]), + ); + + await openServerSettings(); + + const nickname = await screen.findByLabelText('Никнейм'); + await userEvent.clear(nickname); + await userEvent.type(nickname, 'Алиса'); + await userEvent.click(screen.getByRole('button', { name: 'Применить ко всем серверам' })); + + expect(await screen.findByText('Никнейм применён на всех серверах (2).')).toBeVisible(); + + const patches = recordedRequests(fetchMock).filter( + (request) => request.url.includes('/members/user-1') && request.method === 'PATCH', + ); + expect(patches.map((request) => request.url.split('/api/v1')[1])).toEqual([ + '/guilds/g-1/members/user-1', + '/guilds/g-2/members/user-1', + ]); + expect( + patches.every((request) => JSON.stringify(request.body) === '{"nickname":"Алиса"}'), + ).toBe(true); + }); + it('показывает участников и роли сервера', async () => { installFetch(serversRoutes()); - renderApp('/settings/servers/g-1'); + await openServerSettings(); const list = await screen.findByTestId('guild-members'); expect(within(list).getByText('Аля')).toBeVisible(); @@ -246,7 +294,7 @@ describe('настройки серверов', () => { it('владелец не может покинуть сервер, но может его удалить', async () => { installFetch(serversRoutes()); - renderApp('/settings/servers/g-1'); + await openServerSettings(); expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible(); expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull(); @@ -256,7 +304,7 @@ describe('настройки серверов', () => { it('участник без прав не видит удаление сервера', async () => { installFetch(serversRoutes()); - renderApp('/settings/servers/g-2'); + await openServerSettings('/app/empty', 'Бета'); expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull(); @@ -265,15 +313,32 @@ describe('настройки серверов', () => { it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => { installFetch(serversRoutes()); - renderApp('/settings/servers/g-2'); + await openServerSettings('/app/empty', 'Бета'); await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' })); const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' }); await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' })); await waitFor(() => { - expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull(); }); + // Окно настроек при этом остаётся открытым. + expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible(); + }); + + it('Escape закрывает только верхнее окно подтверждения', async () => { + installFetch(serversRoutes()); + + await openServerSettings('/app/empty', 'Бета'); + await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' })); + await screen.findByRole('dialog', { name: 'Покинуть сервер?' }); + + await userEvent.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull(); + }); + expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible(); }); it('покинуть сервер можно после подтверждения в модальном окне', async () => { @@ -283,7 +348,7 @@ describe('настройки серверов', () => { ]), ); - const { router } = renderApp('/settings/servers/g-2'); + const { router } = await openServerSettings('/app/empty', 'Бета'); await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' })); const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' }); @@ -303,7 +368,7 @@ describe('настройки серверов', () => { ]), ); - const { router } = renderApp('/settings/servers/g-1'); + const { router } = await openServerSettings(); await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' })); const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' }); @@ -319,11 +384,13 @@ describe('настройки серверов', () => { installFetch([ { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, ]); - renderApp('/settings/servers'); + await openServerSettings(); - expect(await screen.findByText('Пока нет ни одного сервера')).toBeVisible(); - expect(screen.getByText('У вас пока нет серверов.')).toBeVisible(); + const content = screen.getByTestId('settings-content'); + expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible(); + expect(within(content).getByText('У вас пока нет серверов.')).toBeVisible(); }); }); diff --git a/web/tests/settings.test.tsx b/web/tests/settings.test.tsx index b03e82f..bf0c284 100644 --- a/web/tests/settings.test.tsx +++ b/web/tests/settings.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { @@ -10,6 +10,8 @@ import { makeUser, recordedRequests, renderApp, + requestUrl, + type FetchRoute, } from './helpers'; const user = makeUser(); @@ -33,6 +35,321 @@ const sessions = [ }, ]; +/** Ответы оболочки приложения: профиль, серверы, инстанс. */ +function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] { + return [ + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user: current }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ...extra, + ]; +} + +/** Открывает окно настроек шестерёнкой в панели пользователя. */ +async function openSettings(): Promise { + const gear = await screen.findByLabelText('Настройки пользователя'); + await userEvent.click(gear); + return screen.findByRole('dialog', { name: 'Настройки' }); +} + +/** Переходит к пункту настроек по его названию в левом меню. */ +async function goToSection(dialog: HTMLElement, name: string): Promise { + await userEvent.click(within(dialog).getByRole('button', { name })); +} + +/** Тело multipart-запроса аватара (или null, если запроса не было). */ +function avatarForm(fetchMock: ReturnType, method: string): FormData | null { + const call = fetchMock.mock.calls.find( + ([input, init]) => + requestUrl(input).includes('/users/@me/avatar') && + (init?.method ?? 'GET').toUpperCase() === method, + ); + const body = call?.[1]?.body; + return body instanceof FormData ? body : null; +} + +describe('настройки: окно и навигация', () => { + it('открывается шестерёнкой, показывает пункты и закрывается по Escape, крестику и подложке', async () => { + installFetch(appRoutes()); + + renderApp('/app/empty'); + const dialog = await openSettings(); + + for (const item of [ + 'Профиль', + 'Безопасность', + 'Внешний вид', + 'Уведомления', + 'Аудио-видео', + 'Язык и регион', + ]) { + expect(within(dialog).getByRole('button', { name: item })).toBeVisible(); + } + expect(within(dialog).getByRole('button', { name: 'Профиль' })).toHaveAttribute( + 'aria-current', + 'true', + ); + + // Escape. + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull(); + }); + + // Крестик. + await userEvent.click(await screen.findByLabelText('Настройки пользователя')); + const reopened = await screen.findByRole('dialog', { name: 'Настройки' }); + await userEvent.click(within(reopened).getByRole('button', { name: 'Закрыть' })); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull(); + }); + + // Клик по подложке. + const gear = await screen.findByLabelText('Настройки пользователя'); + await userEvent.click(gear); + await screen.findByRole('dialog', { name: 'Настройки' }); + fireEvent.mouseDown(screen.getByTestId('modal-backdrop')); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Настройки' })).toBeNull(); + }); + }); + + it('переключает вкладки «Основной» и «Сервер» внутри профиля', async () => { + installFetch(appRoutes()); + + renderApp('/app/empty'); + const dialog = await openSettings(); + + const mainTab = within(dialog).getByRole('tab', { name: 'Основной' }); + const serverTab = within(dialog).getByRole('tab', { name: 'Сервер' }); + expect(mainTab).toHaveAttribute('aria-selected', 'true'); + expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible(); + + await userEvent.click(serverTab); + expect(serverTab).toHaveAttribute('aria-selected', 'true'); + expect( + within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'), + ).toBeVisible(); + + await userEvent.click(within(dialog).getByRole('tab', { name: 'Основной' })); + expect(mainTab).toHaveAttribute('aria-selected', 'true'); + expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible(); + }); + + it('пункт «Инстанс» видят только администраторы', async () => { + installFetch(appRoutes()); + + renderApp('/app/empty'); + const dialog = await openSettings(); + expect(within(dialog).queryByRole('button', { name: 'Инстанс' })).toBeNull(); + }); + + it('«Уведомления» пока заглушка', async () => { + installFetch(appRoutes()); + + renderApp('/app/empty'); + const dialog = await openSettings(); + await goToSection(dialog, 'Уведомления'); + + expect(within(dialog).getByText('Настройки уведомлений появятся позже.')).toBeVisible(); + }); + + it('«Аудио-видео» без mediaDevices честно сообщает о недоступности', async () => { + installFetch(appRoutes()); + + renderApp('/app/empty'); + const dialog = await openSettings(); + await goToSection(dialog, 'Аудио-видео'); + + expect(within(dialog).getAllByText('Устройства недоступны').length).toBeGreaterThan(0); + expect(within(dialog).getByLabelText('Громкость микрофона')).toBeVisible(); + expect(within(dialog).getByLabelText('Громкость звука')).toBeVisible(); + }); + + it('«Язык и регион» сохраняет часовой пояс через PATCH /users/@me', async () => { + const fetchMock = installFetch( + appRoutes(user, [ + { + match: '/api/v1/users/@me', + method: 'PATCH', + response: () => json({ user: { ...user, timezone: 'Europe/Berlin' } }), + }, + ]), + ); + + renderApp('/app/empty'); + const dialog = await openSettings(); + await goToSection(dialog, 'Язык и регион'); + + await userEvent.selectOptions(within(dialog).getByLabelText('Часовой пояс'), 'Europe/Berlin'); + + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({ + timezone: 'Europe/Berlin', + }); + }); + expect(await within(dialog).findByText('Настройки региона сохранены.')).toBeVisible(); + }); +}); + +describe('настройки: старые адреса', () => { + it.each([ + ['/settings', 'Профиль', 'Основной'], + ['/settings/account/profile', 'Профиль', 'Основной'], + ['/settings/profile', 'Профиль', 'Основной'], + ])('%s открывает окно на «%s → %s»', async (from, section, tab) => { + installFetch(appRoutes()); + + const { router } = renderApp(from); + + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + await waitFor(() => { + expect(router.state.location.pathname).toContain('/app'); + }); + expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute( + 'aria-current', + 'true', + ); + expect(within(dialog).getByRole('tab', { name: tab })).toHaveAttribute('aria-selected', 'true'); + }); + + it.each([ + ['/settings/security', 'Безопасность'], + ['/settings/account/security', 'Безопасность'], + ['/settings/appearance', 'Внешний вид'], + ['/settings/account/appearance', 'Внешний вид'], + ])('%s открывает окно на пункте «%s»', async (from, section) => { + installFetch(appRoutes()); + + renderApp(from); + + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + expect(within(dialog).getByRole('button', { name: section })).toHaveAttribute( + 'aria-current', + 'true', + ); + }); + + it('/settings/servers открывает вкладку «Сервер»', async () => { + installFetch(appRoutes()); + + renderApp('/settings/servers'); + + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); +}); + +describe('настройки: аватар', () => { + it('загружает файл через POST /users/@me/avatar и удаляет его', async () => { + const fetchMock = installFetch( + appRoutes(user, [ + { + match: '/api/v1/users/@me/avatar', + method: 'POST', + response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }), + }, + { + match: '/api/v1/users/@me/avatar', + method: 'DELETE', + response: () => json({ user: { ...user } }), + }, + ]), + ); + + renderApp('/app/empty'); + await openSettings(); + + const input = await screen.findByLabelText('Выбрать файл аватара'); + const file = new File(['картинка'], 'avatar.png', { type: 'image/png' }); + await userEvent.upload(input, file); + + await waitFor(() => { + expect(avatarForm(fetchMock, 'POST')).not.toBeNull(); + }); + // Multipart с полем `file`, как ждёт сервер. + const form = avatarForm(fetchMock, 'POST'); + expect((form?.get('file') as File | null)?.name).toBe('avatar.png'); + + // Ответ сервера обновил профиль: аватар отдаётся файловым сервисом. + await waitFor(() => { + expect(document.querySelector('img[src="/files/file-9"]')).not.toBeNull(); + }); + + await userEvent.click(screen.getByRole('button', { name: 'Удалить аватар' })); + await waitFor(() => { + expect( + recordedRequests(fetchMock).some( + (request) => request.url.includes('/users/@me/avatar') && request.method === 'DELETE', + ), + ).toBe(true); + }); + }); + + it('слишком большой файл не отправляется, показывается ошибка', async () => { + const fetchMock = installFetch(appRoutes()); + + renderApp('/app/empty'); + await openSettings(); + + const input = await screen.findByLabelText('Выбрать файл аватара'); + const big = new File(['x'], 'big.png', { type: 'image/png' }); + Object.defineProperty(big, 'size', { value: 9 * 1024 * 1024 }); + await userEvent.upload(input, big); + + expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше'); + expect(avatarForm(fetchMock, 'POST')).toBeNull(); + }); + + it('ошибка сервера file.too_large показывается понятным текстом', async () => { + installFetch( + appRoutes(user, [ + { + match: '/api/v1/users/@me/avatar', + method: 'POST', + response: () => apiError('file.too_large', 413), + }, + ]), + ); + + renderApp('/app/empty'); + await openSettings(); + + const input = await screen.findByLabelText('Выбрать файл аватара'); + await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера'); + }); + + it('часовой пояс уходит в PATCH вместе с профилем', async () => { + const fetchMock = installFetch( + appRoutes(user, [ + { + match: '/api/v1/users/@me', + method: 'PATCH', + response: () => json({ user: { ...user, timezone: 'Asia/Tokyo' } }), + }, + ]), + ); + + renderApp('/app/empty'); + await openSettings(); + + await userEvent.selectOptions(screen.getByLabelText('Часовой пояс'), 'Asia/Tokyo'); + await userEvent.click(screen.getByRole('button', { name: 'Сохранить' })); + + await waitFor(() => { + expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toMatchObject({ + timezone: 'Asia/Tokyo', + }); + }); + expect(await screen.findByText('Профиль сохранён.')).toBeVisible(); + }); +}); + describe('настройки: безопасность', () => { it('включение 2FA показывает секрет и коды восстановления', async () => { const fetchMock = installFetch([ @@ -105,7 +422,7 @@ describe('настройки: безопасность', () => { it('смена пароля требует step-up и повторяется после подтверждения', async () => { let passwordAttempts = 0; - const fetchMock = installFetch([ + installFetch([ { match: '/api/v1/users/@me', response: () => json({ user }) }, { match: '/api/v1/auth/sessions', response: () => json({ sessions }) }, { @@ -135,11 +452,6 @@ describe('настройки: безопасность', () => { await visitor.click(screen.getByRole('button', { name: 'Подтвердить' })); expect(await screen.findByText('Пароль изменён.')).toBeVisible(); - expect( - recordedRequests(fetchMock).filter( - (request) => request.url.includes('/users/@me/password') && request.method === 'POST', - ), - ).toHaveLength(2); }); it('профиль сохраняется через PATCH /users/@me', async () => { @@ -165,58 +477,6 @@ describe('настройки: безопасность', () => { expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' }); }); - it('вкладка «Инстанс» скрыта от не-администратора', async () => { - installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); - - renderApp('/settings/account/profile'); - - expect(await screen.findByRole('link', { name: 'Профиль' })).toBeVisible(); - expect(screen.queryByRole('link', { name: 'Инстанс' })).toBeNull(); - }); - - it('вверху настроек — две соседние кнопки-переключателя', async () => { - installFetch([ - { match: '/api/v1/users/@me', response: () => json({ user }) }, - { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, - ]); - - const { router } = renderApp('/settings/account/profile'); - const account = await screen.findByRole('link', { name: 'Настройки сервера' }); - const servers = screen.getByRole('link', { name: 'Мои серверы' }); - - expect(account).toHaveAttribute('aria-current', 'page'); - expect(servers).not.toHaveAttribute('aria-current'); - expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-0'); - - await userEvent.click(servers); - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/settings/servers'); - }); - expect(screen.getByRole('link', { name: 'Мои серверы' })).toHaveAttribute( - 'aria-current', - 'page', - ); - // Подложка переключателя уезжает ко второй кнопке без перезагрузки. - expect(screen.getByTestId('settings-switcher-backdrop')).toHaveClass('translate-x-full'); - }); - - it.each([ - ['/settings', '/settings/account/profile'], - ['/settings/profile', '/settings/account/profile'], - ['/settings/security', '/settings/account/security'], - ['/settings/appearance', '/settings/account/appearance'], - ['/settings/instance', '/settings/account/instance'], - ])('старый путь %s редиректит на %s', async (from, to) => { - installFetch([{ match: '/api/v1/users/@me', response: () => json({ user }) }]); - - const { router } = renderApp(from); - - await waitFor(() => { - expect(router.state.location.pathname).toBe(to); - }); - }); - it('администратор видит данные инстанса', async () => { const admin = makeUser({ is_instance_admin: true }); installFetch([ diff --git a/web/tests/shell.test.tsx b/web/tests/shell.test.tsx index 072c8d3..40ac23e 100644 --- a/web/tests/shell.test.tsx +++ b/web/tests/shell.test.tsx @@ -115,13 +115,19 @@ describe('основной экран', () => { expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible(); expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible(); - // Панель пользователя. + // Панель пользователя: имя, статус, кнопки устройств и шестерёнка настроек. const panel = screen.getByLabelText('Панель пользователя'); expect(within(panel).getByText('Alice')).toBeVisible(); expect(within(panel).getByLabelText('Настройки пользователя')).toBeVisible(); - // Выход переехал во всплывающее меню (проверяется в tests/userMenu.test.tsx). + expect(within(panel).getByLabelText('Выключить микрофон')).toBeVisible(); + expect(within(panel).getByLabelText('Включить камеру')).toBeVisible(); + expect(within(panel).getByLabelText('Выключить звук')).toBeVisible(); + // Выход переехал в мини-профиль (проверяется в tests/userMenu.test.tsx). expect(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible(); + // «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне». + expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull(); + // Фаза 2: вместо заглушки в области комнаты открывается чат. expect(await screen.findByTestId('composer')).toBeVisible(); expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий'); @@ -148,7 +154,7 @@ describe('основной экран', () => { }); }); - it('шестерёнка настроек сервера видна владельцу и ведёт в настройки', async () => { + it('шестерёнка настроек сервера видна владельцу и открывает окно настроек', async () => { const { default: userEvent } = await import('@testing-library/user-event'); const visitor = userEvent.setup(); installFetch([ @@ -184,9 +190,14 @@ describe('основной экран', () => { const gear = await screen.findByLabelText('Настройки сервера'); await visitor.click(gear); - await waitFor(() => { - expect(router.state.location.pathname).toBe('/settings/servers/g-1'); - }); + // Настройки — модальное окно: из комнаты никуда не уходим. + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + expect(router.state.location.pathname).toBe('/app/g-1/c-general'); + expect(await within(dialog).findByRole('tab', { name: 'Сервер' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер'); }); it('обычный участник не видит шестерёнку настроек сервера', async () => { diff --git a/web/tests/userMenu.test.tsx b/web/tests/userMenu.test.tsx index 72909a7..bdc0297 100644 --- a/web/tests/userMenu.test.tsx +++ b/web/tests/userMenu.test.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers'; import type { FetchRoute } from './helpers'; -const user = makeUser(); +const user = makeUser({ bio: 'Пишу код и тесты' }); /** Базовые ответы для оболочки приложения без серверов. */ function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] { @@ -17,7 +17,7 @@ function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] { ]; } -/** Открывает нижне-левое меню пользователя кликом по аватару/имени. */ +/** Открывает мини-профиль кликом по аватару/имени. */ async function openMenu() { const trigger = await screen.findByLabelText('Открыть меню пользователя'); await userEvent.click(trigger); @@ -25,55 +25,82 @@ async function openMenu() { return { trigger, menu }; } +/** Открывает пункт меню и возвращает окно настроек. */ +async function openSettings(item: string) { + const { menu } = await openMenu(); + await userEvent.click(within(menu).getByRole('menuitem', { name: item })); + return screen.findByRole('dialog', { name: 'Настройки' }); +} + describe('меню пользователя', () => { - it('открывается по клику на аватар и содержит все пункты', async () => { + it('мини-профиль открывается по клику на аватар и показывает данные и статусы', async () => { installFetch(baseRoutes()); renderApp('/app/empty'); const { menu } = await openMenu(); - expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible(); - expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible(); + // Аватар, имя, логин и «О себе». + expect(within(menu).getByText('Alice')).toBeVisible(); + expect(within(menu).getByText('@alice')).toBeVisible(); + expect(within(menu).getByText('Пишу код и тесты')).toBeVisible(); + + // Статусы с подписями и цветными точками. expect( within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }), ).toBeVisible(); expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).toBeVisible(); + expect(within(menu).getByRole('menuitemradio', { name: 'Неактивен' })).toBeVisible(); + expect(within(menu).getByRole('menuitemradio', { name: 'Невидимый' })).toBeVisible(); + expect(menu.querySelectorAll('[role="menuitemradio"] .rounded-full')).toHaveLength(4); + + expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible(); + expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible(); expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible(); }); - it('шестерёнка открывает то же меню', async () => { + it('шестерёнка открывает окно настроек', async () => { installFetch(baseRoutes()); renderApp('/app/empty'); await userEvent.click(await screen.findByLabelText('Настройки пользователя')); - expect(await screen.findByRole('menu', { name: 'Меню пользователя' })).toBeVisible(); + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + // Мини-профиль при этом не открывается. + expect(screen.queryByRole('menu', { name: 'Меню пользователя' })).toBeNull(); }); - it('пункт «Настройки аккаунта» ведёт в /settings/account/profile', async () => { + it('пункт «Настройки аккаунта» открывает окно на «Профиль → Основной»', async () => { installFetch(baseRoutes()); const { router } = renderApp('/app/empty'); - const { menu } = await openMenu(); - await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })); + const dialog = await openSettings('Настройки аккаунта'); - await waitFor(() => { - expect(router.state.location.pathname).toBe('/settings/account/profile'); - }); - expect(await screen.findByRole('heading', { name: 'Профиль' })).toBeVisible(); + expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(within(dialog).getByRole('heading', { name: 'Профиль' })).toBeVisible(); + // Из приложения никуда не уходим: настройки — модальное окно. + expect(router.state.location.pathname).toBe('/app/empty'); }); - it('пункт «Настройки серверов» ведёт в /settings/servers', async () => { + it('пункт «Настройки серверов» открывает окно на «Профиль → Сервер»', async () => { installFetch(baseRoutes()); - const { router } = renderApp('/app/empty'); - const { menu } = await openMenu(); - await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })); + renderApp('/app/empty'); + const dialog = await openSettings('Настройки серверов'); - await waitFor(() => { - expect(router.state.location.pathname).toBe('/settings/servers'); - }); - expect(await screen.findByText('У вас пока нет серверов.')).toBeVisible(); + expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect( + within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'), + ).toBeVisible(); }); it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => { @@ -159,15 +186,16 @@ describe('меню пользователя', () => { trigger.focus(); await userEvent.keyboard('{Enter}'); - const first = await screen.findByRole('menuitem', { name: 'Настройки аккаунта' }); + // Первый пункт мини-профиля — выбор статуса. + const first = await screen.findByRole('menuitemradio', { name: 'В сети' }); await waitFor(() => { expect(first).toHaveFocus(); }); await userEvent.keyboard('{ArrowDown}'); - expect(screen.getByRole('menuitem', { name: 'Настройки серверов' })).toHaveFocus(); + expect(screen.getByRole('menuitemradio', { name: 'Неактивен' })).toHaveFocus(); await userEvent.keyboard('{ArrowUp}'); - expect(screen.getByRole('menuitem', { name: 'Настройки аккаунта' })).toHaveFocus(); + expect(screen.getByRole('menuitemradio', { name: 'В сети' })).toHaveFocus(); }); }); diff --git a/web/tests/userPanel.test.tsx b/web/tests/userPanel.test.tsx new file mode 100644 index 0000000..9c1079f --- /dev/null +++ b/web/tests/userPanel.test.tsx @@ -0,0 +1,229 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { installFetch, json, makeUser, renderApp, type FetchRoute } from './helpers'; +import { useVoiceStore, VOICE_STORAGE_KEY } from '@/stores/voice'; + +/** Устройства, которые «видит» браузер в тестах. */ +const devices = [ + { kind: 'audioinput', deviceId: 'mic-1', label: 'Микрофон USB' }, + { kind: 'audioinput', deviceId: 'mic-2', label: '' }, + { kind: 'videoinput', deviceId: 'cam-1', label: 'Веб-камера HD' }, + { kind: 'audiooutput', deviceId: 'out-1', label: 'Наушники' }, +]; + +/** Подменяет `navigator.mediaDevices` (в jsdom его нет). */ +function installMediaDevices(list = devices) { + const mediaDevices = { + enumerateDevices: vi.fn(() => Promise.resolve(list)), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: mediaDevices }); + return mediaDevices; +} + +afterEach(() => { + Reflect.deleteProperty(navigator, 'mediaDevices'); +}); + +function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] { + // Дополнительные маршруты идут первыми: при одинаковом шаблоне побеждает + // первый зарегистрированный, поэтому тест может переопределить профиль. + return [ + ...extra, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + { match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) }, + { match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) }, + ]; +} + +/** Панель пользователя после загрузки оболочки. */ +async function bootPanel(extra: FetchRoute[] = []) { + installFetch(baseRoutes(extra)); + renderApp('/app/empty'); + return screen.findByLabelText('Панель пользователя'); +} + +function storedPrefs(): Record { + const raw = window.localStorage.getItem(VOICE_STORAGE_KEY); + return raw === null ? {} : (JSON.parse(raw) as Record); +} + +describe('панель пользователя: устройства', () => { + it('кнопки микрофона, камеры и наушников переключают состояние и сохраняют его', async () => { + const panel = await bootPanel(); + + const mic = within(panel).getByLabelText('Выключить микрофон'); + expect(mic).toHaveAttribute('aria-pressed', 'true'); + + await userEvent.click(mic); + + // Иконка микрофона перечёркнута, а состояние ушло в localStorage. + const muted = within(panel).getByLabelText('Включить микрофон'); + expect(muted).toHaveAttribute('aria-pressed', 'false'); + expect(storedPrefs()).toMatchObject({ micEnabled: false }); + + const camera = within(panel).getByLabelText('Включить камеру'); + expect(camera).toHaveAttribute('aria-pressed', 'false'); + await userEvent.click(camera); + expect(within(panel).getByLabelText('Выключить камеру')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + + const headphones = within(panel).getByLabelText('Выключить звук'); + await userEvent.click(headphones); + expect(within(panel).getByLabelText('Включить звук')).toHaveAttribute('aria-pressed', 'false'); + expect(storedPrefs()).toMatchObject({ + micEnabled: false, + cameraEnabled: true, + headphonesEnabled: false, + }); + }); + + it('стрелка микрофона открывает список устройств ввода и слайдер громкости', async () => { + installMediaDevices(); + const panel = await bootPanel(); + + await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); + + const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' }); + expect(await within(popover).findByRole('option', { name: 'Микрофон USB' })).toBeVisible(); + // Устройство без подписи показываем как «Устройство 2». + expect(within(popover).getByRole('option', { name: 'Устройство 2' })).toBeVisible(); + expect( + within(popover).getByRole('option', { name: 'Устройство по умолчанию' }), + ).toHaveAttribute('aria-selected', 'true'); + + await userEvent.click(within(popover).getByRole('option', { name: 'Микрофон USB' })); + expect(storedPrefs()).toMatchObject({ micDeviceId: 'mic-1' }); + + const volume = within(popover).getByLabelText('Громкость микрофона'); + fireEvent.change(volume, { target: { value: '42' } }); + expect(storedPrefs()).toMatchObject({ micVolume: 42 }); + }); + + it('стрелка камеры показывает устройства videoinput', async () => { + installMediaDevices(); + const panel = await bootPanel(); + + await userEvent.click(within(panel).getByLabelText('Настройки камеры')); + + const popover = await screen.findByRole('dialog', { name: 'Настройки камеры' }); + expect(await within(popover).findByRole('option', { name: 'Веб-камера HD' })).toBeVisible(); + expect(within(popover).queryByRole('option', { name: 'Микрофон USB' })).toBeNull(); + }); + + it('стрелка наушников показывает устройства вывода и громкость звука', async () => { + installMediaDevices(); + const panel = await bootPanel(); + + await userEvent.click(within(panel).getByLabelText('Настройки звука')); + + const popover = await screen.findByRole('dialog', { name: 'Настройки звука' }); + await userEvent.click(await within(popover).findByRole('option', { name: 'Наушники' })); + expect(storedPrefs()).toMatchObject({ outputDeviceId: 'out-1' }); + + fireEvent.change(within(popover).getByLabelText('Громкость звука'), { + target: { value: '10' }, + }); + expect(storedPrefs()).toMatchObject({ outputVolume: 10 }); + }); + + it('без navigator.mediaDevices показывает «Устройства недоступны»', async () => { + const panel = await bootPanel(); + + await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); + + const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' }); + expect(await within(popover).findByText('Устройства недоступны')).toBeVisible(); + }); + + it('ссылка «Аудио-видео настройки» открывает окно настроек на этом пункте', async () => { + installMediaDevices(); + const panel = await bootPanel(); + + await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); + const popover = await screen.findByRole('dialog', { name: 'Настройки микрофона' }); + await userEvent.click( + await within(popover).findByRole('button', { name: 'Аудио-видео настройки' }), + ); + + const dialog = await screen.findByRole('dialog', { name: 'Настройки' }); + expect(within(dialog).getByRole('button', { name: 'Аудио-видео' })).toHaveAttribute( + 'aria-current', + 'true', + ); + // Поповер устройства закрылся. + expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull(); + }); + + it('поповеры закрываются по Escape и не открываются одновременно', async () => { + installMediaDevices(); + const panel = await bootPanel(); + + await userEvent.click(within(panel).getByLabelText('Настройки микрофона')); + await screen.findByRole('dialog', { name: 'Настройки микрофона' }); + + // Открытие второй панели закрывает первую. + await userEvent.click(within(panel).getByLabelText('Настройки звука')); + await screen.findByRole('dialog', { name: 'Настройки звука' }); + expect(screen.queryByRole('dialog', { name: 'Настройки микрофона' })).toBeNull(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Настройки звука' })).toBeNull(); + }); + }); +}); + +describe('панель пользователя: профиль', () => { + it('показывает имя, статус и цветную точку статуса', async () => { + installFetch( + baseRoutes([ + { + match: '/api/v1/users/@me', + response: () => json({ user: makeUser({ status: 'idle' }) }), + }, + ]), + ); + renderApp('/app/empty'); + + const panel = await screen.findByLabelText('Панель пользователя'); + expect(within(panel).getByText('Alice')).toBeVisible(); + await waitFor(() => { + expect(within(panel).getByText('Неактивен')).toBeVisible(); + }); + expect(within(panel).getByTestId('user-status-dot')).toHaveClass('bg-warning'); + }); + + it('сохраняет предпочтения устройств в localStorage', async () => { + await bootPanel(); + + useVoiceStore.getState().toggleMic(); + useVoiceStore.getState().setOutputVolume(30); + + expect(storedPrefs()).toMatchObject({ + micEnabled: false, + outputVolume: 30, + headphonesEnabled: true, + }); + }); + + it('читает сохранённые предпочтения при загрузке модуля', async () => { + window.localStorage.setItem( + VOICE_STORAGE_KEY, + JSON.stringify({ micEnabled: false, micVolume: 20 }), + ); + + vi.resetModules(); + const fresh = await import('@/stores/voice'); + + expect(fresh.useVoiceStore.getState().micEnabled).toBe(false); + expect(fresh.useVoiceStore.getState().micVolume).toBe(20); + // Неизвестные поля берут значения по умолчанию. + expect(fresh.useVoiceStore.getState().headphonesEnabled).toBe(true); + }); +});