Files
ds-chat/frontend/src/components/EmojiPicker.tsx
T
ksmithandClaude Sonnet 5 b4a104f8c6 Move custom emoji management out of the emoji picker
The picker's delete "x" overlaps the glyph in a tightly packed grid,
which is too easy to hit by accident on a touch screen. Adding and
deleting custom emoji now live in their own modal under the account
menu, with delete gated behind the same confirm() every other
destructive action in the app uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:02:21 -06:00

170 lines
6.2 KiB
TypeScript

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<string>()
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 (
<>
<div className="emoji-picker-scrim" onClick={onClose} />
<div
className={`emoji-picker emoji-picker-${placement} emoji-picker-${align}`}
role="menu"
>
<input
type="text"
className="emoji-picker-search"
placeholder="Search emoji"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
{searching ? (
searchResults.length > 0 ? (
<div className="emoji-picker-grid">
{searchResults.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
className="emoji-picker-item"
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
<EmojiGlyph value={emoji} />
</button>
))}
</div>
) : (
<div className="emoji-picker-no-results">No emoji found</div>
)
) : (
<>
{customEmoji.length > 0 && (
<div className="emoji-picker-category">
<div className="emoji-picker-category-label">Custom</div>
<div className="emoji-picker-grid">
{customEmoji.map((e) => (
<button
key={e.id}
type="button"
role="menuitem"
className="emoji-picker-item"
title={`:${e.shortcode}:`}
onClick={() => pick(`:${e.shortcode}:`)}
>
<EmojiGlyph value={`:${e.shortcode}:`} />
</button>
))}
</div>
</div>
)}
{recent.length > 0 && (
<div className="emoji-picker-category">
<div className="emoji-picker-category-label">Recently used</div>
<div className="emoji-picker-grid">
{recent.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
className="emoji-picker-item"
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
<EmojiGlyph value={emoji} />
</button>
))}
</div>
</div>
)}
{EMOJI_CATEGORIES.map((category) => (
<div key={category.label} className="emoji-picker-category">
<div className="emoji-picker-category-label">{category.label}</div>
<div className="emoji-picker-grid">
{category.emoji.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
className="emoji-picker-item"
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
<EmojiGlyph value={emoji} />
</button>
))}
</div>
</div>
))}
</>
)}
</div>
</>
)
}