feat(web): чат Фазы 2 — лента, композер, реакции, ответы, поиск и вложения

Полноценный текстовый чат в веб-клиенте:

- стор сообщений по комнатам (кэш 200, оптимистичная отправка с nonce,
  применение всех событий Gateway, пагинация курсором, непрочитанные и
  упоминания, сброс при логауте и INVALID_SESSION);
- лента: группировка подряд идущих сообщений одного автора, разделители дат,
  разделитель «новые сообщения», подгрузка истории вверх, пустые состояния;
- разметка: жирный/курсив/код/блок кода/зачёркнутый/цитата/ссылки (только
  http(s)) и упоминания <@id> — безопасный рендер React-узлами без
  dangerouslySetInnerHTML;
- реакции чипами с подсветкой своих, быстрые эмодзи и пикер, панель действий и
  контекстное меню (ответ, реакция, закрепление, правка, удаление, копирование);
- ответы с переходом к оригиналу, inline-правка, «печатает…» с POST /typing раз
  в 3 с, пины в модалке, поиск по комнате с подсветкой и переходом;
- композер: автовысота, Enter/Shift+Enter, лимит 4000, drag&drop и скрепка
  (POST /channels/{id}/files → attachment_ids), обработка 429 с таймером и
  perm.denied с причиной, read-only без SEND_MESSAGES;
- бейджи непрочитанных и упоминаний в сайдбаре, авто-POST /ack при активном окне;
- 176 тестов Vitest (было 100), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 00:15:18 +03:00
parent b22db8f048
commit b01eb1f34c
34 changed files with 6417 additions and 52 deletions
+23 -4
View File
@@ -18,6 +18,13 @@ interface MenuProps {
* закрывалось бы и тут же открывалось заново), ему же возвращается фокус.
*/
anchorRef?: RefObject<HTMLElement | null>;
/**
* Куда раскрывается панель: вверх (`top`, по умолчанию — меню над панелью
* пользователя) или вниз (`bottom` — меню действий у сообщения).
*/
placement?: 'top' | 'bottom';
/** Горизонтальное выравнивание панели относительно якоря. */
align?: 'left' | 'right';
className?: string;
}
@@ -29,10 +36,20 @@ const ITEM_SELECTOR =
* открывается с фокусом на первом пункте, стрелки/Home/End перемещают фокус,
* Tab закрывает меню и возвращает управление странице.
*
* Позиционирование задаёт родитель: панель абсолютная и растёт вверх
* (`.gl-popover`), поэтому меню удобно вешать над нижней панелью пользователя.
* Позиционирование задаёт родитель: панель абсолютная и по умолчанию растёт
* вверх (`.gl-popover`) — так меню удобно вешать над нижней панелью
* пользователя; `placement="bottom"` раскрывает его вниз (меню сообщения).
*/
export function Menu({ open, label, onClose, children, anchorRef, className = '' }: MenuProps) {
export function Menu({
open,
label,
onClose,
children,
anchorRef,
placement = 'top',
align = 'left',
className = '',
}: MenuProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
@@ -136,7 +153,9 @@ export function Menu({ open, label, onClose, children, anchorRef, className = ''
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}`}
className={`absolute z-40 min-w-56 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-1 shadow-[var(--shadow-3)] ${
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
} ${align === 'right' ? 'right-0' : 'left-0'} ${className}`}
>
{children}
</div>