feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -68,6 +68,8 @@ export function apiErrorField(error: unknown, key: string): unknown {
|
|||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
|
/** Тело multipart (загрузка файлов): заменяет JSON-тело. */
|
||||||
|
form?: FormData;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
headers?: Record<string, string>;
|
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> {
|
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
// Multipart отдаёт браузер: Content-Type с boundary выставляет он сам.
|
||||||
|
const isForm = options.form !== undefined;
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: options.method ?? 'GET',
|
method: options.method ?? 'GET',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
...(options.body === undefined || isForm ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (options.body !== undefined) {
|
if (isForm) {
|
||||||
|
init.body = options.form ?? null;
|
||||||
|
} else if (options.body !== undefined) {
|
||||||
init.body = JSON.stringify(options.body);
|
init.body = JSON.stringify(options.body);
|
||||||
}
|
}
|
||||||
if (options.signal !== undefined) {
|
if (options.signal !== undefined) {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface User {
|
|||||||
is_instance_admin: boolean;
|
is_instance_admin: boolean;
|
||||||
badges: string[];
|
badges: string[];
|
||||||
locale: string;
|
locale: string;
|
||||||
|
/** Часовой пояс в формате IANA; сервер может его не отдавать. */
|
||||||
|
timezone?: string;
|
||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
||||||
totp_enabled: boolean;
|
totp_enabled: boolean;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface UpdateProfileInput {
|
|||||||
custom_status?: string;
|
custom_status?: string;
|
||||||
custom_status_emoji?: string;
|
custom_status_emoji?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
/** Часовой пояс в формате IANA (например `Europe/Moscow`). */
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnboardingInput {
|
export interface OnboardingInput {
|
||||||
@@ -44,6 +46,32 @@ export async function completeOnboarding(input: OnboardingInput): Promise<User>
|
|||||||
return payload.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[]> {
|
export async function fetchMyGuilds(signal?: AbortSignal): Promise<GuildSummary[]> {
|
||||||
const payload = await request<{ guilds: GuildSummary[] }>(
|
const payload = await request<{ guilds: GuildSummary[] }>(
|
||||||
'/users/@me/guilds',
|
'/users/@me/guilds',
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ export interface MessageContentProps {
|
|||||||
/**
|
/**
|
||||||
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||||
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||||
* ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются.
|
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
|
||||||
|
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
|
||||||
|
* слова (`foo_bar_baz`).
|
||||||
*/
|
*/
|
||||||
const INLINE_PATTERN =
|
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 } {
|
function splitLink(raw: string): { href: string; trailing: string } {
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
/** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */
|
/** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
/**
|
||||||
|
* Показывать иконку и у выбранного пункта (галочка не заменяет её).
|
||||||
|
* Нужно для цветных точек статуса: цвет важнее отметки выбора.
|
||||||
|
*/
|
||||||
|
alwaysShowIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */
|
/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */
|
||||||
@@ -175,6 +180,7 @@ export function MenuItem({
|
|||||||
icon,
|
icon,
|
||||||
selected = false,
|
selected = false,
|
||||||
danger = false,
|
danger = false,
|
||||||
|
alwaysShowIcon = false,
|
||||||
className = '',
|
className = '',
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
@@ -192,7 +198,7 @@ export function MenuItem({
|
|||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="w-4 shrink-0 text-center">
|
<span aria-hidden="true" className="w-4 shrink-0 text-center">
|
||||||
{selected ? '✓' : (icon ?? '')}
|
{selected && !alwaysShowIcon ? '✓' : (icon ?? '')}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate">{children}</span>
|
<span className="min-w-0 flex-1 truncate">{children}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { motionDelay } from '@/lib/motion';
|
import { motionDelay } from '@/lib/motion';
|
||||||
@@ -9,20 +10,53 @@ interface ModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** Ширина панели: обычные окна — `md`, настройки — `xl`. */
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
/** Дополнительные классы панели (например, ограничение высоты). */
|
||||||
|
panelClassName?: string;
|
||||||
|
/** Дополнительные классы области содержимого (прокрутка длинных форм). */
|
||||||
|
bodyClassName?: string;
|
||||||
|
/**
|
||||||
|
* Рендерить окно в `document.body`. Нужно, когда модалка открывается из
|
||||||
|
* узкой колонки (панель пользователя): портал снимает вопросы к вложенным
|
||||||
|
* `overflow` и трансформациям родителей.
|
||||||
|
*/
|
||||||
|
portal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
|
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
|
||||||
const EXIT_MS = 140;
|
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 и по клику на подложку,
|
* Модальное окно: закрывается по Escape, по клику на подложку и по крестику
|
||||||
* focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса.
|
* в правом верхнем углу. Escape обрабатывает только верхнее окно — вложенное
|
||||||
|
* подтверждение (например, «выйти с сервера») не закрывает родителя.
|
||||||
*
|
*
|
||||||
* Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`):
|
* Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`):
|
||||||
* при закрытии узел остаётся в DOM до конца обратной анимации, а при
|
* при закрытии узел остаётся в DOM до конца обратной анимации, а при
|
||||||
* `prefers-reduced-motion` удаляется сразу.
|
* `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 { t } = useTranslation();
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [mounted, setMounted] = useState(open);
|
const [mounted, setMounted] = useState(open);
|
||||||
@@ -64,8 +98,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Стек открытых окон: Escape закрывает только верхнее.
|
||||||
|
const token = Symbol('modal');
|
||||||
|
openModals.push(token);
|
||||||
const onKeyDown = (event: KeyboardEvent): void => {
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape' && openModals[openModals.length - 1] === token) {
|
||||||
onCloseRef.current();
|
onCloseRef.current();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,6 +114,10 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
|||||||
firstField?.focus();
|
firstField?.focus();
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
const index = openModals.indexOf(token);
|
||||||
|
if (index !== -1) {
|
||||||
|
openModals.splice(index, 1);
|
||||||
|
}
|
||||||
// Возвращаем фокус туда, откуда окно открыли.
|
// Возвращаем фокус туда, откуда окно открыли.
|
||||||
previous?.focus();
|
previous?.focus();
|
||||||
};
|
};
|
||||||
@@ -86,8 +127,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const node = (
|
||||||
<div
|
<div
|
||||||
|
data-testid="modal-backdrop"
|
||||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 ${
|
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 ${
|
||||||
closing ? 'gl-backdrop-out' : 'gl-backdrop-in'
|
closing ? 'gl-backdrop-out' : 'gl-backdrop-in'
|
||||||
}`}
|
}`}
|
||||||
@@ -102,9 +144,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
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'
|
closing ? 'gl-pop-out' : 'gl-pop-in'
|
||||||
}`}
|
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold">{title}</h2>
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
@@ -117,9 +159,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
{footer === undefined ? null : <div className="mt-5 flex justify-end gap-2">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return portal ? createPortal(node, document.body) : node;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,15 +100,16 @@
|
|||||||
"email": "Email",
|
"email": "Email",
|
||||||
"emailPlaceholder": "you@example.com",
|
"emailPlaceholder": "you@example.com",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"totp": "2FA code",
|
"totp": "2FA or recovery code",
|
||||||
"totpHint": "Six digits from your authenticator app.",
|
"totpHint": "If 2FA is enabled; a recovery code like xxxx-xxxx-xxxx also works.",
|
||||||
"submit": "Sign in",
|
"submit": "Sign in",
|
||||||
"submitting": "Signing in…",
|
"submitting": "Signing in…",
|
||||||
"noAccount": "No account yet?",
|
"noAccount": "No account yet?",
|
||||||
"registerLink": "Create one",
|
"registerLink": "Create one",
|
||||||
"forgot": "Forgot your password? Ask the instance admin.",
|
"forgot": "Forgot your password? Ask the instance admin.",
|
||||||
"instanceStatus": "Instance status",
|
"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": {
|
"register": {
|
||||||
"title": "Create account",
|
"title": "Create account",
|
||||||
@@ -219,7 +220,8 @@
|
|||||||
},
|
},
|
||||||
"selectUnread": "Open channel {{name}}, {{count}} unread",
|
"selectUnread": "Open channel {{name}}, {{count}} unread",
|
||||||
"selectMentions": "Open channel {{name}}, {{count}} mentions",
|
"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": {
|
"chat": {
|
||||||
"log": "Message log of {{name}}",
|
"log": "Message log of {{name}}",
|
||||||
@@ -309,7 +311,11 @@
|
|||||||
"empty": "Nothing found."
|
"empty": "Nothing found."
|
||||||
},
|
},
|
||||||
"unread": "Unread messages: {{count}}",
|
"unread": "Unread messages: {{count}}",
|
||||||
"mentions": "Mentions: {{count}}"
|
"mentions": "Mentions: {{count}}",
|
||||||
|
"spoiler": {
|
||||||
|
"show": "Reveal spoiler",
|
||||||
|
"hide": "Hide spoiler"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"panelLabel": "User panel",
|
"panelLabel": "User panel",
|
||||||
@@ -319,7 +325,8 @@
|
|||||||
"open": "Open the user menu",
|
"open": "Open the user menu",
|
||||||
"account": "Account settings",
|
"account": "Account settings",
|
||||||
"guilds": "Server settings",
|
"guilds": "Server settings",
|
||||||
"status": "Status"
|
"status": "Status",
|
||||||
|
"noBio": "No bio yet."
|
||||||
},
|
},
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"loggingOut": "Signing out…",
|
"loggingOut": "Signing out…",
|
||||||
@@ -331,6 +338,29 @@
|
|||||||
"dnd": "Do not disturb",
|
"dnd": "Do not disturb",
|
||||||
"invisible": "Invisible",
|
"invisible": "Invisible",
|
||||||
"offline": "Offline"
|
"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": {
|
"gateway": {
|
||||||
@@ -357,6 +387,9 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"voice": "Audio & video",
|
||||||
|
"language": "Language & region",
|
||||||
"instance": "Instance"
|
"instance": "Instance"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -375,7 +408,31 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"userId": "User ID",
|
"userId": "User ID",
|
||||||
"badges": "Badges",
|
"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": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
@@ -467,7 +524,13 @@
|
|||||||
"placeholder": "Leave empty to reset the nickname",
|
"placeholder": "Leave empty to reset the nickname",
|
||||||
"current": "Current nickname:",
|
"current": "Current nickname:",
|
||||||
"none": "not set",
|
"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": {
|
"members": {
|
||||||
"title": "Members",
|
"title": "Members",
|
||||||
@@ -497,7 +560,33 @@
|
|||||||
"deleteConfirmTitle": "Delete the server?",
|
"deleteConfirmTitle": "Delete the server?",
|
||||||
"deleteConfirmBody": "“{{name}}” will be deleted permanently.",
|
"deleteConfirmBody": "“{{name}}” will be deleted permanently.",
|
||||||
"deleteConfirm": "Delete"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,15 +100,16 @@
|
|||||||
"email": "Почта",
|
"email": "Почта",
|
||||||
"emailPlaceholder": "you@example.com",
|
"emailPlaceholder": "you@example.com",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"totp": "Код 2FA",
|
"totp": "Код 2FA или резервный код",
|
||||||
"totpHint": "Шесть цифр из приложения-аутентификатора.",
|
"totpHint": "Если 2FA включена; можно ввести резервный код вида xxxx-xxxx-xxxx.",
|
||||||
"submit": "Войти",
|
"submit": "Войти",
|
||||||
"submitting": "Входим…",
|
"submitting": "Входим…",
|
||||||
"noAccount": "Ещё нет аккаунта?",
|
"noAccount": "Ещё нет аккаунта?",
|
||||||
"registerLink": "Зарегистрироваться",
|
"registerLink": "Зарегистрироваться",
|
||||||
"forgot": "Пароль забыт? Обратитесь к администратору инстанса.",
|
"forgot": "Пароль забыт? Обратитесь к администратору инстанса.",
|
||||||
"instanceStatus": "Состояние инстанса",
|
"instanceStatus": "Состояние инстанса",
|
||||||
"registrationDisabled": "Регистрация на этом инстансе выключена администратором."
|
"registrationDisabled": "Регистрация на этом инстансе выключена администратором.",
|
||||||
|
"totpRequired": "Введите код 2FA или резервный код — без него вход невозможен."
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Регистрация",
|
"title": "Регистрация",
|
||||||
@@ -219,7 +220,8 @@
|
|||||||
},
|
},
|
||||||
"selectUnread": "Открыть комнату {{name}}, непрочитанных: {{count}}",
|
"selectUnread": "Открыть комнату {{name}}, непрочитанных: {{count}}",
|
||||||
"selectMentions": "Открыть комнату {{name}}, упоминаний: {{count}}",
|
"selectMentions": "Открыть комнату {{name}}, упоминаний: {{count}}",
|
||||||
"slowmode": "медленный режим {{seconds}} с"
|
"slowmode": "медленный режим {{seconds}} с",
|
||||||
|
"leaveHint": "Выйти из сервера можно в настройках сервера → «Опасная зона»."
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"log": "Лента сообщений комнаты {{name}}",
|
"log": "Лента сообщений комнаты {{name}}",
|
||||||
@@ -309,7 +311,11 @@
|
|||||||
"empty": "Ничего не найдено."
|
"empty": "Ничего не найдено."
|
||||||
},
|
},
|
||||||
"unread": "Непрочитанных сообщений: {{count}}",
|
"unread": "Непрочитанных сообщений: {{count}}",
|
||||||
"mentions": "Упоминаний: {{count}}"
|
"mentions": "Упоминаний: {{count}}",
|
||||||
|
"spoiler": {
|
||||||
|
"show": "Показать спойлер",
|
||||||
|
"hide": "Скрыть спойлер"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"panelLabel": "Панель пользователя",
|
"panelLabel": "Панель пользователя",
|
||||||
@@ -319,7 +325,8 @@
|
|||||||
"open": "Открыть меню пользователя",
|
"open": "Открыть меню пользователя",
|
||||||
"account": "Настройки аккаунта",
|
"account": "Настройки аккаунта",
|
||||||
"guilds": "Настройки серверов",
|
"guilds": "Настройки серверов",
|
||||||
"status": "Статус"
|
"status": "Статус",
|
||||||
|
"noBio": "О себе пока не заполнено."
|
||||||
},
|
},
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"loggingOut": "Выходим…",
|
"loggingOut": "Выходим…",
|
||||||
@@ -331,6 +338,29 @@
|
|||||||
"dnd": "Не беспокоить",
|
"dnd": "Не беспокоить",
|
||||||
"invisible": "Невидимый",
|
"invisible": "Невидимый",
|
||||||
"offline": "Не в сети"
|
"offline": "Не в сети"
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"input": "Устройство ввода",
|
||||||
|
"output": "Устройство вывода",
|
||||||
|
"camera": "Камера",
|
||||||
|
"micVolume": "Громкость микрофона",
|
||||||
|
"outputVolume": "Громкость звука",
|
||||||
|
"micOn": "Включить микрофон",
|
||||||
|
"micOff": "Выключить микрофон",
|
||||||
|
"cameraOn": "Включить камеру",
|
||||||
|
"cameraOff": "Выключить камеру",
|
||||||
|
"soundOn": "Включить звук",
|
||||||
|
"soundOff": "Выключить звук",
|
||||||
|
"micSettings": "Настройки микрофона",
|
||||||
|
"cameraSettings": "Настройки камеры",
|
||||||
|
"soundSettings": "Настройки звука",
|
||||||
|
"audioSettings": "Аудио-видео настройки"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"default": "Устройство по умолчанию",
|
||||||
|
"unavailable": "Устройства недоступны",
|
||||||
|
"empty": "Устройства не найдены",
|
||||||
|
"unnamed": "Устройство {{index}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
@@ -357,6 +387,9 @@
|
|||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
"security": "Безопасность",
|
"security": "Безопасность",
|
||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
|
"notifications": "Уведомления",
|
||||||
|
"voice": "Аудио-видео",
|
||||||
|
"language": "Язык и регион",
|
||||||
"instance": "Инстанс"
|
"instance": "Инстанс"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -375,7 +408,31 @@
|
|||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"userId": "ID пользователя",
|
"userId": "ID пользователя",
|
||||||
"badges": "Значки",
|
"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": {
|
"security": {
|
||||||
"title": "Безопасность",
|
"title": "Безопасность",
|
||||||
@@ -467,7 +524,13 @@
|
|||||||
"placeholder": "Оставьте пустым, чтобы сбросить ник",
|
"placeholder": "Оставьте пустым, чтобы сбросить ник",
|
||||||
"current": "Текущий никнейм:",
|
"current": "Текущий никнейм:",
|
||||||
"none": "не задан",
|
"none": "не задан",
|
||||||
"saved": "Никнейм сохранён."
|
"saved": "Никнейм сохранён.",
|
||||||
|
"applyAll": "Применить ко всем серверам",
|
||||||
|
"applyAllHint": "Один ник для всех серверов, где вы состоите.",
|
||||||
|
"applyAllPending": "Применяем…",
|
||||||
|
"applyAllProgress": "Обновлено серверов: {{done}} из {{total}}",
|
||||||
|
"applyAllDone": "Никнейм применён на всех серверах ({{total}}).",
|
||||||
|
"applyAllFailed": "Не удалось обновить серверов: {{failed}}."
|
||||||
},
|
},
|
||||||
"members": {
|
"members": {
|
||||||
"title": "Участники",
|
"title": "Участники",
|
||||||
@@ -497,7 +560,33 @@
|
|||||||
"deleteConfirmTitle": "Удалить сервер?",
|
"deleteConfirmTitle": "Удалить сервер?",
|
||||||
"deleteConfirmBody": "Сервер «{{name}}» будет удалён без возможности восстановления.",
|
"deleteConfirmBody": "Сервер «{{name}}» будет удалён без возможности восстановления.",
|
||||||
"deleteConfirm": "Удалить"
|
"deleteConfirm": "Удалить"
|
||||||
}
|
},
|
||||||
|
"selectLabel": "Выбрать сервер"
|
||||||
|
},
|
||||||
|
"navLabel": "Пункты настроек",
|
||||||
|
"notifications": {
|
||||||
|
"title": "Уведомления",
|
||||||
|
"description": "Звуки, упоминания и веб-пуш.",
|
||||||
|
"stub": "Настройки уведомлений появятся позже."
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"title": "Аудио-видео",
|
||||||
|
"description": "Устройства ввода и вывода и громкость.",
|
||||||
|
"input": "Микрофон",
|
||||||
|
"camera": "Камера",
|
||||||
|
"output": "Звук",
|
||||||
|
"micVolume": "Громкость микрофона",
|
||||||
|
"outputVolume": "Громкость звука",
|
||||||
|
"stub": "Голосовые комнаты появятся в следующей фазе — пока настройки только сохраняются."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Язык и регион",
|
||||||
|
"description": "Часовой пояс и язык интерфейса.",
|
||||||
|
"timezone": "Часовой пояс",
|
||||||
|
"timezoneHint": "Используется для времени сообщений.",
|
||||||
|
"locale": "Язык интерфейса",
|
||||||
|
"localeHint": "Переключается сразу.",
|
||||||
|
"saved": "Настройки региона сохранены."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { VoiceDeviceKind } from '@/stores/voice';
|
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
|
||||||
|
|
||||||
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
|
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
|
||||||
export interface MediaDevice {
|
export interface MediaDevice {
|
||||||
@@ -30,13 +30,15 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supported) {
|
if (!supported) {
|
||||||
setDevices([]);
|
// API нет — список остаётся пустым, интерфейс покажет «недоступны».
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Ссылку запоминаем: к моменту очистки эффекта API может быть уже недоступно.
|
||||||
|
const mediaDevices = navigator.mediaDevices;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const load = (): void => {
|
const load = (): void => {
|
||||||
void navigator.mediaDevices
|
void mediaDevices
|
||||||
.enumerateDevices()
|
.enumerateDevices()
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -57,12 +59,33 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
|
|||||||
|
|
||||||
load();
|
load();
|
||||||
// Список меняется при подключении гарнитуры — обновляем его на лету.
|
// Список меняется при подключении гарнитуры — обновляем его на лету.
|
||||||
navigator.mediaDevices.addEventListener?.('devicechange', load);
|
mediaDevices.addEventListener?.('devicechange', load);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
navigator.mediaDevices.removeEventListener?.('devicechange', load);
|
mediaDevices.removeEventListener?.('devicechange', load);
|
||||||
};
|
};
|
||||||
}, [kind, supported]);
|
}, [kind, supported]);
|
||||||
|
|
||||||
return { devices, 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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 type { Channel } from '@/api/types';
|
||||||
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
||||||
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
||||||
import { myGuildsQueryKey } from '@/api/users';
|
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, SelectField } from '@/components/ui/Field';
|
import { Field, SelectField } from '@/components/ui/Field';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
@@ -17,6 +16,7 @@ import { groupChannels } from '@/lib/channels';
|
|||||||
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
|
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
interface ChannelSidebarProps {
|
interface ChannelSidebarProps {
|
||||||
guildId: string | null;
|
guildId: string | null;
|
||||||
@@ -79,7 +79,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
);
|
);
|
||||||
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
|
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
|
||||||
const upsertChannel = useSessionStore((state) => state.upsertChannel);
|
const upsertChannel = useSessionStore((state) => state.upsertChannel);
|
||||||
const removeGuild = useSessionStore((state) => state.removeGuild);
|
const openSettings = useUiStore((state) => state.openSettings);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [channelName, setChannelName] = useState('');
|
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 groups = useMemo(() => groupChannels(channels), [channels]);
|
||||||
const categories = useMemo(
|
const categories = useMemo(
|
||||||
() => channels.filter((channel) => channel.type === 'category'),
|
() => channels.filter((channel) => channel.type === 'category'),
|
||||||
@@ -163,14 +152,15 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{canManageSettings ? (
|
{canManageSettings ? (
|
||||||
<Link
|
<button
|
||||||
to={`/settings/servers/${guildId}`}
|
type="button"
|
||||||
aria-label={t('channels.settings')}
|
aria-label={t('channels.settings')}
|
||||||
title={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"
|
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}
|
) : null}
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<button
|
<button
|
||||||
@@ -228,19 +218,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="border-t border-border/40 px-2 py-2">
|
<footer className="border-t border-border/40 px-2 py-2">
|
||||||
<Button
|
<p className="px-1 text-xs text-fg-muted">{t('channels.leaveHint')}</p>
|
||||||
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}
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
+292
-42
@@ -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 { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
@@ -8,19 +8,123 @@ import { currentUserQueryKey, updateProfile } from '@/api/users';
|
|||||||
import { userStatuses, type UserStatus } from '@/api/types';
|
import { userStatuses, type UserStatus } from '@/api/types';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
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 { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
import { useDeviceChoice } from '@/lib/useMediaDevices';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore } from '@/stores/session';
|
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 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() {
|
export function UserPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -30,12 +134,30 @@ export function UserPanel() {
|
|||||||
const describeError = useApiErrorMessage();
|
const describeError = useApiErrorMessage();
|
||||||
const sessionUser = useSessionStore((state) => state.user);
|
const sessionUser = useSessionStore((state) => state.user);
|
||||||
const disconnect = useGatewayStore((state) => state.disconnect);
|
const disconnect = useGatewayStore((state) => state.disconnect);
|
||||||
|
const openSettings = useUiStore((state) => state.openSettings);
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const micEnabled = useVoiceStore((state) => state.micEnabled);
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
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 name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
|
||||||
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
|
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
|
||||||
|
const bio = currentUser.data?.bio ?? '';
|
||||||
const displayName = name === '' ? username : name;
|
const displayName = name === '' ? username : name;
|
||||||
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
|
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
|
||||||
const customStatus = sessionUser?.custom_status ?? '';
|
const customStatus = sessionUser?.custom_status ?? '';
|
||||||
@@ -61,7 +183,7 @@ export function UserPanel() {
|
|||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||||
useSessionStore.getState().patchUser(updated);
|
useSessionStore.getState().patchUser(updated);
|
||||||
setMenuOpen(false);
|
setProfileOpen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,14 +196,29 @@ export function UserPanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void navigator.clipboard?.writeText(currentUser.data.id);
|
void navigator.clipboard?.writeText(currentUser.data.id);
|
||||||
|
setProfileOpen(false);
|
||||||
}, [currentUser.data]);
|
}, [currentUser.data]);
|
||||||
|
|
||||||
const toggleMenu = useCallback(() => setMenuOpen((value) => !value), []);
|
const toggleProfile = useCallback(() => {
|
||||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
setOpenControl(null);
|
||||||
|
setProfileOpen((value) => !value);
|
||||||
|
}, []);
|
||||||
|
const closeProfile = useCallback(() => setProfileOpen(false), []);
|
||||||
|
|
||||||
const goTo = (path: string) => (): void => {
|
/** Открыть поповер устройства: мини-профиль при этом закрывается. */
|
||||||
setMenuOpen(false);
|
const toggleControl = useCallback((control: 'mic' | 'camera' | 'headphones') => {
|
||||||
void navigate(path);
|
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;
|
const error = changeStatus.error ?? signOut.error;
|
||||||
@@ -90,19 +227,27 @@ export function UserPanel() {
|
|||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
aria-label={t('user.panelLabel')}
|
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
|
<button
|
||||||
ref={triggerRef}
|
ref={profileRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={profileOpen}
|
||||||
aria-label={t('user.menu.open')}
|
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"
|
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="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium" title={displayName}>
|
<span className="block truncate text-sm font-medium" title={displayName}>
|
||||||
{displayName}
|
{displayName}
|
||||||
@@ -118,37 +263,109 @@ export function UserPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<PanelControl
|
||||||
type="button"
|
glyph="🎤"
|
||||||
aria-label={t('user.copyId')}
|
enabled={micEnabled}
|
||||||
title={t('user.copyId')}
|
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
|
||||||
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"
|
menuLabel={t('user.voice.micSettings')}
|
||||||
onClick={onCopyId}
|
onToggle={toggleMic}
|
||||||
|
open={openControl === 'mic'}
|
||||||
|
onOpenMenu={() => toggleControl('mic')}
|
||||||
|
onCloseMenu={() => setOpenControl(null)}
|
||||||
>
|
>
|
||||||
#
|
<VoiceDeviceList
|
||||||
</button>
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
aria-label={t('user.settings')}
|
aria-label={t('user.settings')}
|
||||||
title={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"
|
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={toggleMenu}
|
onClick={() => {
|
||||||
|
setOpenControl(null);
|
||||||
|
goToSettings({ section: 'profile', tab: 'main' });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
⚙
|
<span aria-hidden="true">⚙</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
|
<Menu
|
||||||
<MenuGroupLabel>{displayName}</MenuGroupLabel>
|
open={profileOpen}
|
||||||
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
|
label={t('user.menu.label')}
|
||||||
{t('user.menu.account')}
|
onClose={closeProfile}
|
||||||
</MenuItem>
|
anchorRef={profileRef}
|
||||||
<MenuItem icon="🗄" onClick={goTo('/settings/servers')}>
|
className="w-64"
|
||||||
{t('user.menu.guilds')}
|
>
|
||||||
</MenuItem>
|
{/* Мини-профиль: аватар, имя, логин и «О себе». */}
|
||||||
<MenuSeparator />
|
<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')}>
|
<div role="group" aria-label={t('user.menu.status')}>
|
||||||
<MenuGroupLabel>{t('user.menu.status')}</MenuGroupLabel>
|
<MenuGroupLabel>{t('user.menu.status')}</MenuGroupLabel>
|
||||||
{selectableStatuses.map((value) => (
|
{selectableStatuses.map((value) => (
|
||||||
@@ -156,6 +373,12 @@ export function UserPanel() {
|
|||||||
key={value}
|
key={value}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
selected={value === status}
|
selected={value === status}
|
||||||
|
alwaysShowIcon
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2.5 w-2.5 rounded-full ${statusDotClass[value]}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
disabled={changeStatus.isPending}
|
disabled={changeStatus.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
changeStatus.reset();
|
changeStatus.reset();
|
||||||
@@ -166,6 +389,17 @@ export function UserPanel() {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 />
|
<MenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
@@ -181,10 +415,26 @@ export function UserPanel() {
|
|||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{errorText === null ? null : (
|
{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}
|
{errorText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Окно настроек живёт здесь же: панель — единственная точка входа. */}
|
||||||
|
<SettingsModal />
|
||||||
</footer>
|
</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 { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
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 {
|
interface GuildNicknameSectionProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
/** id текущего пользователя: сервер разрешает менять ник самому себе. */
|
/** id текущего пользователя: сервер разрешает менять ник самому себе. */
|
||||||
@@ -18,6 +28,8 @@ interface GuildNicknameSectionProps {
|
|||||||
currentNickname: string;
|
currentNickname: string;
|
||||||
/** Участники сервера (для подсказки, если ник пришёл не из снапшота). */
|
/** Участники сервера (для подсказки, если ник пришёл не из снапшота). */
|
||||||
members: GuildMember[] | undefined;
|
members: GuildMember[] | undefined;
|
||||||
|
/** Кнопка «Применить ко всем серверам» (только во вкладке профиля сервера). */
|
||||||
|
applyAll?: ApplyAllNickname;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** «Мой профиль на сервере»: свой никнейм, пустое значение его сбрасывает. */
|
/** «Мой профиль на сервере»: свой никнейм, пустое значение его сбрасывает. */
|
||||||
@@ -26,6 +38,7 @@ export function GuildNicknameSection({
|
|||||||
userId,
|
userId,
|
||||||
currentNickname,
|
currentNickname,
|
||||||
members,
|
members,
|
||||||
|
applyAll,
|
||||||
}: GuildNicknameSectionProps) {
|
}: GuildNicknameSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -92,6 +105,47 @@ export function GuildNicknameSection({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { userStatuses, type User } from '@/api/types';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
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 { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||||
|
import { formatBytes } from '@/lib/format';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones';
|
||||||
import { useSessionStore } from '@/stores/session';
|
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` при монтировании,
|
* Форма профиля. Состояние инициализируется из `user` при монтировании,
|
||||||
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
|
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
|
||||||
* синхронизация через эффект не нужна.
|
* синхронизация через эффект не нужна.
|
||||||
*/
|
*/
|
||||||
function ProfileForm({ user }: { user: User }) {
|
export function ProfileForm({ user }: { user: User }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru';
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState(user.display_name);
|
const [displayName, setDisplayName] = useState(user.display_name);
|
||||||
const [bio, setBio] = useState(user.bio);
|
const [bio, setBio] = useState(user.bio);
|
||||||
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
||||||
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
||||||
const [status, setStatus] = useState<string>(user.status);
|
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 [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({
|
const save = useMutation({
|
||||||
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
applyUser(updated);
|
||||||
useSessionStore.getState().patchUser(updated);
|
|
||||||
setSaved(true);
|
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 => {
|
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
@@ -51,100 +163,188 @@ function ProfileForm({ user }: { user: User }) {
|
|||||||
custom_status: customStatus.trim(),
|
custom_status: customStatus.trim(),
|
||||||
custom_status_emoji: customEmoji.trim(),
|
custom_status_emoji: customEmoji.trim(),
|
||||||
status,
|
status,
|
||||||
locale,
|
locale: language,
|
||||||
|
timezone: tz,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const avatarName = displayName === '' ? user.username : displayName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="flex flex-col gap-5">
|
||||||
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
|
<Card>
|
||||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
|
<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>
|
<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]">
|
|
||||||
<Field
|
<Field
|
||||||
label={t('settings.profile.customStatus')}
|
label={t('settings.profile.displayName')}
|
||||||
placeholder={t('settings.profile.customStatusPlaceholder')}
|
name="display_name"
|
||||||
name="custom_status"
|
value={displayName}
|
||||||
maxLength={128}
|
maxLength={64}
|
||||||
value={customStatus}
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
onChange={(event) => setCustomStatus(event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<Field
|
<TextAreaField
|
||||||
label={t('settings.profile.customStatusEmoji')}
|
label={t('settings.profile.bio')}
|
||||||
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
|
placeholder={t('settings.profile.bioPlaceholder')}
|
||||||
name="custom_status_emoji"
|
name="bio"
|
||||||
maxLength={8}
|
rows={4}
|
||||||
value={customEmoji}
|
maxLength={512}
|
||||||
onChange={(event) => setCustomEmoji(event.target.value)}
|
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>
|
</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">
|
{avatarError === null ? null : (
|
||||||
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
<p role="alert" className="mt-3 text-sm text-danger">
|
||||||
<dt>{t('settings.profile.username')}</dt>
|
{avatarError}
|
||||||
<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>
|
</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">
|
<SettingsSection
|
||||||
<Button type="submit" disabled={save.isPending}>
|
title={t('settings.profile.decorations.title')}
|
||||||
{t(save.isPending ? 'common.saving' : 'common.save')}
|
description={t('settings.profile.decorations.hint')}
|
||||||
</Button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</form>
|
<p className="mt-3 text-sm text-fg-muted">{t('settings.profile.decorations.stub')}</p>
|
||||||
</Card>
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||||
import { Card } from '@/components/ui/primitives';
|
import { Card } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { canManageGuildSettings } from '@/lib/identity';
|
import { canManageGuildSettings } from '@/lib/identity';
|
||||||
@@ -20,21 +26,21 @@ import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
|||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настройки пользовательских серверов (`/settings/servers/:guildId`).
|
* Вкладка «Профиль → Сервер»: мой профиль на конкретном сервере и (для
|
||||||
|
* владельца и прав управления) администрирование сервера.
|
||||||
*
|
*
|
||||||
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
||||||
* из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится
|
* из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же
|
||||||
* в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает.
|
* сбрасывает «Опасная зона» после выхода с сервера.
|
||||||
*/
|
*/
|
||||||
export default function ServersSettingsPage() {
|
export function ServersSettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const queryClient = useQueryClient();
|
||||||
const params = useParams<{ guildId?: string }>();
|
|
||||||
const routeGuildId = params.guildId;
|
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
const sessionGuilds = useSessionStore((state) => state.guilds);
|
const sessionGuilds = useSessionStore((state) => state.guilds);
|
||||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||||
|
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||||
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
|
|
||||||
const myGuilds = useQuery({
|
const myGuilds = useQuery({
|
||||||
@@ -58,25 +64,27 @@ export default function ServersSettingsPage() {
|
|||||||
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
|
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
|
||||||
[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(() => {
|
useEffect(() => {
|
||||||
if (activeId !== null) {
|
if (active !== null || guilds.length === 0) {
|
||||||
selectSettingsGuild(activeId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (routeGuildId === undefined && guilds.length > 0) {
|
const first = guilds[0];
|
||||||
const first = guilds[0];
|
if (first !== undefined) {
|
||||||
if (first !== undefined) {
|
selectSettingsGuild(first.id);
|
||||||
void navigate(`/settings/servers/${first.id}`, { replace: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [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({
|
const detail = useQuery({
|
||||||
queryKey: guildQueryKey(activeId ?? ''),
|
queryKey: guildQueryKey(activeId ?? ''),
|
||||||
@@ -92,6 +100,35 @@ export default function ServersSettingsPage() {
|
|||||||
retry: 0,
|
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 guild = detail.data;
|
||||||
const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null;
|
const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null;
|
||||||
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
|
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
|
||||||
@@ -102,76 +139,97 @@ export default function ServersSettingsPage() {
|
|||||||
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
||||||
return 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 ?? '';
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<Card className="p-3">
|
<div className="relative">
|
||||||
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
<button
|
||||||
{t('settings.servers.pickerLabel')}
|
ref={pickerRef}
|
||||||
</h2>
|
type="button"
|
||||||
{guilds.length === 0 ? (
|
aria-haspopup="menu"
|
||||||
myGuilds.isPending ? (
|
aria-expanded={pickerOpen}
|
||||||
<SkeletonLines className="mt-3" rows={1} />
|
aria-label={t('settings.servers.selectLabel')}
|
||||||
) : (
|
onClick={() => setPickerOpen((value) => !value)}
|
||||||
<p className="mt-2 px-1 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
|
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 : (
|
||||||
<nav
|
<Avatar
|
||||||
aria-label={t('settings.servers.pickerLabel')}
|
name={active.name}
|
||||||
className="gl-stagger mt-2 flex flex-wrap gap-1.5"
|
seed={active.id}
|
||||||
data-testid="guild-picker"
|
fileId={active.icon_file_id}
|
||||||
>
|
size="sm"
|
||||||
{guilds.map((guildItem) => {
|
shape="square"
|
||||||
const selected = guildItem.id === activeId;
|
/>
|
||||||
return (
|
)}
|
||||||
<NavLink
|
<span className="min-w-0 flex-1 truncate font-medium">
|
||||||
key={guildItem.id}
|
{active?.name ?? t('settings.servers.selectLabel')}
|
||||||
to={`/settings/servers/${guildItem.id}`}
|
</span>
|
||||||
aria-current={selected ? 'page' : undefined}
|
<span aria-hidden="true" className="text-fg-muted">
|
||||||
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)] ${
|
</span>
|
||||||
selected
|
</button>
|
||||||
? '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>
|
|
||||||
|
|
||||||
{guilds.length === 0 ? (
|
<Menu
|
||||||
myGuilds.isPending ? (
|
open={pickerOpen}
|
||||||
<Card>
|
label={t('settings.servers.pickerLabel')}
|
||||||
<SkeletonLines rows={4} />
|
anchorRef={pickerRef}
|
||||||
</Card>
|
placement="bottom"
|
||||||
) : (
|
onClose={() => setPickerOpen(false)}
|
||||||
<SettingsSection
|
>
|
||||||
title={t('settings.servers.emptyTitle')}
|
<MenuGroupLabel>{t('settings.servers.pickerLabel')}</MenuGroupLabel>
|
||||||
description={t('settings.servers.emptyHint')}
|
{guilds.map((item) => (
|
||||||
>
|
<MenuItem
|
||||||
<div className="mt-4">
|
key={item.id}
|
||||||
<NavLink className="text-sm text-accent underline" to="/app">
|
role="menuitemradio"
|
||||||
{t('settings.back')}
|
selected={item.id === activeId}
|
||||||
</NavLink>
|
alwaysShowIcon
|
||||||
</div>
|
icon={
|
||||||
</SettingsSection>
|
<Avatar
|
||||||
)
|
name={item.name}
|
||||||
) : activeId === null ? (
|
seed={item.id}
|
||||||
// Сервер из URL не найден среди доступных пользователю.
|
fileId={item.icon_file_id}
|
||||||
|
size="sm"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
selectSettingsGuild(item.id);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeId === null ? (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -185,13 +243,19 @@ export default function ServersSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
|
||||||
<GuildNicknameSection
|
<GuildNicknameSection
|
||||||
guildId={guild.id}
|
guildId={guild.id}
|
||||||
userId={currentUser.data?.id}
|
userId={userId}
|
||||||
currentNickname={currentNickname}
|
currentNickname={currentNickname}
|
||||||
members={members.data}
|
members={members.data}
|
||||||
|
applyAll={{
|
||||||
|
pending: applyAll.isPending,
|
||||||
|
progress,
|
||||||
|
result: applyResult,
|
||||||
|
run: onApplyAll,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||||
<GuildMembersSection
|
<GuildMembersSection
|
||||||
members={members.data}
|
members={members.data}
|
||||||
isPending={members.isPending}
|
isPending={members.isPending}
|
||||||
@@ -203,7 +267,7 @@ export default function ServersSettingsPage() {
|
|||||||
key={`${guild.id}-danger`}
|
key={`${guild.id}-danger`}
|
||||||
guildId={guild.id}
|
guildId={guild.id}
|
||||||
guildName={guild.name}
|
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}
|
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -211,3 +275,5 @@ export default function ServersSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ServersSettingsPage;
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router';
|
import { Navigate, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { SettingsSwitcher } from '@/pages/settings/SettingsSwitcher';
|
import { parseSettingsPath } from '@/pages/settings/settingsRoute';
|
||||||
import { SettingsSuspense } from '@/pages/settings/SettingsSection';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Каркас настроек: две соседние кнопки-раздела («Настройки сервера» и
|
* Старые страницы настроек (`/settings/*`) больше не отдельные экраны:
|
||||||
* «Мои серверы») и содержимое активного раздела. Переключение разделов —
|
* настройки открываются модальным окном поверх приложения (см. `SettingsModal`).
|
||||||
* обычная навигация react-router, без перезагрузки страницы.
|
*
|
||||||
|
* Маршрут остаётся, чтобы не ломать закладки: определяем нужный пункт по пути,
|
||||||
|
* открываем окно и заменяем адрес на `/app`.
|
||||||
*/
|
*/
|
||||||
export default function SettingsLayout() {
|
export default function SettingsLayout() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const openSettings = useUiStore((state) => state.openSettings);
|
||||||
|
const target = useMemo(() => parseSettingsPath(location.pathname), [location.pathname]);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-5 px-4 py-8">
|
openSettings(target);
|
||||||
<header className="flex items-center justify-between gap-4">
|
}, [openSettings, target]);
|
||||||
<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>
|
|
||||||
|
|
||||||
<SettingsSwitcher />
|
return <Navigate to="/app" replace />;
|
||||||
|
|
||||||
{/* Ключ по пути: при смене вкладки/раздела контент появляется с анимацией. */}
|
|
||||||
<div key={location.pathname} className="gl-page flex flex-col gap-5">
|
|
||||||
<SettingsSuspense>
|
|
||||||
<Outlet />
|
|
||||||
</SettingsSuspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -517,6 +517,61 @@ describe('чат комнаты: реакции и ответы', () => {
|
|||||||
expect(screen.queryByText('Ответ Аля')).toBeNull();
|
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('чат комнаты: права и действия', () => {
|
describe('чат комнаты: права и действия', () => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { setQueryClient } from '@/lib/queryClient';
|
|||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import { useMessagesStore } from '@/stores/messages';
|
import { useMessagesStore } from '@/stores/messages';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
import { useVoiceStore } from '@/stores/voice';
|
||||||
import type { User } from '@/api/types';
|
import type { User } from '@/api/types';
|
||||||
|
|
||||||
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
/** Ответ-заглушка с JSON-телом (как у настоящего API). */
|
||||||
@@ -117,6 +119,13 @@ export function findRequest(
|
|||||||
export function resetStores(): void {
|
export function resetStores(): void {
|
||||||
useSessionStore.getState().reset();
|
useSessionStore.getState().reset();
|
||||||
useMessagesStore.getState().reset();
|
useMessagesStore.getState().reset();
|
||||||
|
useVoiceStore.getState().reset();
|
||||||
|
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
|
||||||
|
useUiStore.setState({
|
||||||
|
settingsOpen: false,
|
||||||
|
settingsSection: 'profile',
|
||||||
|
settingsProfileTab: 'main',
|
||||||
|
});
|
||||||
useGatewayStore.setState({
|
useGatewayStore.setState({
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
heartbeatIntervalMs: null,
|
heartbeatIntervalMs: null,
|
||||||
|
|||||||
+99
-18
@@ -25,14 +25,44 @@ const instanceOpen = {
|
|||||||
guild_count: 1,
|
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('страница входа', () => {
|
describe('страница входа', () => {
|
||||||
it('успешный вход переводит в /app', async () => {
|
it('поле кода 2FA показано сразу и принимает резервные коды', async () => {
|
||||||
const user = makeUser();
|
installFetch([{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }]);
|
||||||
const fetchMock = installFetch([
|
|
||||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
renderApp('/login');
|
||||||
{ match: '/api/v1/auth/login', method: 'POST', response: () => json({ user }) },
|
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
const field = await screen.findByLabelText(totpLabel);
|
||||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
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 }) },
|
{ 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();
|
const user = makeUser();
|
||||||
let loginAttempts = 0;
|
let loginAttempts = 0;
|
||||||
const fetchMock = installFetch([
|
const fetchMock = installFetch([
|
||||||
@@ -80,24 +162,23 @@ describe('страница входа', () => {
|
|||||||
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
await visitor.type(screen.getByLabelText('Пароль'), 'super-secret-1');
|
||||||
await visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
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 visitor.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(router.state.location.pathname).toBe('/app/empty');
|
expect(router.state.location.pathname).toBe('/app/empty');
|
||||||
});
|
});
|
||||||
|
expect(loginBodies(fetchMock)).toHaveLength(2);
|
||||||
const logins = recordedRequests(fetchMock).filter(
|
expect(loginBodies(fetchMock)[1]?.body).toEqual({
|
||||||
(request) => request.url.includes('/auth/login') && request.method === 'POST',
|
|
||||||
);
|
|
||||||
expect(logins).toHaveLength(2);
|
|
||||||
expect(logins[1]?.body).toEqual({
|
|
||||||
email: 'alice@example.com',
|
email: 'alice@example.com',
|
||||||
password: 'super-secret-1',
|
password: 'super-secret-1',
|
||||||
totp_code: '123456',
|
totp_code: 'a8eh-pshp-t8st',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -91,6 +91,9 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
|||||||
return [
|
return [
|
||||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
{ 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-1/roles', response: () => json({ roles }) },
|
||||||
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
|
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
|
||||||
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
|
||||||
@@ -107,61 +110,70 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Первая кнопка «Сохранить» на странице (секция «Общее»). */
|
/** Кнопка «Сохранить» внутри формы конкретного поля. */
|
||||||
function saveButton(index = 0): HTMLElement {
|
function saveNear(label: string): HTMLElement {
|
||||||
const buttons = screen.getAllByRole('button', { name: 'Сохранить' });
|
const field = screen.getByLabelText(label);
|
||||||
const button = buttons[index];
|
const form = field.closest('form');
|
||||||
if (button === undefined) {
|
if (form === null) {
|
||||||
throw new Error('кнопка сохранения не найдена');
|
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('настройки серверов', () => {
|
describe('настройки серверов', () => {
|
||||||
it('показывает список серверов с иконкой и именем и выбирает первый', async () => {
|
it('показывает выпадающий список серверов с иконкой и именем и выбирает первый', async () => {
|
||||||
installFetch(serversRoutes());
|
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('Альфа');
|
||||||
|
|
||||||
// Первый сервер подсвечен. Список запрашиваем заново: после выбора
|
const picker = await openGuildPicker();
|
||||||
// страница перерисовывается, и прежний узел может быть уже отсоединён.
|
const first = within(picker).getByRole('menuitemradio', { name: 'Альфа' });
|
||||||
await waitFor(() => {
|
expect(first).toHaveAttribute('aria-checked', 'true');
|
||||||
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: 'Открыть настройки сервера Альфа' });
|
|
||||||
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
|
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
|
||||||
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
|
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
|
||||||
expect(
|
expect(within(picker).getByRole('menuitemradio', { name: 'Бета' })).toBeVisible();
|
||||||
within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('переключение сервера меняет отображаемые настройки и права', async () => {
|
it('переключение сервера меняет отображаемые настройки и права', async () => {
|
||||||
installFetch(serversRoutes());
|
installFetch(serversRoutes());
|
||||||
|
|
||||||
const { router } = renderApp('/settings/servers/g-1');
|
await openServerSettings();
|
||||||
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
|
||||||
// Владелец может редактировать.
|
// Владелец может редактировать.
|
||||||
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
|
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
|
||||||
|
|
||||||
await userEvent.click(
|
const picker = await openGuildPicker();
|
||||||
await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }),
|
await userEvent.click(within(picker).getByRole('menuitemradio', { name: 'Бета' }));
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(router.state.location.pathname).toBe('/settings/servers/g-2');
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
|
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
|
||||||
});
|
});
|
||||||
@@ -181,7 +193,7 @@ describe('настройки серверов', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
renderApp('/settings/servers/g-1');
|
await openServerSettings();
|
||||||
|
|
||||||
const name = await screen.findByLabelText('Название сервера');
|
const name = await screen.findByLabelText('Название сервера');
|
||||||
await userEvent.clear(name);
|
await userEvent.clear(name);
|
||||||
@@ -189,7 +201,7 @@ describe('настройки серверов', () => {
|
|||||||
const description = screen.getByLabelText('Описание');
|
const description = screen.getByLabelText('Описание');
|
||||||
await userEvent.clear(description);
|
await userEvent.clear(description);
|
||||||
await userEvent.type(description, 'Новое');
|
await userEvent.type(description, 'Новое');
|
||||||
await userEvent.click(saveButton());
|
await userEvent.click(saveNear('Название сервера'));
|
||||||
|
|
||||||
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
|
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
|
||||||
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({
|
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('Никнейм');
|
const nickname = await screen.findByLabelText('Никнейм');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -218,8 +230,7 @@ describe('настройки серверов', () => {
|
|||||||
|
|
||||||
await userEvent.clear(nickname);
|
await userEvent.clear(nickname);
|
||||||
await userEvent.type(nickname, 'Алиса');
|
await userEvent.type(nickname, 'Алиса');
|
||||||
// Вторая кнопка «Сохранить» — секция «Мой профиль на сервере».
|
await userEvent.click(saveNear('Никнейм'));
|
||||||
await userEvent.click(saveButton(1));
|
|
||||||
|
|
||||||
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
|
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
|
||||||
const patch = recordedRequests(fetchMock).find(
|
const patch = recordedRequests(fetchMock).find(
|
||||||
@@ -228,10 +239,47 @@ describe('настройки серверов', () => {
|
|||||||
expect(patch?.body).toEqual({ nickname: 'Алиса' });
|
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 () => {
|
it('показывает участников и роли сервера', async () => {
|
||||||
installFetch(serversRoutes());
|
installFetch(serversRoutes());
|
||||||
|
|
||||||
renderApp('/settings/servers/g-1');
|
await openServerSettings();
|
||||||
|
|
||||||
const list = await screen.findByTestId('guild-members');
|
const list = await screen.findByTestId('guild-members');
|
||||||
expect(within(list).getByText('Аля')).toBeVisible();
|
expect(within(list).getByText('Аля')).toBeVisible();
|
||||||
@@ -246,7 +294,7 @@ describe('настройки серверов', () => {
|
|||||||
it('владелец не может покинуть сервер, но может его удалить', async () => {
|
it('владелец не может покинуть сервер, но может его удалить', async () => {
|
||||||
installFetch(serversRoutes());
|
installFetch(serversRoutes());
|
||||||
|
|
||||||
renderApp('/settings/servers/g-1');
|
await openServerSettings();
|
||||||
|
|
||||||
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
|
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
|
||||||
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
||||||
@@ -256,7 +304,7 @@ describe('настройки серверов', () => {
|
|||||||
it('участник без прав не видит удаление сервера', async () => {
|
it('участник без прав не видит удаление сервера', async () => {
|
||||||
installFetch(serversRoutes());
|
installFetch(serversRoutes());
|
||||||
|
|
||||||
renderApp('/settings/servers/g-2');
|
await openServerSettings('/app/empty', 'Бета');
|
||||||
|
|
||||||
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
|
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
|
||||||
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
|
||||||
@@ -265,15 +313,32 @@ describe('настройки серверов', () => {
|
|||||||
it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => {
|
it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => {
|
||||||
installFetch(serversRoutes());
|
installFetch(serversRoutes());
|
||||||
|
|
||||||
renderApp('/settings/servers/g-2');
|
await openServerSettings('/app/empty', 'Бета');
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
|
||||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' }));
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
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: 'Покинуть сервер' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { 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: 'Удалить сервер' }));
|
await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' }));
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
|
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
|
||||||
@@ -319,11 +384,13 @@ describe('настройки серверов', () => {
|
|||||||
installFetch([
|
installFetch([
|
||||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
{ 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();
|
const content = screen.getByTestId('settings-content');
|
||||||
expect(screen.getByText('У вас пока нет серверов.')).toBeVisible();
|
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
|
||||||
|
expect(within(content).getByText('У вас пока нет серверов.')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+319
-59
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
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 userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
makeUser,
|
makeUser,
|
||||||
recordedRequests,
|
recordedRequests,
|
||||||
renderApp,
|
renderApp,
|
||||||
|
requestUrl,
|
||||||
|
type FetchRoute,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
const user = makeUser();
|
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('настройки: безопасность', () => {
|
describe('настройки: безопасность', () => {
|
||||||
it('включение 2FA показывает секрет и коды восстановления', async () => {
|
it('включение 2FA показывает секрет и коды восстановления', async () => {
|
||||||
const fetchMock = installFetch([
|
const fetchMock = installFetch([
|
||||||
@@ -105,7 +422,7 @@ describe('настройки: безопасность', () => {
|
|||||||
|
|
||||||
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
|
it('смена пароля требует step-up и повторяется после подтверждения', async () => {
|
||||||
let passwordAttempts = 0;
|
let passwordAttempts = 0;
|
||||||
const fetchMock = installFetch([
|
installFetch([
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
|
{ match: '/api/v1/auth/sessions', response: () => json({ sessions }) },
|
||||||
{
|
{
|
||||||
@@ -135,11 +452,6 @@ describe('настройки: безопасность', () => {
|
|||||||
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
|
await visitor.click(screen.getByRole('button', { name: 'Подтвердить' }));
|
||||||
|
|
||||||
expect(await screen.findByText('Пароль изменён.')).toBeVisible();
|
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 () => {
|
it('профиль сохраняется через PATCH /users/@me', async () => {
|
||||||
@@ -165,58 +477,6 @@ describe('настройки: безопасность', () => {
|
|||||||
expect(patchCall?.body).toMatchObject({ display_name: 'Алиса' });
|
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 () => {
|
it('администратор видит данные инстанса', async () => {
|
||||||
const admin = makeUser({ is_instance_admin: true });
|
const admin = makeUser({ is_instance_admin: true });
|
||||||
installFetch([
|
installFetch([
|
||||||
|
|||||||
@@ -115,13 +115,19 @@ describe('основной экран', () => {
|
|||||||
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
|
expect(screen.getByLabelText('Открыть комнату Голосовая')).toBeVisible();
|
||||||
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
|
expect(screen.getByRole('heading', { name: 'Текстовые' })).toBeVisible();
|
||||||
|
|
||||||
// Панель пользователя.
|
// Панель пользователя: имя, статус, кнопки устройств и шестерёнка настроек.
|
||||||
const panel = screen.getByLabelText('Панель пользователя');
|
const panel = screen.getByLabelText('Панель пользователя');
|
||||||
expect(within(panel).getByText('Alice')).toBeVisible();
|
expect(within(panel).getByText('Alice')).toBeVisible();
|
||||||
expect(within(panel).getByLabelText('Настройки пользователя')).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(within(panel).getByLabelText('Открыть меню пользователя')).toBeVisible();
|
||||||
|
|
||||||
|
// «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне».
|
||||||
|
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
||||||
|
|
||||||
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
||||||
expect(await screen.findByTestId('composer')).toBeVisible();
|
expect(await screen.findByTestId('composer')).toBeVisible();
|
||||||
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
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 { default: userEvent } = await import('@testing-library/user-event');
|
||||||
const visitor = userEvent.setup();
|
const visitor = userEvent.setup();
|
||||||
installFetch([
|
installFetch([
|
||||||
@@ -184,9 +190,14 @@ describe('основной экран', () => {
|
|||||||
const gear = await screen.findByLabelText('Настройки сервера');
|
const gear = await screen.findByLabelText('Настройки сервера');
|
||||||
await visitor.click(gear);
|
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 () => {
|
it('обычный участник не видит шестерёнку настроек сервера', async () => {
|
||||||
|
|||||||
+53
-25
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
|
import { findRequest, installFetch, json, makeUser, recordedRequests, renderApp } from './helpers';
|
||||||
import type { FetchRoute } from './helpers';
|
import type { FetchRoute } from './helpers';
|
||||||
|
|
||||||
const user = makeUser();
|
const user = makeUser({ bio: 'Пишу код и тесты' });
|
||||||
|
|
||||||
/** Базовые ответы для оболочки приложения без серверов. */
|
/** Базовые ответы для оболочки приложения без серверов. */
|
||||||
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||||
@@ -17,7 +17,7 @@ function baseRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Открывает нижне-левое меню пользователя кликом по аватару/имени. */
|
/** Открывает мини-профиль кликом по аватару/имени. */
|
||||||
async function openMenu() {
|
async function openMenu() {
|
||||||
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
||||||
await userEvent.click(trigger);
|
await userEvent.click(trigger);
|
||||||
@@ -25,55 +25,82 @@ async function openMenu() {
|
|||||||
return { trigger, menu };
|
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('меню пользователя', () => {
|
describe('меню пользователя', () => {
|
||||||
it('открывается по клику на аватар и содержит все пункты', async () => {
|
it('мини-профиль открывается по клику на аватар и показывает данные и статусы', async () => {
|
||||||
installFetch(baseRoutes());
|
installFetch(baseRoutes());
|
||||||
|
|
||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
const { menu } = await openMenu();
|
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(
|
expect(
|
||||||
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
|
within(menu).getByRole('menuitemradio', { name: 'В сети', checked: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(within(menu).getByRole('menuitemradio', { name: 'Не беспокоить' })).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();
|
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('шестерёнка открывает то же меню', async () => {
|
it('шестерёнка открывает окно настроек', async () => {
|
||||||
installFetch(baseRoutes());
|
installFetch(baseRoutes());
|
||||||
|
|
||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
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());
|
installFetch(baseRoutes());
|
||||||
|
|
||||||
const { router } = renderApp('/app/empty');
|
const { router } = renderApp('/app/empty');
|
||||||
const { menu } = await openMenu();
|
const dialog = await openSettings('Настройки аккаунта');
|
||||||
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(within(dialog).getByRole('tab', { name: 'Основной' })).toHaveAttribute(
|
||||||
expect(router.state.location.pathname).toBe('/settings/account/profile');
|
'aria-selected',
|
||||||
});
|
'true',
|
||||||
expect(await screen.findByRole('heading', { name: 'Профиль' })).toBeVisible();
|
);
|
||||||
|
expect(within(dialog).getByRole('heading', { name: 'Профиль' })).toBeVisible();
|
||||||
|
// Из приложения никуда не уходим: настройки — модальное окно.
|
||||||
|
expect(router.state.location.pathname).toBe('/app/empty');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('пункт «Настройки серверов» ведёт в /settings/servers', async () => {
|
it('пункт «Настройки серверов» открывает окно на «Профиль → Сервер»', async () => {
|
||||||
installFetch(baseRoutes());
|
installFetch(baseRoutes());
|
||||||
|
|
||||||
const { router } = renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
const { menu } = await openMenu();
|
const dialog = await openSettings('Настройки серверов');
|
||||||
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
||||||
expect(router.state.location.pathname).toBe('/settings/servers');
|
'aria-selected',
|
||||||
});
|
'true',
|
||||||
expect(await screen.findByText('У вас пока нет серверов.')).toBeVisible();
|
);
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId('settings-content')).getByText('Пока нет ни одного сервера'),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
|
it('смена статуса уходит в PATCH /users/@me и отмечает текущий статус', async () => {
|
||||||
@@ -159,15 +186,16 @@ describe('меню пользователя', () => {
|
|||||||
trigger.focus();
|
trigger.focus();
|
||||||
await userEvent.keyboard('{Enter}');
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
const first = await screen.findByRole('menuitem', { name: 'Настройки аккаунта' });
|
// Первый пункт мини-профиля — выбор статуса.
|
||||||
|
const first = await screen.findByRole('menuitemradio', { name: 'В сети' });
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(first).toHaveFocus();
|
expect(first).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
expect(screen.getByRole('menuitem', { name: 'Настройки серверов' })).toHaveFocus();
|
expect(screen.getByRole('menuitemradio', { name: 'Неактивен' })).toHaveFocus();
|
||||||
|
|
||||||
await userEvent.keyboard('{ArrowUp}');
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
expect(screen.getByRole('menuitem', { name: 'Настройки аккаунта' })).toHaveFocus();
|
expect(screen.getByRole('menuitemradio', { name: 'В сети' })).toHaveFocus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user