2026-09-20 18:13:04 +03:00
|
|
|
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
2026-09-20 18:13:04 +03:00
|
|
|
import { fileContentUrl } from '@/api/files';
|
|
|
|
|
import { useGuildEmojis } from '@/components/chat/useGuildEmojis';
|
2026-09-20 00:15:18 +03:00
|
|
|
import { POPULAR_EMOJIS, QUICK_REACTIONS } from '@/lib/chat';
|
2026-09-20 18:13:04 +03:00
|
|
|
import { customEmojiShortcode } from '@/lib/customEmoji';
|
2026-09-20 00:15:18 +03:00
|
|
|
|
|
|
|
|
interface EmojiPickerProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onPick: (emoji: string) => void;
|
|
|
|
|
/** Якорь: клик по нему не считается кликом «вне пикера». */
|
|
|
|
|
anchorRef?: RefObject<HTMLElement | null>;
|
|
|
|
|
/** Куда раскрывается панель: под якорем или над ним. */
|
|
|
|
|
placement?: 'top' | 'bottom';
|
2026-09-20 18:13:04 +03:00
|
|
|
/** Сервер, чьи кастомные эмодзи показываем: в беседе их нет. */
|
|
|
|
|
guildId?: string | null;
|
2026-09-20 00:15:18 +03:00
|
|
|
className?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 18:13:04 +03:00
|
|
|
* Выпадающий пикер эмодзи: ряд быстрых реакций, эмодзи сервера и сетка
|
|
|
|
|
* популярных. Пикер общий для ленты (реакции) и композера (вставка в текст):
|
|
|
|
|
* `onPick` получает либо юникод-эмодзи, либо токен `<:имя:file_id>`.
|
|
|
|
|
*
|
|
|
|
|
* Эмодзи сервера ищутся по имени; список пуст, если сервера нет (личная
|
|
|
|
|
* беседа) или на нём ещё не загрузили ни одного эмодзи.
|
|
|
|
|
*
|
2026-09-20 00:15:18 +03:00
|
|
|
* Закрывается по Escape и клику вне панели; фокус остаётся в пределах
|
|
|
|
|
* tab-порядка страницы.
|
|
|
|
|
*/
|
|
|
|
|
export function EmojiPicker({
|
|
|
|
|
open,
|
|
|
|
|
onClose,
|
|
|
|
|
onPick,
|
|
|
|
|
anchorRef,
|
|
|
|
|
placement = 'bottom',
|
2026-09-20 18:13:04 +03:00
|
|
|
guildId = null,
|
2026-09-20 00:15:18 +03:00
|
|
|
className = '',
|
|
|
|
|
}: EmojiPickerProps) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
2026-09-20 18:13:04 +03:00
|
|
|
const [query, setQuery] = useState('');
|
|
|
|
|
const serverEmojis = useGuildEmojis(guildId);
|
2026-09-20 00:15:18 +03:00
|
|
|
const onCloseRef = useRef(onClose);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
onCloseRef.current = onClose;
|
|
|
|
|
}, [onClose]);
|
|
|
|
|
|
2026-09-20 18:13:04 +03:00
|
|
|
// Поиск сбрасываем при каждом открытии: иначе список «залипал» бы от прошлого раза.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) {
|
|
|
|
|
setQuery('');
|
|
|
|
|
}
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
const found = useMemo(() => {
|
|
|
|
|
const needle = query.trim().toLowerCase();
|
|
|
|
|
if (needle === '') {
|
|
|
|
|
return serverEmojis;
|
|
|
|
|
}
|
|
|
|
|
return serverEmojis.filter((emoji) => emoji.name.toLowerCase().includes(needle));
|
|
|
|
|
}, [serverEmojis, query]);
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
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>
|
2026-09-20 18:13:04 +03:00
|
|
|
|
|
|
|
|
{serverEmojis.length === 0 ? null : (
|
|
|
|
|
<div
|
|
|
|
|
role="group"
|
|
|
|
|
aria-label={t('chat.emoji.server')}
|
|
|
|
|
data-testid="emoji-picker-server"
|
|
|
|
|
className="mt-2 border-t border-border/40 pt-2"
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="search"
|
|
|
|
|
value={query}
|
|
|
|
|
aria-label={t('chat.emoji.search')}
|
|
|
|
|
placeholder={t('chat.emoji.searchPlaceholder')}
|
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
|
|
|
className="w-full rounded-[var(--radius-sm)] border border-border/60 bg-surface-1 px-2 py-1 text-xs text-fg placeholder:text-fg-muted/70"
|
|
|
|
|
/>
|
|
|
|
|
{found.length === 0 ? (
|
|
|
|
|
<p className="mt-1 px-1 text-xs text-fg-muted">{t('chat.emoji.notFound')}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="mt-1 flex max-h-40 flex-col gap-0.5 overflow-y-auto">
|
|
|
|
|
{found.map((emoji) => (
|
|
|
|
|
<li key={emoji.id}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={customEmojiShortcode(emoji.name)}
|
|
|
|
|
aria-label={t('chat.emoji.pickServer', { name: emoji.name })}
|
|
|
|
|
className="flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-1 py-0.5 text-left text-xs text-fg transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-[0.98]"
|
|
|
|
|
onClick={() => onPick(emoji.token)}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={fileContentUrl(emoji.file_id)}
|
|
|
|
|
alt=""
|
|
|
|
|
loading="lazy"
|
|
|
|
|
className="h-6 w-6 shrink-0"
|
|
|
|
|
/>
|
|
|
|
|
<span className="truncate">{customEmojiShortcode(emoji.name)}</span>
|
|
|
|
|
{emoji.animated ? (
|
|
|
|
|
<span className="ml-auto shrink-0 text-[10px] uppercase text-fg-muted">
|
|
|
|
|
{t('chat.emoji.animated')}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|