Files
glchat/web/src/components/chat/EmojiPicker.tsx
T
grendervill b01eb1f34c 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 зелёные.
2026-09-20 00:15:18 +03:00

113 lines
3.6 KiB
TypeScript

import { useEffect, useRef, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { POPULAR_EMOJIS, QUICK_REACTIONS } from '@/lib/chat';
interface EmojiPickerProps {
open: boolean;
onClose: () => void;
onPick: (emoji: string) => void;
/** Якорь: клик по нему не считается кликом «вне пикера». */
anchorRef?: RefObject<HTMLElement | null>;
/** Куда раскрывается панель: под якорем или над ним. */
placement?: 'top' | 'bottom';
className?: string;
}
/**
* Выпадающий пикер эмодзи: ряд быстрых реакций и сетка популярных.
* Закрывается по Escape и клику вне панели; фокус остаётся в пределах
* tab-порядка страницы.
*/
export function EmojiPicker({
open,
onClose,
onPick,
anchorRef,
placement = 'bottom',
className = '',
}: EmojiPickerProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: Event): 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();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onCloseRef.current();
}
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open, anchorRef]);
if (!open) {
return null;
}
return (
<div
ref={panelRef}
role="dialog"
aria-label={t('chat.emoji.title')}
className={`absolute right-0 z-40 w-64 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 shadow-[var(--shadow-3)] ${
placement === 'top' ? 'gl-popover bottom-full mb-2' : 'gl-popover-down top-full mt-2'
} ${className}`}
>
<div className="flex flex-wrap gap-1" role="group" aria-label={t('chat.emoji.quick')}>
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.emoji.pick', { emoji })}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
<div
role="group"
aria-label={t('chat.emoji.popular')}
className="mt-2 grid max-h-40 grid-cols-6 gap-1 overflow-y-auto border-t border-border/40 pt-2"
>
{POPULAR_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={t('chat.emoji.pick', { emoji })}
className="rounded-[var(--radius-sm)] py-0.5 text-lg leading-none transition-transform duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-90"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
</div>
);
}