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
+194
View File
@@ -0,0 +1,194 @@
import {
useEffect,
useRef,
type ButtonHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type RefObject,
} from 'react';
interface MenuProps {
open: boolean;
/** Доступное имя меню (озвучивается скринридером). */
label: string;
onClose: () => void;
children: ReactNode;
/**
* Элемент-якорь: клик по нему не считается кликом «вне меню» (иначе меню
* закрывалось бы и тут же открывалось заново), ему же возвращается фокус.
*/
anchorRef?: RefObject<HTMLElement | null>;
className?: string;
}
const ITEM_SELECTOR =
'[role="menuitem"]:not([disabled]),[role="menuitemradio"]:not([disabled]),[role="menuitemcheckbox"]:not([disabled])';
/**
* Всплывающее меню (`role="menu"`): закрывается по Escape и клику вне,
* открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус,
* Tab закрывает меню и возвращает управление странице.
*
* Позиционирование задаёт родитель: панель абсолютная и растёт вверх
* (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя.
*/
export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
// Фокус — на первом пункте: меню сразу доступно с клавиатуры.
useEffect(() => {
if (!open) {
return;
}
const items = panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR);
items?.[0]?.focus();
}, [open]);
// Клик вне меню (и вне якоря) закрывает его.
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: MouseEvent): void => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (panelRef.current?.contains(target) === true) {
return;
}
if (anchorRef?.current?.contains(target) === true) {
return;
}
onCloseRef.current();
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
};
}, [open, anchorRef]);
// Escape работает и когда фокус ушёл на якорь.
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onCloseRef.current();
anchorRef?.current?.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [open, anchorRef]);
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Tab') {
// Меню — не часть tab-порядка: уводим фокус дальше по странице.
onClose();
return;
}
const items = Array.from(panelRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? []);
if (items.length === 0) {
return;
}
const current = items.findIndex((item) => item === document.activeElement);
const move = (next: number): void => {
event.preventDefault();
const target = items[(next + items.length) % items.length];
target?.focus();
};
switch (event.key) {
case 'ArrowDown':
move(current + 1);
return;
case 'ArrowUp':
move(current <= 0 ? items.length - 1 : current - 1);
return;
case 'Home':
move(0);
return;
case 'End':
move(items.length - 1);
return;
default:
return;
}
};
if (!open) {
return null;
}
return (
<div
ref={panelRef}
role="menu"
aria-label={label}
onKeyDown={onKeyDown}
className={`gl-popover absolute bottom-full left-0 z-40 mb-2 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${className}`}
>
{children}
</div>
);
}
interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Иконка слева (декоративная). */
icon?: ReactNode;
/** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */
selected?: boolean;
danger?: boolean;
}
/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */
export function MenuItem({
icon,
selected = false,
danger = false,
className = '',
children,
...rest
}: MenuItemProps) {
const role = rest.role ?? 'menuitem';
return (
<button
{...rest}
type="button"
role={role}
tabIndex={rest.tabIndex ?? -1}
aria-checked={role === 'menuitemradio' ? selected : undefined}
className={`flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm transition-colors duration-[var(--duration-fast)] focus-visible:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50 ${
danger ? 'text-danger hover:bg-danger/10' : 'text-fg hover:bg-surface-3'
} ${className}`}
>
<span aria-hidden="true" className="w-4 shrink-0 text-center">
{selected ? '✓' : (icon ?? '')}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</button>
);
}
/** Заголовок группы пунктов внутри меню. */
export function MenuGroupLabel({ children }: { children: ReactNode }) {
return (
<p role="presentation" className="px-2 py-1 text-xs font-semibold uppercase text-fg-muted">
{children}
</p>
);
}
/** Разделитель между группами пунктов. */
export function MenuSeparator() {
return <div role="separator" className="my-1 h-px bg-border/60" />;
}
+50 -7
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { motionDelay } from '@/lib/motion';
interface ModalProps {
open: boolean;
title: string;
@@ -9,13 +11,47 @@ interface ModalProps {
footer?: ReactNode;
}
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
const EXIT_MS = 140;
/**
* Простое модальное окно: закрывается по Escape и по клику на подложку,
* focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса.
*
* Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`):
* при закрытии узел остаётся в DOM до конца обратной анимации, а при
* `prefers-reduced-motion` удаляется сразу.
*/
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const [mounted, setMounted] = useState(open);
const [closing, setClosing] = useState(false);
// Свежий onClose без перезапуска эффекта фокуса при каждом рендере.
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
// Монтирование/размонтирование с учётом анимации закрытия.
useEffect(() => {
if (open) {
setMounted(true);
setClosing(false);
return;
}
if (!mounted) {
return;
}
setClosing(true);
const timer = window.setTimeout(() => {
setMounted(false);
setClosing(false);
}, motionDelay(EXIT_MS));
return () => {
window.clearTimeout(timer);
};
}, [open, mounted]);
useEffect(() => {
if (!open) {
@@ -23,26 +59,31 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
onCloseRef.current();
}
};
document.addEventListener('keydown', onKeyDown);
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const firstField = panelRef.current?.querySelector<HTMLElement>(
'input, textarea, select, button',
);
firstField?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
// Возвращаем фокус туда, откуда окно открыли.
previous?.focus();
};
}, [open, onClose]);
}, [open]);
if (!open) {
if (!mounted) {
return null;
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 ${
closing ? 'gl-backdrop-out' : 'gl-backdrop-in'
}`}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
@@ -54,14 +95,16 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
role="dialog"
aria-modal="true"
aria-label={title}
className="w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)]"
className={`w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)] ${
closing ? 'gl-pop-out' : 'gl-pop-in'
}`}
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
type="button"
aria-label={t('common.close')}
className="rounded-[var(--radius-sm)] px-2 text-lg leading-none text-fg-muted hover:text-fg"
className="rounded-[var(--radius-sm)] px-2 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg active:scale-95"
onClick={onClose}
>
×
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
interface SkeletonProps {
className?: string;
}
/** Одна полоса-скелетон с бегущим блеском (см. `.gl-skeleton` в index.css). */
export function Skeleton({ className = '' }: SkeletonProps) {
return <span aria-hidden="true" className={`gl-skeleton ${className}`} />;
}
interface SkeletonLinesProps {
/** Сколько строк-полос нарисовать. */
rows?: number;
className?: string;
}
/**
* Набор полос вместо текста «Загрузка…»: используется в списках и секциях
* настроек, чтобы интерфейс не «прыгал» после прихода данных.
*/
export function SkeletonLines({ rows = 3, className = '' }: SkeletonLinesProps) {
const { t } = useTranslation();
return (
<div
role="status"
aria-busy="true"
aria-label={t('common.loading')}
data-testid="skeleton"
className={`flex flex-col gap-2 ${className}`}
>
{Array.from({ length: Math.max(rows, 1) }, (_, index) => (
<Skeleton key={index} className="h-4 w-full" />
))}
</div>
);
}