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:
2026-09-19 23:32:11 +03:00
parent 4550bea6cc
commit e4ffbb05a5
30 changed files with 3182 additions and 106 deletions
+133 -46
View File
@@ -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>
);
}