Private
Public Access
Add a "Recently used" section to the emoji picker (#35)
Shows up to 8 most-recently-picked emoji, pinned above the regular categories, whenever the picker isn't in search mode -- shared by both the composer's insert-emoji button and message reactions, since both go through the same EmojiPicker component. Stored in localStorage (frontend/src/lib/recentEmoji.ts), per-browser rather than synced across devices, matching this app's existing local-only preferences (e.g. the resizable-panel widths). Verified in-browser: no section when empty, a pick is recorded and shows up on reopen, order is most-recent-first, re-picking an already- recent emoji moves it to the front without duplicating, and the list caps at 8 by dropping the oldest entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
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 './EmojiPicker.css'
|
||||
|
||||
interface EmojiPickerProps {
|
||||
@@ -39,6 +40,16 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
const [query, setQuery] = useState('')
|
||||
const searchResults = useMemo(() => searchEmoji(query), [query])
|
||||
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 (
|
||||
<>
|
||||
@@ -65,7 +76,7 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
role="menuitem"
|
||||
className="emoji-picker-item"
|
||||
title={EMOJI_NAMES[emoji]?.name}
|
||||
onClick={() => onPick(emoji)}
|
||||
onClick={() => pick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
@@ -75,25 +86,46 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
||||
<div className="emoji-picker-no-results">No emoji found</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={EMOJI_NAMES[emoji]?.name}
|
||||
onClick={() => onPick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<>
|
||||
{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={EMOJI_NAMES[emoji]?.name}
|
||||
onClick={() => pick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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={EMOJI_NAMES[emoji]?.name}
|
||||
onClick={() => pick(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const KEY = 'recent-emoji'
|
||||
const MAX_RECENT = 8
|
||||
|
||||
export function getRecentEmoji(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((e) => typeof e === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Most-recently-used first, deduped, capped -- a picked emoji that's
|
||||
// already in the list moves to the front rather than appearing twice.
|
||||
export function recordEmojiUsed(emoji: string): void {
|
||||
try {
|
||||
const current = getRecentEmoji().filter((e) => e !== emoji)
|
||||
const next = [emoji, ...current].slice(0, MAX_RECENT)
|
||||
localStorage.setItem(KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// storage unavailable (private browsing, quota) -- recent emoji just
|
||||
// won't persist this session, not fatal.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user