2026-09-19 23:32:11 +03:00
|
|
|
import { useCallback, useRef, useState } from 'react';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-09-19 23:32:11 +03:00
|
|
|
import { useNavigate } from 'react-router';
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
import { logout } from '@/api/auth';
|
2026-09-19 23:32:11 +03:00
|
|
|
import { currentUserQueryKey, updateProfile } from '@/api/users';
|
|
|
|
|
import { userStatuses, type UserStatus } from '@/api/types';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { Avatar } from '@/components/ui/Avatar';
|
2026-09-19 23:32:11 +03:00
|
|
|
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { useCurrentUser } from '@/lib/hooks';
|
|
|
|
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
|
|
|
|
import { useGatewayStore } from '@/stores/gateway';
|
|
|
|
|
import { useSessionStore } from '@/stores/session';
|
|
|
|
|
|
2026-09-19 23:32:11 +03:00
|
|
|
/** Статусы, которые можно выставить из меню: «не в сети» сервер выводит сам. */
|
|
|
|
|
const selectableStatuses = userStatuses.filter((value) => value !== 'offline');
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Панель пользователя внизу слева: аватар, имя и статус. Клик по аватару,
|
|
|
|
|
* имени или шестерёнке открывает всплывающее вверх меню с настройками,
|
|
|
|
|
* сменой статуса и выходом (см. `Menu`).
|
|
|
|
|
*/
|
2026-09-19 21:50:06 +03:00
|
|
|
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);
|
|
|
|
|
|
2026-09-19 23:32:11 +03:00
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
|
|
|
|
|
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
|
2026-09-19 23:32:11 +03:00
|
|
|
const displayName = name === '' ? username : name;
|
2026-09-19 21:50:06 +03:00
|
|
|
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();
|
|
|
|
|
queryClient.clear();
|
|
|
|
|
void navigate('/login', { replace: true });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-19 23:32:11 +03:00
|
|
|
const changeStatus = useMutation({
|
|
|
|
|
mutationFn: (next: UserStatus) => updateProfile({ status: next }),
|
|
|
|
|
onSuccess: (updated) => {
|
|
|
|
|
queryClient.setQueryData(currentUserQueryKey, updated);
|
|
|
|
|
useSessionStore.getState().patchUser(updated);
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
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);
|
|
|
|
|
}, [currentUser.data]);
|
|
|
|
|
|
2026-09-19 23:32:11 +03:00
|
|
|
const toggleMenu = useCallback(() => setMenuOpen((value) => !value), []);
|
|
|
|
|
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
|
|
|
|
|
|
|
|
|
const goTo = (path: string) => (): void => {
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
void navigate(path);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const error = changeStatus.error ?? signOut.error;
|
|
|
|
|
const errorText = error === null || error === undefined ? null : describeError(error);
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
return (
|
|
|
|
|
<footer
|
|
|
|
|
aria-label={t('user.panelLabel')}
|
2026-09-19 23:32:11 +03:00
|
|
|
className="relative flex flex-col border-t border-border/40 bg-surface-2/80"
|
2026-09-19 21:50:06 +03:00
|
|
|
>
|
2026-09-19 23:32:11 +03:00
|
|
|
<div className="flex items-center gap-1 px-2 py-2">
|
|
|
|
|
<button
|
|
|
|
|
ref={triggerRef}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
aria-expanded={menuOpen}
|
|
|
|
|
aria-label={t('user.menu.open')}
|
|
|
|
|
onClick={toggleMenu}
|
|
|
|
|
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="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>
|
2026-09-19 21:50:06 +03:00
|
|
|
</span>
|
2026-09-19 23:32:11 +03:00
|
|
|
</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}
|
|
|
|
|
>
|
|
|
|
|
#
|
|
|
|
|
</button>
|
|
|
|
|
<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}
|
|
|
|
|
>
|
|
|
|
|
⚙
|
|
|
|
|
</button>
|
2026-09-19 21:50:06 +03:00
|
|
|
</div>
|
2026-09-19 23:32:11 +03:00
|
|
|
|
|
|
|
|
<Menu
|
|
|
|
|
open={menuOpen}
|
|
|
|
|
label={t('user.menu.label')}
|
|
|
|
|
onClose={closeMenu}
|
|
|
|
|
anchorRef={triggerRef}
|
2026-09-19 21:50:06 +03:00
|
|
|
>
|
2026-09-19 23:32:11 +03:00
|
|
|
<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 />
|
|
|
|
|
<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}
|
|
|
|
|
disabled={changeStatus.isPending}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
changeStatus.reset();
|
|
|
|
|
changeStatus.mutate(value);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t(`user.status.${value}`)}
|
|
|
|
|
</MenuItem>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<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-3 pb-2 text-xs text-danger">
|
|
|
|
|
{errorText}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-09-19 21:50:06 +03:00
|
|
|
</footer>
|
|
|
|
|
);
|
|
|
|
|
}
|