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:
@@ -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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user