Files
glchat/web/src/pages/app/UserPanel.tsx
T

441 lines
17 KiB
TypeScript
Raw Normal View History

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';
import { logout } from '@/api/auth';
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;
}
/**
* Кнопка устройства со стрелкой слева: сама кнопка переключает состояние,
* стрелка открывает поповер с устройствами и громкостью. Это две отдельные
* кнопки, поэтому выпадающий список не перекрывает переключатель.
*/
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();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const describeError = useApiErrorMessage();
const sessionUser = useSessionStore((state) => state.user);
const disconnect = useGatewayStore((state) => state.disconnect);
const openSettings = useUiStore((state) => state.openSettings);
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 ?? '';
const emoji = sessionUser?.custom_status_emoji ?? '';
const seed = currentUser.data?.id ?? sessionUser?.id ?? username;
const avatarFileId = sessionUser?.avatar_file_id ?? currentUser.data?.avatar_file_id;
const signOut = useMutation({
mutationFn: () => logout(),
onSuccess: () => {
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
disconnect();
useSessionStore.getState().reset();
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
useMessagesStore.getState().reset();
queryClient.clear();
void navigate('/login', { replace: true });
},
});
const changeStatus = useMutation({
mutationFn: (next: UserStatus) => updateProfile({ status: next }),
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
setProfileOpen(false);
},
});
const statusLabel = userStatuses.includes(status)
? t(`user.status.${status}`)
: t('user.status.offline');
const onCopyId = useCallback(() => {
if (currentUser.data === undefined) {
return;
}
void navigator.clipboard?.writeText(currentUser.data.id);
setProfileOpen(false);
}, [currentUser.data]);
const toggleProfile = useCallback(() => {
setOpenControl(null);
setProfileOpen((value) => !value);
}, []);
const closeProfile = useCallback(() => setProfileOpen(false), []);
/** Открыть поповер устройства: мини-профиль при этом закрывается. */
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;
const errorText = error === null || error === undefined ? null : describeError(error);
return (
<footer
aria-label={t('user.panelLabel')}
className="relative border-t border-border/40 bg-surface-2/80 p-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={profileRef}
type="button"
aria-haspopup="menu"
aria-expanded={profileOpen}
aria-label={t('user.menu.open')}
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"
>
<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}
</span>
<span className="block truncate text-xs text-fg-muted" title={statusLabel}>
{emoji === '' ? null : (
<span aria-hidden="true" className="mr-1">
{emoji}
</span>
)}
{customStatus === '' ? statusLabel : `${statusLabel} · ${customStatus}`}
</span>
</span>
</button>
<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)}
>
<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-label={t('user.settings')}
title={t('user.settings')}
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={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) => (
<MenuItem
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();
changeStatus.mutate(value);
}}
>
{t(`user.status.${value}`)}
</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
icon="⏻"
disabled={signOut.isPending}
onClick={() => {
signOut.reset();
signOut.mutate();
}}
>
{t(signOut.isPending ? 'user.loggingOut' : 'user.logout')}
</MenuItem>
</Menu>
{errorText === null ? null : (
<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>
);
}