feat(messages): сообщения, реакции, пины, typing, read states и поиск
Бэкенд текстовой связи (AGENT.md 7.6, 7.15, 7.16, 8.6):
- POST/GET/PATCH/DELETE /channels/{id}/messages, ответы и упоминания,
окно правки 24 часа, права автора или MANAGE_MESSAGES;
- реакции (PUT/DELETE .../reactions/{emoji}) с агрегацией и отметкой «моя»;
- закрепления (GET/PUT/DELETE .../pins) под MANAGE_MESSAGES;
- typing с лимитом 1/3 с и адресной рассылкой всем, кроме автора;
- read states: POST /channels/{id}/ack, синхронизация READ_STATE_UPDATE между
устройствами пользователя;
- поиск FTS5 по комнате (10/мин) с экранированием запроса;
- лимиты отправки 5/5 с (burst 10) и slowmode комнаты; администратор инстанса
обходит и то, и другое (AGENT.md 7.19);
- Gateway: DispatchToChannel/DispatchToChannelExcept доставляют события комнат
только тем, кто видит комнату (VIEW_CHANNEL), права считает общий движок;
- store: messages, message_reactions, channel_read_states, files (загрузка
файлов появится вместе с вложениями).
Тесты: жизненный цикл сообщения, видимость скрытой комнаты (404 участнику,
200 админу), slowmode, упоминания и ответы, typing и read state, фильтрация
событий комнаты между двумя WS-клиентами.
This commit is contained in:
+133
-46
@@ -1,17 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
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 { useCurrentUser } from '@/lib/hooks';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { userStatuses, type UserStatus } from '@/api/types';
|
||||
|
||||
/** Панель пользователя внизу: аватар, имя, статус, настройки и выход. */
|
||||
/** Статусы, которые можно выставить из меню: «не в сети» сервер выводит сам. */
|
||||
const selectableStatuses = userStatuses.filter((value) => value !== 'offline');
|
||||
|
||||
/**
|
||||
* Панель пользователя внизу слева: аватар, имя и статус. Клик по аватару,
|
||||
* имени или шестерёнке открывает всплывающее вверх меню с настройками,
|
||||
* сменой статуса и выходом (см. `Menu`).
|
||||
*/
|
||||
export function UserPanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -21,8 +30,12 @@ export function UserPanel() {
|
||||
const sessionUser = useSessionStore((state) => state.user);
|
||||
const disconnect = useGatewayStore((state) => state.disconnect);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
|
||||
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
|
||||
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 ?? '';
|
||||
@@ -40,6 +53,15 @@ export function UserPanel() {
|
||||
},
|
||||
});
|
||||
|
||||
const changeStatus = useMutation({
|
||||
mutationFn: (next: UserStatus) => updateProfile({ status: next }),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||
useSessionStore.getState().patchUser(updated);
|
||||
setMenuOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const statusLabel = userStatuses.includes(status)
|
||||
? t(`user.status.${status}`)
|
||||
: t('user.status.offline');
|
||||
@@ -51,55 +73,120 @@ export function UserPanel() {
|
||||
void navigator.clipboard?.writeText(currentUser.data.id);
|
||||
}, [currentUser.data]);
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<footer
|
||||
aria-label={t('user.panelLabel')}
|
||||
className="flex items-center gap-2 border-t border-border/40 bg-surface-2/80 px-2 py-2"
|
||||
className="relative flex flex-col border-t border-border/40 bg-surface-2/80"
|
||||
>
|
||||
<Avatar name={name === '' ? username : name} seed={seed} fileId={avatarFileId} size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium" title={name}>
|
||||
{name === '' ? username : name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted" title={statusLabel}>
|
||||
<span aria-hidden="true" className="mr-1">
|
||||
{emoji}
|
||||
<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>
|
||||
</span>
|
||||
{customStatus === '' ? statusLabel : `${statusLabel} · ${customStatus}`}
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('user.copyId')}
|
||||
title={t('user.copyId')}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
onClick={onCopyId}
|
||||
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
label={t('user.menu.label')}
|
||||
onClose={closeMenu}
|
||||
anchorRef={triggerRef}
|
||||
>
|
||||
#
|
||||
</button>
|
||||
<Link
|
||||
to="/settings"
|
||||
aria-label={t('user.settings')}
|
||||
title={t('user.settings')}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
⚙
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('user.logout')}
|
||||
title={t('user.logout')}
|
||||
disabled={signOut.isPending}
|
||||
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted hover:bg-surface-3 hover:text-fg disabled:opacity-50"
|
||||
onClick={() => signOut.mutate()}
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
{signOut.isError ? (
|
||||
<span role="alert" className="max-w-40 truncate text-xs text-danger">
|
||||
{describeError(signOut.error)}
|
||||
</span>
|
||||
) : null}
|
||||
<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>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user