feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
+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 { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -8,19 +8,123 @@ import { currentUserQueryKey, updateProfile } from '@/api/users';
|
||||
import { userStatuses, type UserStatus } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
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 { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useDeviceChoice } from '@/lib/useMediaDevices';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useMessagesStore } from '@/stores/messages';
|
||||
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 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() {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,12 +134,30 @@ export function UserPanel() {
|
||||
const describeError = useApiErrorMessage();
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
const disconnect = useGatewayStore((state) => state.disconnect);
|
||||
const openSettings = useUiStore((state) => state.openSettings);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const micEnabled = useVoiceStore((state) => state.micEnabled);
|
||||
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 username = sessionUser?.username ?? currentUser.data?.username ?? '';
|
||||
const bio = currentUser.data?.bio ?? '';
|
||||
const displayName = name === '' ? username : name;
|
||||
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
|
||||
const customStatus = sessionUser?.custom_status ?? '';
|
||||
@@ -61,7 +183,7 @@ export function UserPanel() {
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||
useSessionStore.getState().patchUser(updated);
|
||||
setMenuOpen(false);
|
||||
setProfileOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,14 +196,29 @@ export function UserPanel() {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard?.writeText(currentUser.data.id);
|
||||
setProfileOpen(false);
|
||||
}, [currentUser.data]);
|
||||
|
||||
const toggleMenu = useCallback(() => setMenuOpen((value) => !value), []);
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
const toggleProfile = useCallback(() => {
|
||||
setOpenControl(null);
|
||||
setProfileOpen((value) => !value);
|
||||
}, []);
|
||||
const closeProfile = useCallback(() => setProfileOpen(false), []);
|
||||
|
||||
const goTo = (path: string) => (): void => {
|
||||
setMenuOpen(false);
|
||||
void navigate(path);
|
||||
/** Открыть поповер устройства: мини-профиль при этом закрывается. */
|
||||
const toggleControl = useCallback((control: 'mic' | 'camera' | 'headphones') => {
|
||||
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;
|
||||
@@ -90,19 +227,27 @@ export function UserPanel() {
|
||||
return (
|
||||
<footer
|
||||
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
|
||||
ref={triggerRef}
|
||||
ref={profileRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-expanded={profileOpen}
|
||||
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"
|
||||
>
|
||||
<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="block truncate text-sm font-medium" title={displayName}>
|
||||
{displayName}
|
||||
@@ -118,37 +263,109 @@ export function UserPanel() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('user.copyId')}
|
||||
title={t('user.copyId')}
|
||||
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"
|
||||
onClick={onCopyId}
|
||||
<PanelControl
|
||||
glyph="🎤"
|
||||
enabled={micEnabled}
|
||||
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
|
||||
menuLabel={t('user.voice.micSettings')}
|
||||
onToggle={toggleMic}
|
||||
open={openControl === 'mic'}
|
||||
onOpenMenu={() => toggleControl('mic')}
|
||||
onCloseMenu={() => setOpenControl(null)}
|
||||
>
|
||||
#
|
||||
</button>
|
||||
<VoiceDeviceList
|
||||
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
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={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"
|
||||
onClick={toggleMenu}
|
||||
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={() => {
|
||||
setOpenControl(null);
|
||||
goToSettings({ section: 'profile', tab: 'main' });
|
||||
}}
|
||||
>
|
||||
⚙
|
||||
<span aria-hidden="true">⚙</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
|
||||
<MenuGroupLabel>{displayName}</MenuGroupLabel>
|
||||
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
|
||||
{t('user.menu.account')}
|
||||
</MenuItem>
|
||||
<MenuItem icon="🗄" onClick={goTo('/settings/servers')}>
|
||||
{t('user.menu.guilds')}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<Menu
|
||||
open={profileOpen}
|
||||
label={t('user.menu.label')}
|
||||
onClose={closeProfile}
|
||||
anchorRef={profileRef}
|
||||
className="w-64"
|
||||
>
|
||||
{/* Мини-профиль: аватар, имя, логин и «О себе». */}
|
||||
<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')}>
|
||||
<MenuGroupLabel>{t('user.menu.status')}</MenuGroupLabel>
|
||||
{selectableStatuses.map((value) => (
|
||||
@@ -156,6 +373,12 @@ export function UserPanel() {
|
||||
key={value}
|
||||
role="menuitemradio"
|
||||
selected={value === status}
|
||||
alwaysShowIcon
|
||||
icon={
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${statusDotClass[value]}`}
|
||||
/>
|
||||
}
|
||||
disabled={changeStatus.isPending}
|
||||
onClick={() => {
|
||||
changeStatus.reset();
|
||||
@@ -166,6 +389,17 @@ export function UserPanel() {
|
||||
</MenuItem>
|
||||
))}
|
||||
</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 />
|
||||
<MenuItem
|
||||
danger
|
||||
@@ -181,10 +415,26 @@ export function UserPanel() {
|
||||
</Menu>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Окно настроек живёт здесь же: панель — единственная точка входа. */}
|
||||
<SettingsModal />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user