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

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

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+4 -2
View File
@@ -26,10 +26,12 @@ export interface MessageContentProps {
/**
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
* ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются.
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
* слова (`foo_bar_baz`).
*/
const INLINE_PATTERN =
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|(\*[^*\n]+\*)|((?<![\p{L}\p{N}_])_[^_\n]+_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
function splitLink(raw: string): { href: string; trailing: string } {
+7 -1
View File
@@ -168,6 +168,11 @@ interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */
selected?: boolean;
danger?: boolean;
/**
* Показывать иконку и у выбранного пункта (галочка не заменяет её).
* Нужно для цветных точек статуса: цвет важнее отметки выбора.
*/
alwaysShowIcon?: boolean;
}
/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */
@@ -175,6 +180,7 @@ export function MenuItem({
icon,
selected = false,
danger = false,
alwaysShowIcon = false,
className = '',
children,
...rest
@@ -192,7 +198,7 @@ export function MenuItem({
} ${className}`}
>
<span aria-hidden="true" className="w-4 shrink-0 text-center">
{selected ? '✓' : (icon ?? '')}
{selected && !alwaysShowIcon ? '✓' : (icon ?? '')}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</button>
+52 -8
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { motionDelay } from '@/lib/motion';
@@ -9,20 +10,53 @@ interface ModalProps {
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
/** Ширина панели: обычные окна — `md`, настройки — `xl`. */
size?: 'sm' | 'md' | 'lg' | 'xl';
/** Дополнительные классы панели (например, ограничение высоты). */
panelClassName?: string;
/** Дополнительные классы области содержимого (прокрутка длинных форм). */
bodyClassName?: string;
/**
* Рендерить окно в `document.body`. Нужно, когда модалка открывается из
* узкой колонки (панель пользователя): портал снимает вопросы к вложенным
* `overflow` и трансформациям родителей.
*/
portal?: boolean;
}
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
const EXIT_MS = 140;
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
} as const;
/** Открытые окна в порядке появления: последнее получает Escape. */
const openModals: symbol[] = [];
/**
* Простое модальное окно: закрывается по Escape и по клику на подложку,
* focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса.
* Модальное окно: закрывается по Escape, по клику на подложку и по крестику
* в правом верхнем углу. Escape обрабатывает только верхнее окно — вложенное
* подтверждение (например, «выйти с сервера») не закрывает родителя.
*
* Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`):
* при закрытии узел остаётся в DOM до конца обратной анимации, а при
* `prefers-reduced-motion` удаляется сразу.
*/
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
export function Modal({
open,
title,
onClose,
children,
footer,
size = 'md',
panelClassName = '',
bodyClassName = '',
portal = false,
}: ModalProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const [mounted, setMounted] = useState(open);
@@ -64,8 +98,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
if (!open) {
return;
}
// Стек открытых окон: Escape закрывает только верхнее.
const token = Symbol('modal');
openModals.push(token);
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
if (event.key === 'Escape' && openModals[openModals.length - 1] === token) {
onCloseRef.current();
}
};
@@ -77,6 +114,10 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
firstField?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
const index = openModals.indexOf(token);
if (index !== -1) {
openModals.splice(index, 1);
}
// Возвращаем фокус туда, откуда окно открыли.
previous?.focus();
};
@@ -86,8 +127,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
return null;
}
return (
const node = (
<div
data-testid="modal-backdrop"
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 ${
closing ? 'gl-backdrop-out' : 'gl-backdrop-in'
}`}
@@ -102,9 +144,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
role="dialog"
aria-modal="true"
aria-label={title}
className={`w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)] ${
className={`w-full ${sizes[size]} rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 shadow-[var(--shadow-3)] ${
closing ? 'gl-pop-out' : 'gl-pop-in'
}`}
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
@@ -117,9 +159,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
×
</button>
</div>
<div className="mt-4">{children}</div>
<div className={`mt-4 ${bodyClassName}`}>{children}</div>
{footer === undefined ? null : <div className="mt-5 flex justify-end gap-2">{footer}</div>}
</div>
</div>
);
return portal ? createPortal(node, document.body) : node;
}
+112
View File
@@ -0,0 +1,112 @@
import { useTranslation } from 'react-i18next';
import { useMediaDevices } from '@/lib/useMediaDevices';
import type { VoiceDeviceKind } from '@/stores/voice';
/**
* Список устройств ввода/вывода: используется и в поповерах панели
* пользователя, и в разделе настроек «Аудио-видео».
*
* Если API недоступно (старый браузер), показываем «Устройства недоступны»;
* пустой список без разрешения на доступ тоже честнее показать текстом.
*/
export function VoiceDeviceList({
kind,
label,
value,
onChange,
}: {
kind: VoiceDeviceKind;
label: string;
value: string;
onChange: (deviceId: string) => void;
}) {
const { t } = useTranslation();
const { devices, supported } = useMediaDevices(kind);
const optionClass = (selected: boolean): string =>
`w-full truncate rounded-[var(--radius-sm)] px-2 py-1 text-left text-sm transition-colors duration-[var(--duration-fast)] ${
selected ? 'bg-accent/15 text-fg' : 'text-fg-muted hover:bg-surface-3 hover:text-fg'
}`;
if (!supported) {
return (
<div>
<p className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">{label}</p>
<p className="px-1 py-1 text-xs text-fg-muted">{t('user.devices.unavailable')}</p>
</div>
);
}
return (
<div>
<p className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">{label}</p>
{devices.length === 0 ? (
<p className="px-1 py-1 text-xs text-fg-muted">{t('user.devices.empty')}</p>
) : (
<ul
role="listbox"
aria-label={label}
className="flex max-h-40 flex-col gap-0.5 overflow-y-auto"
>
<li>
<button
type="button"
role="option"
aria-selected={value === ''}
className={optionClass(value === '')}
onClick={() => onChange('')}
>
{t('user.devices.default')}
</button>
</li>
{devices.map((device, index) => (
<li key={device.deviceId}>
<button
type="button"
role="option"
aria-selected={value === device.deviceId}
className={optionClass(value === device.deviceId)}
onClick={() => onChange(device.deviceId)}
>
{device.label === ''
? t('user.devices.unnamed', { index: index + 1 })
: device.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}
/** Слайдер громкости 0–100: значение уходит в zustand-стор и localStorage. */
export function VolumeSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<label className="flex flex-col gap-1 px-1 pt-2">
<span className="flex items-center justify-between gap-2 text-xs text-fg-muted">
{label}
<span aria-hidden="true">{value}%</span>
</span>
<input
type="range"
min={0}
max={100}
step={1}
value={value}
aria-label={label}
className="w-full accent-[var(--color-accent)]"
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}