Files
glchat/web/src/lib/useMediaDevices.ts
T
grendervill 20eed227ca feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
2026-09-20 01:32:11 +03:00

92 lines
3.3 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
export interface MediaDevice {
deviceId: string;
/** Подпись может быть пустой: браузер скрывает её без разрешения. */
label: string;
}
export interface MediaDevicesResult {
devices: MediaDevice[];
/** Доступен ли `navigator.mediaDevices.enumerateDevices()`. */
supported: boolean;
}
/**
* Список устройств нужного вида через `navigator.mediaDevices`.
*
* Без разрешения на доступ браузер отдаёт устройства с пустыми подписями —
* их показываем как «Устройство N». Если API недоступно (старый браузер или
* тестовое окружение), возвращаем `supported: false`.
*/
export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
const supported =
typeof navigator !== 'undefined' &&
typeof navigator.mediaDevices?.enumerateDevices === 'function';
const [devices, setDevices] = useState<MediaDevice[]>([]);
useEffect(() => {
if (!supported) {
// API нет — список остаётся пустым, интерфейс покажет «недоступны».
return;
}
// Ссылку запоминаем: к моменту очистки эффекта API может быть уже недоступно.
const mediaDevices = navigator.mediaDevices;
let cancelled = false;
const load = (): void => {
void mediaDevices
.enumerateDevices()
.then((list) => {
if (cancelled) {
return;
}
setDevices(
list
.filter((device) => device.kind === kind)
.map((device) => ({ deviceId: device.deviceId, label: device.label })),
);
})
.catch(() => {
if (!cancelled) {
setDevices([]);
}
});
};
load();
// Список меняется при подключении гарнитуры — обновляем его на лету.
mediaDevices.addEventListener?.('devicechange', load);
return () => {
cancelled = true;
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) };
}