import { useMemo, useState } from 'react' import { useCustomEmoji } from '../context/CustomEmojiContext' import { useEscapeKey } from '../hooks/useEscapeKey' import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji' import { EMOJI_NAMES } from '../lib/emojiNames' import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji' import { EmojiGlyph } from './MessageContent' import './EmojiPicker.css' interface EmojiPickerProps { onPick: (emoji: string) => void onClose: () => void placement?: 'above' | 'below' align?: 'left' | 'right' } // Kept in sync with .emoji-picker's max-height in EmojiPicker.css -- callers // that compute placement dynamically (flipping above/below based on // available viewport space) need this to know how much room to check for. export const EMOJI_PICKER_MAX_HEIGHT = 380 // Every emoji this picker deals with -- built-in or custom -- is just a // string from here on: a raw unicode glyph, or a custom emoji's literal // `:shortcode:` reference (see EmojiGlyph in MessageContent.tsx, which // resolves either into the right thing to render). Keeping both kinds in // the same list/search/recent machinery means there's exactly one grid // rendering path instead of a parallel one for custom emoji. function titleFor(value: string): string { return EMOJI_NAMES[value]?.name ?? value } function searchEmoji(query: string, customShortcodes: string[]): string[] { const q = query.trim().toLowerCase() if (!q) return [] const seen = new Set() const results: string[] = [] for (const emoji of ALL_EMOJI) { if (seen.has(emoji)) continue const entry = EMOJI_NAMES[emoji] if (!entry) continue const matches = entry.name.toLowerCase().includes(q) || entry.keywords.some((k) => k.toLowerCase().includes(q)) if (matches) { seen.add(emoji) results.push(emoji) } } for (const shortcode of customShortcodes) { if (shortcode.toLowerCase().includes(q)) results.push(`:${shortcode}:`) } return results } export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) { useEscapeKey(onClose) const { list: customEmoji } = useCustomEmoji() const [query, setQuery] = useState('') const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji]) const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes]) const searching = query.trim().length > 0 // A snapshot taken once when the picker opens, not live-updating as picks // happen within this same session -- picking an emoji always closes the // picker (see Composer.tsx/MessageList.tsx), so there's never a second // pick in the same open session to show an updated list to. const [recent] = useState(getRecentEmoji) function pick(emoji: string) { recordEmojiUsed(emoji) onPick(emoji) } return ( <>
setQuery(e.target.value)} autoFocus /> {searching ? ( searchResults.length > 0 ? (
{searchResults.map((emoji) => ( ))}
) : (
No emoji found
) ) : ( <> {customEmoji.length > 0 && (
Custom
{customEmoji.map((e) => ( ))}
)} {recent.length > 0 && (
Recently used
{recent.map((emoji) => ( ))}
)} {EMOJI_CATEGORIES.map((category) => (
{category.label}
{category.emoji.map((emoji) => ( ))}
))} )}
) }