feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+8 -2
View File
@@ -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<string, string>;
}
@@ -87,16 +89,20 @@ export function parsePayload(text: string, contentType: string | null): unknown
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
// 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) {
+2
View File
@@ -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;
+28
View File
@@ -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<User>
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<User | null> {
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<User | null> {
const payload = await request<{ user?: User }>('/users/@me/avatar', { method: 'DELETE' });
return payload.user ?? null;
}
export async function fetchMyGuilds(signal?: AbortSignal): Promise<GuildSummary[]> {
const payload = await request<{ guilds: GuildSummary[] }>(
'/users/@me/guilds',
+4 -2
View File
@@ -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]+\*)|((?<![\p{L}\p{N}_])_[^_\n]+_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
function splitLink(raw: string): { href: string; trailing: string } {
+7 -1
View File
@@ -168,6 +168,11 @@ interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Для `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}`}
>
<span aria-hidden="true" className="w-4 shrink-0 text-center">
{selected ? '✓' : (icon ?? '')}
{selected && !alwaysShowIcon ? '✓' : (icon ?? '')}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</button>
+52 -8
View File
@@ -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<HTMLDivElement | null>(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 = (
<div
data-testid="modal-backdrop"
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 ${
closing ? 'gl-backdrop-out' : 'gl-backdrop-in'
}`}
@@ -102,9 +144,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
role="dialog"
aria-modal="true"
aria-label={title}
className={`w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)] ${
className={`w-full ${sizes[size]} rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 shadow-[var(--shadow-3)] ${
closing ? 'gl-pop-out' : 'gl-pop-in'
}`}
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
@@ -117,9 +159,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
×
</button>
</div>
<div className="mt-4">{children}</div>
<div className={`mt-4 ${bodyClassName}`}>{children}</div>
{footer === undefined ? null : <div className="mt-5 flex justify-end gap-2">{footer}</div>}
</div>
</div>
);
return portal ? createPortal(node, document.body) : node;
}
+112
View File
@@ -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 (
<div>
<p className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">{label}</p>
<p className="px-1 py-1 text-xs text-fg-muted">{t('user.devices.unavailable')}</p>
</div>
);
}
return (
<div>
<p className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">{label}</p>
{devices.length === 0 ? (
<p className="px-1 py-1 text-xs text-fg-muted">{t('user.devices.empty')}</p>
) : (
<ul
role="listbox"
aria-label={label}
className="flex max-h-40 flex-col gap-0.5 overflow-y-auto"
>
<li>
<button
type="button"
role="option"
aria-selected={value === ''}
className={optionClass(value === '')}
onClick={() => onChange('')}
>
{t('user.devices.default')}
</button>
</li>
{devices.map((device, index) => (
<li key={device.deviceId}>
<button
type="button"
role="option"
aria-selected={value === device.deviceId}
className={optionClass(value === device.deviceId)}
onClick={() => onChange(device.deviceId)}
>
{device.label === ''
? t('user.devices.unnamed', { index: index + 1 })
: device.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}
/** Слайдер громкости 0–100: значение уходит в zustand-стор и localStorage. */
export function VolumeSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<label className="flex flex-col gap-1 px-1 pt-2">
<span className="flex items-center justify-between gap-2 text-xs text-fg-muted">
{label}
<span aria-hidden="true">{value}%</span>
</span>
<input
type="range"
min={0}
max={100}
step={1}
value={value}
aria-label={label}
className="w-full accent-[var(--color-accent)]"
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
+98 -9
View File
@@ -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."
}
}
}
+98 -9
View File
@@ -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": "Настройки региона сохранены."
}
}
}
+68
View File
@@ -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;
}
+28 -5
View File
@@ -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) };
}
+9 -31
View File
@@ -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) {
</h1>
<div className="flex items-center gap-1">
{canManageSettings ? (
<Link
to={`/settings/servers/${guildId}`}
<button
type="button"
aria-label={t('channels.settings')}
title={t('channels.settings')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => openSettings({ section: 'profile', tab: 'server', guildId })}
>
⚙
</Link>
</button>
) : null}
{canManage ? (
<button
@@ -228,19 +218,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
</div>
<footer className="border-t border-border/40 px-2 py-2">
<Button
variant="ghost"
className="w-full"
disabled={leave.isPending}
onClick={() => {
if (window.confirm(t('guilds.leaveConfirm', { name: guild?.name ?? '' }))) {
leave.mutate();
}
}}
>
{t('guilds.leave')}
</Button>
{leave.isError ? <ErrorNotice className="mt-2" error={leave.error} /> : null}
<p className="px-1 text-xs text-fg-muted">{t('channels.leaveHint')}</p>
</footer>
<Modal
+292 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState, type ReactNode, type RefObject } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
@@ -8,19 +8,123 @@ import { currentUserQueryKey, updateProfile } from '@/api/users';
import { userStatuses, type UserStatus } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { Popover } from '@/components/ui/Popover';
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useDeviceChoice } from '@/lib/useMediaDevices';
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 { SettingsModal } from '@/pages/settings/SettingsModal';
/** Статусы, которые можно выставить из меню: «не в сети» сервер выводит сам. */
/** Статусы, которые можно выставить из мини-профиля: «не в сети» сервер выводит сам. */
const selectableStatuses = userStatuses.filter((value) => value !== 'offline');
/** Цвет точки статуса — тот же, что в списках участников. */
const statusDotClass: Record<UserStatus, string> = {
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 (
<span aria-hidden="true" className="relative inline-block leading-none">
<span className={off ? 'opacity-60' : ''}>{glyph}</span>
{off ? (
<span className="pointer-events-none absolute left-1/2 top-1/2 h-[1.5px] w-5 -translate-x-1/2 -translate-y-1/2 -rotate-45 rounded-full bg-current" />
) : null}
</span>
);
}
interface PanelControlProps {
glyph: string;
/** Включено ли устройство: от этого зависит вид и подпись кнопки. */
enabled: boolean;
/** Подпись кнопки-переключателя (озвучивает текущее действие). */
toggleLabel: string;
/** Подпись стрелки и поповера. */
menuLabel: string;
onToggle: () => void;
open: boolean;
onOpenMenu: () => void;
onCloseMenu: () => void;
children: ReactNode;
}
/**
* Панель пользователя внизу слева: аватар, имя и статус. Клик по аватару,
* имени или шестерёнке открывает всплывающее вверх меню с настройками,
* сменой статуса и выходом (см. `Menu`).
* Кнопка устройства со стрелкой слева: сама кнопка переключает состояние,
* стрелка открывает поповер с устройствами и громкостью. Это две отдельные
* кнопки, поэтому выпадающий список не перекрывает переключатель.
*/
function PanelControl({
glyph,
enabled,
toggleLabel,
menuLabel,
onToggle,
open,
onOpenMenu,
onCloseMenu,
children,
}: PanelControlProps) {
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
return (
<div className="relative flex items-center">
{/* Узкая стрелка: панель шириной 240px, место здесь на счету. */}
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={menuLabel}
title={menuLabel}
onClick={onOpenMenu}
className="rounded-[var(--radius-sm)] px-0.5 text-[9px] leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
>
▾
</button>
<button
ref={anchorRef}
type="button"
aria-pressed={enabled}
aria-label={toggleLabel}
title={toggleLabel}
onClick={onToggle}
className={`rounded-[var(--radius-sm)] px-1 py-1 text-sm leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
enabled ? 'text-fg' : 'text-danger'
}`}
>
<PanelIcon glyph={glyph} off={!enabled} />
</button>
<Popover
open={open}
label={menuLabel}
anchorRef={anchorRef}
align="right"
className="w-72"
onClose={onCloseMenu}
>
{children}
</Popover>
</div>
);
}
/**
* Панель пользователя внизу сайдбара: одна «подложка» с двумя зонами.
*
* Слева — аватар, имя, статус и мини-профиль (статус, настройки, выход),
* справа — микрофон, камера, наушники (каждое со своим поповером устройств)
* и шестерёнка, открывающая модальное окно настроек.
*/
export function UserPanel() {
const { t } = useTranslation();
@@ -30,12 +134,30 @@ export function UserPanel() {
const describeError = useApiErrorMessage();
const sessionUser = useSessionStore((state) => state.user);
const disconnect = useGatewayStore((state) => state.disconnect);
const openSettings = useUiStore((state) => state.openSettings);
const [menuOpen, setMenuOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const micEnabled = useVoiceStore((state) => state.micEnabled);
const cameraEnabled = useVoiceStore((state) => state.cameraEnabled);
const headphonesEnabled = useVoiceStore((state) => state.headphonesEnabled);
const toggleMic = useVoiceStore((state) => state.toggleMic);
const toggleCamera = useVoiceStore((state) => state.toggleCamera);
const toggleHeadphones = useVoiceStore((state) => state.toggleHeadphones);
const micVolume = useVoiceStore((state) => state.micVolume);
const outputVolume = useVoiceStore((state) => state.outputVolume);
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
const mic = useDeviceChoice('audioinput');
const camera = useDeviceChoice('videoinput');
const output = useDeviceChoice('audiooutput');
const [profileOpen, setProfileOpen] = useState(false);
/** Какое устройство сейчас раскрыто в поповере: одновременно только одно. */
const [openControl, setOpenControl] = useState<'mic' | 'camera' | 'headphones' | null>(null);
const profileRef = useRef<HTMLButtonElement | null>(null);
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
const bio = currentUser.data?.bio ?? '';
const displayName = name === '' ? username : name;
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
const customStatus = sessionUser?.custom_status ?? '';
@@ -61,7 +183,7 @@ export function UserPanel() {
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
setMenuOpen(false);
setProfileOpen(false);
},
});
@@ -74,14 +196,29 @@ export function UserPanel() {
return;
}
void navigator.clipboard?.writeText(currentUser.data.id);
setProfileOpen(false);
}, [currentUser.data]);
const toggleMenu = useCallback(() => setMenuOpen((value) => !value), []);
const closeMenu = useCallback(() => setMenuOpen(false), []);
const toggleProfile = useCallback(() => {
setOpenControl(null);
setProfileOpen((value) => !value);
}, []);
const closeProfile = useCallback(() => setProfileOpen(false), []);
const goTo = (path: string) => (): void => {
setMenuOpen(false);
void navigate(path);
/** Открыть поповер устройства: мини-профиль при этом закрывается. */
const toggleControl = useCallback((control: 'mic' | 'camera' | 'headphones') => {
setProfileOpen(false);
setOpenControl((current) => (current === control ? null : control));
}, []);
const openAudioSettings = (): void => {
setOpenControl(null);
openSettings({ section: 'voice' });
};
const goToSettings = (target: { section?: 'profile'; tab?: 'main' | 'server' }): void => {
setProfileOpen(false);
openSettings(target);
};
const error = changeStatus.error ?? signOut.error;
@@ -90,19 +227,27 @@ export function UserPanel() {
return (
<footer
aria-label={t('user.panelLabel')}
className="relative flex flex-col border-t border-border/40 bg-surface-2/80"
className="relative border-t border-border/40 bg-surface-2/80 p-2"
>
<div className="flex items-center gap-1 px-2 py-2">
{/* Подложка панели: слева профиль, справа кнопки устройств и настроек. */}
<div className="flex items-center gap-0.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-1.5 py-1.5">
<button
ref={triggerRef}
ref={profileRef}
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-expanded={profileOpen}
aria-label={t('user.menu.open')}
onClick={toggleMenu}
onClick={toggleProfile}
className="flex min-w-0 flex-1 items-center gap-2 rounded-[var(--radius-md)] px-1 py-1 text-left transition-colors duration-[var(--duration-fast)] hover:bg-surface-3/70 active:bg-surface-3"
>
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="md" />
<span className="relative shrink-0">
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="md" />
<span
aria-hidden="true"
data-testid="user-status-dot"
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-1 ${statusDotClass[status]}`}
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium" title={displayName}>
{displayName}
@@ -118,37 +263,109 @@ export function UserPanel() {
</span>
</button>
<button
type="button"
aria-label={t('user.copyId')}
title={t('user.copyId')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={onCopyId}
<PanelControl
glyph="🎤"
enabled={micEnabled}
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
menuLabel={t('user.voice.micSettings')}
onToggle={toggleMic}
open={openControl === 'mic'}
onOpenMenu={() => toggleControl('mic')}
onCloseMenu={() => setOpenControl(null)}
>
#
</button>
<VoiceDeviceList
kind="audioinput"
label={t('user.voice.input')}
value={mic.value}
onChange={mic.select}
/>
<VolumeSlider
label={t('user.voice.micVolume')}
value={micVolume}
onChange={setMicVolume}
/>
<AudioSettingsLink label={t('user.voice.audioSettings')} onOpen={openAudioSettings} />
</PanelControl>
<PanelControl
glyph="🎥"
enabled={cameraEnabled}
toggleLabel={t(cameraEnabled ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
menuLabel={t('user.voice.cameraSettings')}
onToggle={toggleCamera}
open={openControl === 'camera'}
onOpenMenu={() => toggleControl('camera')}
onCloseMenu={() => setOpenControl(null)}
>
<VoiceDeviceList
kind="videoinput"
label={t('user.voice.camera')}
value={camera.value}
onChange={camera.select}
/>
<AudioSettingsLink label={t('user.voice.audioSettings')} onOpen={openAudioSettings} />
</PanelControl>
<PanelControl
glyph="🎧"
enabled={headphonesEnabled}
toggleLabel={t(headphonesEnabled ? 'user.voice.soundOff' : 'user.voice.soundOn')}
menuLabel={t('user.voice.soundSettings')}
onToggle={toggleHeadphones}
open={openControl === 'headphones'}
onOpenMenu={() => toggleControl('headphones')}
onCloseMenu={() => setOpenControl(null)}
>
<VoiceDeviceList
kind="audiooutput"
label={t('user.voice.output')}
value={output.value}
onChange={output.select}
/>
<VolumeSlider
label={t('user.voice.outputVolume')}
value={outputVolume}
onChange={setOutputVolume}
/>
<AudioSettingsLink label={t('user.voice.audioSettings')} onOpen={openAudioSettings} />
</PanelControl>
<button
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('user.settings')}
title={t('user.settings')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={toggleMenu}
className="rounded-[var(--radius-sm)] px-1 py-1 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => {
setOpenControl(null);
goToSettings({ section: 'profile', tab: 'main' });
}}
>
⚙
<span aria-hidden="true">⚙</span>
</button>
</div>
<Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
<MenuGroupLabel>{displayName}</MenuGroupLabel>
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
{t('user.menu.account')}
</MenuItem>
<MenuItem icon="🗄" onClick={goTo('/settings/servers')}>
{t('user.menu.guilds')}
</MenuItem>
<MenuSeparator />
<Menu
open={profileOpen}
label={t('user.menu.label')}
onClose={closeProfile}
anchorRef={profileRef}
className="w-64"
>
{/* Мини-профиль: аватар, имя, логин и «О себе». */}
<div
role="presentation"
className="flex flex-col gap-2 border-b border-border/60 px-2 pb-2"
>
<div className="flex items-center gap-2">
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="lg" />
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{displayName}</p>
<p className="truncate text-xs text-fg-muted">@{username}</p>
</div>
</div>
<p className="text-xs text-fg-muted">{bio === '' ? t('user.menu.noBio') : bio}</p>
</div>
<div role="group" aria-label={t('user.menu.status')}>
<MenuGroupLabel>{t('user.menu.status')}</MenuGroupLabel>
{selectableStatuses.map((value) => (
@@ -156,6 +373,12 @@ export function UserPanel() {
key={value}
role="menuitemradio"
selected={value === status}
alwaysShowIcon
icon={
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${statusDotClass[value]}`}
/>
}
disabled={changeStatus.isPending}
onClick={() => {
changeStatus.reset();
@@ -166,6 +389,17 @@ export function UserPanel() {
</MenuItem>
))}
</div>
<MenuSeparator />
<MenuItem icon="👤" onClick={() => goToSettings({ section: 'profile', tab: 'main' })}>
{t('user.menu.account')}
</MenuItem>
<MenuItem icon="🗄" onClick={() => goToSettings({ section: 'profile', tab: 'server' })}>
{t('user.menu.guilds')}
</MenuItem>
<MenuItem icon="#" onClick={onCopyId}>
{t('user.copyId')}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
@@ -181,10 +415,26 @@ export function UserPanel() {
</Menu>
{errorText === null ? null : (
<p role="alert" className="px-3 pb-2 text-xs text-danger">
<p role="alert" className="px-1 pt-2 text-xs text-danger">
{errorText}
</p>
)}
{/* Окно настроек живёт здесь же: панель — единственная точка входа. */}
<SettingsModal />
</footer>
);
}
/** Ссылка «Аудио-видео настройки» в поповере: открывает окно настроек. */
function AudioSettingsLink({ label, onOpen }: { label: string; onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
className="mt-2 w-full border-t border-border/60 px-1 pt-2 text-left text-xs text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
>
{label}
</button>
);
}
@@ -10,6 +10,16 @@ import { Button } from '@/components/ui/primitives';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Массовое применение ника: прогресс и итог по всем серверам пользователя. */
export interface ApplyAllNickname {
pending: boolean;
/** Текущий прогресс: сколько серверов уже обработано. */
progress: { done: number; total: number } | null;
/** Итог последнего запуска: сколько серверов не удалось обновить. */
result: { failed: number; total: number } | null;
run: (nickname: string) => void;
}
interface GuildNicknameSectionProps {
guildId: string;
/** id текущего пользователя: сервер разрешает менять ник самому себе. */
@@ -18,6 +28,8 @@ interface GuildNicknameSectionProps {
currentNickname: string;
/** Участники сервера (для подсказки, если ник пришёл не из снапшота). */
members: GuildMember[] | undefined;
/** Кнопка «Применить ко всем серверам» (только во вкладке профиля сервера). */
applyAll?: ApplyAllNickname;
}
/** «Мой профиль на сервере»: свой никнейм, пустое значение его сбрасывает. */
@@ -26,6 +38,7 @@ export function GuildNicknameSection({
userId,
currentNickname,
members,
applyAll,
}: GuildNicknameSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -92,6 +105,47 @@ export function GuildNicknameSection({
</Button>
</div>
</form>
{applyAll === undefined ? null : (
<div className="mt-4 border-t border-border/40 pt-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-md text-sm text-fg-muted">
{t('settings.servers.nickname.applyAllHint')}
</p>
<Button
variant="ghost"
disabled={applyAll.pending || userId === undefined}
onClick={() => applyAll.run(nickname.trim())}
>
{t(
applyAll.pending
? 'settings.servers.nickname.applyAllPending'
: 'settings.servers.nickname.applyAll',
)}
</Button>
</div>
{applyAll.progress === null ? null : (
<p role="status" className="mt-2 text-sm text-fg-muted">
{t('settings.servers.nickname.applyAllProgress', {
done: applyAll.progress.done,
total: applyAll.progress.total,
})}
</p>
)}
{applyAll.result === null ? null : applyAll.result.failed === 0 ? (
<p role="status" className="mt-2 text-sm text-success">
{t('settings.servers.nickname.applyAllDone', { total: applyAll.result.total })}
</p>
) : (
<p role="alert" className="mt-2 text-sm text-danger">
{t('settings.servers.nickname.applyAllFailed', {
failed: applyAll.result.failed,
})}
</p>
)}
</div>
)}
</SettingsSection>
);
}
@@ -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 (
<SettingsSection title={t('settings.language.title')}>
<SkeletonLines rows={3} />
</SettingsSection>
);
}
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 (
<SettingsSection
title={t('settings.language.title')}
description={t('settings.language.description')}
>
<div className="mt-4 flex flex-col gap-4">
<SelectField
label={t('settings.language.timezone')}
hint={t('settings.language.timezoneHint')}
value={timezone}
onChange={(value) => 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),
}))}
/>
<SelectField
label={t('settings.language.locale')}
hint={t('settings.language.localeHint')}
value={language}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((value) => ({
value,
label: t(`language.${value}`),
}))}
/>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{save.isSuccess ? (
<p role="status" className="text-sm text-success">
{t('settings.language.saved')}
</p>
) : null}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Уведомления»: пункт уже есть в списке настроек, но сами уведомления
* (веб-пуш и звуки) появятся в следующих фазах — показываем заглушку.
*/
export function NotificationsSettingsSection() {
const { t } = useTranslation();
return (
<SettingsSection
title={t('settings.notifications.title')}
description={t('settings.notifications.description')}
>
<p className="mt-3 text-sm text-fg-muted" role="status">
{t('settings.notifications.stub')}
</p>
</SettingsSection>
);
}
+292 -92
View File
@@ -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<string | null>(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<string>(user.status);
const [locale, setLocale] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [tz, setTz] = useState(user.timezone ?? DEFAULT_TIMEZONE);
const [language, setLanguage] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [saved, setSaved] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>): 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<HTMLFormElement>): 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 (
<Card>
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
<div className="flex flex-col gap-5">
<Card>
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.profile.displayName')}
name="display_name"
value={displayName}
maxLength={64}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextAreaField
label={t('settings.profile.bio')}
placeholder={t('settings.profile.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.profile.customStatus')}
placeholder={t('settings.profile.customStatusPlaceholder')}
name="custom_status"
maxLength={128}
value={customStatus}
onChange={(event) => setCustomStatus(event.target.value)}
label={t('settings.profile.displayName')}
name="display_name"
value={displayName}
maxLength={64}
onChange={(event) => setDisplayName(event.target.value)}
/>
<Field
label={t('settings.profile.customStatusEmoji')}
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
name="custom_status_emoji"
maxLength={8}
value={customEmoji}
onChange={(event) => setCustomEmoji(event.target.value)}
<TextAreaField
label={t('settings.profile.bio')}
placeholder={t('settings.profile.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
<Field
label={t('settings.profile.customStatus')}
placeholder={t('settings.profile.customStatusPlaceholder')}
name="custom_status"
maxLength={128}
value={customStatus}
onChange={(event) => setCustomStatus(event.target.value)}
/>
<Field
label={t('settings.profile.customStatusEmoji')}
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
name="custom_status_emoji"
maxLength={8}
value={customEmoji}
onChange={(event) => setCustomEmoji(event.target.value)}
/>
</div>
<SelectField
label={t('settings.profile.status')}
value={status}
onChange={setStatus}
options={userStatuses.map((value) => ({
value,
label: t(`user.status.${value}`),
}))}
/>
<SelectField
label={t('settings.profile.timezone')}
hint={t('settings.profile.timezoneHint')}
value={tz}
onChange={setTz}
options={timezoneChoices(tz).map((zone) => ({
value: zone,
label:
zone === DEFAULT_TIMEZONE
? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}`
: timezoneLabel(zone, locale),
}))}
/>
<SelectField
label={t('settings.profile.locale')}
value={language}
onChange={(value) => setLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((value) => ({
value,
label: t(`language.${value}`),
}))}
/>
<dl className="text-sm text-fg-muted">
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.username')}</dt>
<dd className="font-medium text-fg">{user.username}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.userId')}</dt>
<dd className="font-mono text-xs text-fg">{user.id}</dd>
</div>
<div className="flex justify-between gap-4 py-2">
<dt>{t('settings.profile.badges')}</dt>
<dd className="text-fg">
{user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')}
</dd>
</div>
</dl>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{saved && !save.isError ? (
<p className="text-sm text-success" role="status">
{t('settings.profile.saved')}
</p>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
</div>
</form>
</Card>
<SettingsSection
title={t('settings.profile.avatar.title')}
description={t('settings.profile.avatar.hint')}
>
<div className="mt-4 flex flex-wrap items-center gap-4">
{avatar.preview === null ? (
<Avatar name={avatarName} seed={user.id} fileId={user.avatar_file_id} size="lg" />
) : (
<img
src={avatar.preview}
alt={t('settings.profile.avatar.preview')}
className="h-14 w-14 rounded-full object-cover"
/>
)}
<div className="flex flex-col items-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
aria-label={t('settings.profile.avatar.choose')}
className="sr-only"
onChange={onPickFile}
/>
<Button
variant="ghost"
disabled={upload.isPending}
onClick={() => fileInputRef.current?.click()}
>
{t(upload.isPending ? 'common.saving' : 'settings.profile.avatar.upload')}
</Button>
{user.avatar_file_id === undefined ? null : (
<Button
variant="ghost"
disabled={removeAvatar.isPending}
onClick={() => removeAvatar.mutate()}
>
{t('settings.profile.avatar.remove')}
</Button>
)}
<p className="text-xs text-fg-muted">
{t('settings.profile.avatar.limit', {
limit: formatBytes(AVATAR_MAX_BYTES, locale),
})}
</p>
</div>
</div>
<SelectField
label={t('settings.profile.status')}
value={status}
onChange={setStatus}
options={userStatuses.map((value) => ({
value,
label: t(`user.status.${value}`),
}))}
/>
<SelectField
label={t('settings.profile.locale')}
value={locale}
onChange={(value) => setLocale(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<dl className="text-sm text-fg-muted">
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.username')}</dt>
<dd className="font-medium text-fg">{user.username}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.userId')}</dt>
<dd className="font-mono text-xs text-fg">{user.id}</dd>
</div>
<div className="flex justify-between gap-4 py-2">
<dt>{t('settings.profile.badges')}</dt>
<dd className="text-fg">
{user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')}
</dd>
</div>
</dl>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{saved && !save.isError ? (
<p className="text-sm text-success" role="status">
{t('settings.profile.saved')}
{avatarError === null ? null : (
<p role="alert" className="mt-3 text-sm text-danger">
{avatarError}
</p>
) : null}
)}
{upload.isError ? <ErrorNotice className="mt-3" error={upload.error} /> : null}
{removeAvatar.isError ? <ErrorNotice className="mt-3" error={removeAvatar.error} /> : null}
</SettingsSection>
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
<SettingsSection
title={t('settings.profile.decorations.title')}
description={t('settings.profile.decorations.hint')}
>
<div className="mt-3 flex flex-wrap items-center gap-2">
{user.badges.length === 0 ? (
<p className="text-sm text-fg-muted">{t('settings.profile.decorations.empty')}</p>
) : (
user.badges.map((badge) => <Badge key={badge}>{badge}</Badge>)
)}
</div>
</form>
</Card>
<p className="mt-3 text-sm text-fg-muted">{t('settings.profile.decorations.stub')}</p>
</SettingsSection>
</div>
);
}
+159 -93
View File
@@ -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<HTMLButtonElement | null>(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 ? (
<Card>
<SkeletonLines rows={4} />
</Card>
) : (
<SettingsSection
title={t('settings.servers.emptyTitle')}
description={t('settings.servers.emptyHint')}
>
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
</SettingsSection>
);
}
return (
<div className="flex flex-col gap-5">
<Card className="p-3">
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('settings.servers.pickerLabel')}
</h2>
{guilds.length === 0 ? (
myGuilds.isPending ? (
<SkeletonLines className="mt-3" rows={1} />
) : (
<p className="mt-2 px-1 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
)
) : (
<nav
aria-label={t('settings.servers.pickerLabel')}
className="gl-stagger mt-2 flex flex-wrap gap-1.5"
data-testid="guild-picker"
>
{guilds.map((guildItem) => {
const selected = guildItem.id === activeId;
return (
<NavLink
key={guildItem.id}
to={`/settings/servers/${guildItem.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={t('settings.servers.select', { name: guildItem.name })}
className={`flex max-w-56 items-center gap-2 rounded-[var(--radius-md)] border px-2 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'border-accent bg-surface-3 text-fg'
: 'border-border/60 bg-surface-1 text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<Avatar
name={guildItem.name}
seed={guildItem.id}
fileId={guildItem.icon_file_id}
size="sm"
shape="square"
/>
<span className="truncate">{guildItem.name}</span>
</NavLink>
);
})}
</nav>
)}
</Card>
<div className="relative">
<button
ref={pickerRef}
type="button"
aria-haspopup="menu"
aria-expanded={pickerOpen}
aria-label={t('settings.servers.selectLabel')}
onClick={() => setPickerOpen((value) => !value)}
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-3 py-2 text-left text-sm transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
{active === null ? null : (
<Avatar
name={active.name}
seed={active.id}
fileId={active.icon_file_id}
size="sm"
shape="square"
/>
)}
<span className="min-w-0 flex-1 truncate font-medium">
{active?.name ?? t('settings.servers.selectLabel')}
</span>
<span aria-hidden="true" className="text-fg-muted">
▾
</span>
</button>
{guilds.length === 0 ? (
myGuilds.isPending ? (
<Card>
<SkeletonLines rows={4} />
</Card>
) : (
<SettingsSection
title={t('settings.servers.emptyTitle')}
description={t('settings.servers.emptyHint')}
>
<div className="mt-4">
<NavLink className="text-sm text-accent underline" to="/app">
{t('settings.back')}
</NavLink>
</div>
</SettingsSection>
)
) : activeId === null ? (
// Сервер из URL не найден среди доступных пользователю.
<Menu
open={pickerOpen}
label={t('settings.servers.pickerLabel')}
anchorRef={pickerRef}
placement="bottom"
onClose={() => setPickerOpen(false)}
>
<MenuGroupLabel>{t('settings.servers.pickerLabel')}</MenuGroupLabel>
{guilds.map((item) => (
<MenuItem
key={item.id}
role="menuitemradio"
selected={item.id === activeId}
alwaysShowIcon
icon={
<Avatar
name={item.name}
seed={item.id}
fileId={item.icon_file_id}
size="sm"
shape="square"
/>
}
onClick={() => {
selectSettingsGuild(item.id);
setPickerOpen(false);
}}
>
{item.name}
</MenuItem>
))}
</Menu>
</div>
{activeId === null ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
</Card>
@@ -185,13 +243,19 @@ export default function ServersSettingsPage() {
</Card>
) : (
<>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildNicknameSection
guildId={guild.id}
userId={currentUser.data?.id}
userId={userId}
currentNickname={currentNickname}
members={members.data}
applyAll={{
pending: applyAll.isPending,
progress,
result: applyResult,
run: onApplyAll,
}}
/>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildMembersSection
members={members.data}
isPending={members.isPending}
@@ -203,7 +267,7 @@ export default function ServersSettingsPage() {
key={`${guild.id}-danger`}
guildId={guild.id}
guildName={guild.name}
isOwner={currentUser.data?.id !== undefined && currentUser.data.id === guild.owner_id}
isOwner={userId !== undefined && userId === guild.owner_id}
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
/>
</>
@@ -211,3 +275,5 @@ export default function ServersSettingsPage() {
</div>
);
}
export default ServersSettingsPage;
+15 -26
View File
@@ -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 (
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-5 px-4 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
<NavLink className="text-sm text-accent underline" to="/app">
{t('settings.back')}
</NavLink>
</header>
useEffect(() => {
openSettings(target);
}, [openSettings, target]);
<SettingsSwitcher />
{/* Ключ по пути: при смене вкладки/раздела контент появляется с анимацией. */}
<div key={location.pathname} className="gl-page flex flex-col gap-5">
<SettingsSuspense>
<Outlet />
</SettingsSuspense>
</div>
</div>
);
return <Navigate to="/app" replace />;
}
+131
View File
@@ -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 (
<div className="flex flex-col gap-5">
<div
role="tablist"
aria-label={t('settings.profile.tabsLabel')}
className="flex gap-1 border-b border-border/40"
>
{tabs.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
onClick={() => setTab(item.id)}
className={`-mb-px rounded-t-[var(--radius-sm)] border-b-2 px-3 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
tab === item.id
? 'border-accent text-fg'
: 'border-transparent text-fg-muted hover:text-fg'
}`}
>
{item.label}
</button>
))}
</div>
{tab === 'main' ? <ProfileSettingsPage /> : <ServersSettingsPage />}
</div>
);
}
/**
* Модальное окно настроек: слева вертикальный список пунктов, справа
* содержимое выбранного. Открывается из панели пользователя (шестерёнка) и из
* меню пользователя, а также при переходе на старые адреса `/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 (
<Modal
open={open}
title={t('settings.title')}
size="xl"
portal
panelClassName="flex max-h-[85vh] flex-col"
bodyClassName="flex min-h-0 flex-1 gap-4"
onClose={closeSettings}
>
<nav
aria-label={t('settings.navLabel')}
className="flex w-40 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border/40 pr-2"
>
{items.map((item) => (
<button
key={item.id}
type="button"
aria-current={section === item.id ? 'true' : undefined}
onClick={() => setSection(item.id)}
className={`rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm transition-colors duration-[var(--duration-fast)] ${
section === item.id
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
{item.label}
</button>
))}
</nav>
{/* Ключ по пункту: при переключении прокрутка и локальное состояние
секции (например, шаг настройки 2FA) сбрасываются. */}
<div
key={section}
className="min-h-0 min-w-0 flex-1 overflow-y-auto pr-1"
data-testid="settings-content"
>
{section === 'security' ? <SecuritySettingsPage /> : null}
{section === 'appearance' ? <AppearanceSettingsPage /> : null}
{section === 'notifications' ? <NotificationsSettingsSection /> : null}
{section === 'voice' ? <VoiceSettingsSection /> : null}
{section === 'language' ? <LanguageRegionSection /> : null}
{section === 'instance' ? <InstanceSettingsPage /> : null}
{section === 'profile' ? <ProfileTabs /> : null}
</div>
</Modal>
);
}
@@ -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 (
<SettingsSection
title={t('settings.voice.title')}
description={t('settings.voice.description')}
>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<VoiceDeviceList
kind="audioinput"
label={t('settings.voice.input')}
value={mic.value}
onChange={mic.select}
/>
<VoiceDeviceList
kind="videoinput"
label={t('settings.voice.camera')}
value={camera.value}
onChange={camera.select}
/>
<VoiceDeviceList
kind="audiooutput"
label={t('settings.voice.output')}
value={output.value}
onChange={output.select}
/>
<div className="flex flex-col justify-end gap-2">
<VolumeSlider
label={t('settings.voice.micVolume')}
value={micVolume}
onChange={setMicVolume}
/>
<VolumeSlider
label={t('settings.voice.outputVolume')}
value={outputVolume}
onChange={setOutputVolume}
/>
</div>
</div>
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
</SettingsSection>
);
}
+42
View File
@@ -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' };
}
}
+55
View File
@@ -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('чат комнаты: права и действия', () => {
+9
View File
@@ -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,
+99 -18
View File
@@ -25,14 +25,44 @@ const instanceOpen = {
guild_count: 1,
};
const totpLabel = 'Код 2FA или резервный код';
/** Ответы для входа: логин и оболочка приложения. */
function loginRoutes(extra: Parameters<typeof installFetch>[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<typeof installFetch>) {
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',
});
});
});
+161
View File
@@ -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(<MessageContent content={content} />);
}
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,<b>x</b>');
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toContain('javascript:alert(1)');
expect(container.textContent).toContain('data:text/html,<b>x</b>');
});
it('HTML из сообщения не выполняется', () => {
const { container } = markup('<img src=x onerror=alert(1)> <script>alert(2)</script>');
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('<script>alert(2)</script>');
});
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('см. #общий');
});
});
+117 -50
View File
@@ -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<HTMLElement> {
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();
});
});
+319 -59
View File
@@ -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<HTMLElement> {
const gear = await screen.findByLabelText('Настройки пользователя');
await userEvent.click(gear);
return screen.findByRole('dialog', { name: 'Настройки' });
}
/** Переходит к пункту настроек по его названию в левом меню. */
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
await userEvent.click(within(dialog).getByRole('button', { name }));
}
/** Тело multipart-запроса аватара (или null, если запроса не было). */
function avatarForm(fetchMock: ReturnType<typeof installFetch>, 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([
+17 -6
View File
@@ -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 () => {
+53 -25
View File
@@ -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();
});
});
+229
View File
@@ -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<string, unknown> {
const raw = window.localStorage.getItem(VOICE_STORAGE_KEY);
return raw === null ? {} : (JSON.parse(raw) as Record<string, unknown>);
}
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);
});
});