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
+68
View File
@@ -0,0 +1,68 @@
/**
* Часовые пояса для настроек профиля и раздела «Язык и регион».
*
* Значение — идентификатор IANA, как его принимает сервер; подпись собирается
* из названия зоны и текущего смещения (смещение считает `Intl`, поэтому
* переход на летнее время учитывается автоматически).
*/
/** Пояс по умолчанию: Москва (МСК). */
export const DEFAULT_TIMEZONE = 'Europe/Moscow';
export const timezoneOptions = [
'Europe/Kaliningrad',
'Europe/Moscow',
'Europe/Samara',
'Asia/Yekaterinburg',
'Asia/Almaty',
'Asia/Tashkent',
'Asia/Novosibirsk',
'Asia/Krasnoyarsk',
'Asia/Irkutsk',
'Asia/Yakutsk',
'Asia/Vladivostok',
'Asia/Magadan',
'Asia/Kamchatka',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Asia/Tbilisi',
'Asia/Yerevan',
'Asia/Dubai',
'Asia/Tokyo',
'Asia/Shanghai',
'America/New_York',
'America/Los_Angeles',
'UTC',
] as const;
/** Смещение вида `UTC+03:00` (для зоны, которой нет в ICU, — пустая строка). */
export function timezoneOffset(timeZone: string, locale = 'ru'): string {
try {
const parts = new Intl.DateTimeFormat(locale, {
timeZone,
timeZoneName: 'shortOffset',
}).formatToParts(new Date());
return parts.find((part) => part.type === 'timeZoneName')?.value ?? '';
} catch {
return '';
}
}
/** Подпись пункта списка: `Europe/Moscow (UTC+03:00)`. */
export function timezoneLabel(timeZone: string, locale = 'ru'): string {
const offset = timezoneOffset(timeZone, locale);
return offset === '' ? timeZone : `${timeZone} (${offset})`;
}
/**
* Все зоны списка плюс значение из профиля, если его там ещё нет: селект не
* должен «терять» часовой пояс, выставленный с другого клиента.
*/
export function timezoneChoices(value: string): string[] {
const options: string[] = [...timezoneOptions];
if (value !== '' && !options.includes(value)) {
options.unshift(value);
}
return options;
}
+28 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import type { VoiceDeviceKind } from '@/stores/voice';
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
export interface MediaDevice {
@@ -30,13 +30,15 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
useEffect(() => {
if (!supported) {
setDevices([]);
// API нет — список остаётся пустым, интерфейс покажет «недоступны».
return;
}
// Ссылку запоминаем: к моменту очистки эффекта API может быть уже недоступно.
const mediaDevices = navigator.mediaDevices;
let cancelled = false;
const load = (): void => {
void navigator.mediaDevices
void mediaDevices
.enumerateDevices()
.then((list) => {
if (cancelled) {
@@ -57,12 +59,33 @@ export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
load();
// Список меняется при подключении гарнитуры — обновляем его на лету.
navigator.mediaDevices.addEventListener?.('devicechange', load);
mediaDevices.addEventListener?.('devicechange', load);
return () => {
cancelled = true;
navigator.mediaDevices.removeEventListener?.('devicechange', load);
mediaDevices.removeEventListener?.('devicechange', load);
};
}, [kind, supported]);
return { devices, supported };
}
/**
* Выбранное устройство нужного вида из стора предпочтений.
* Одна обёртка на три вида — чтобы не дублировать селекторы в компонентах.
*/
export function useDeviceChoice(kind: VoiceDeviceKind): {
value: string;
select: (deviceId: string) => void;
} {
const value = useVoiceStore((state) => {
if (kind === 'audioinput') {
return state.micDeviceId;
}
if (kind === 'videoinput') {
return state.cameraDeviceId;
}
return state.outputDeviceId;
});
const selectDevice = useVoiceStore((state) => state.selectDevice);
return { value, select: (deviceId: string) => selectDevice(kind, deviceId) };
}