feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user