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:
2026-08-16 19:25:50 -06:00
co-authored by Claude Sonnet 5
parent 2cd44dc3f7
commit fd0b3863f0
2 changed files with 77 additions and 19 deletions
+36 -4
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'
import { useEscapeKey } from '../hooks/useEscapeKey' import { useEscapeKey } from '../hooks/useEscapeKey'
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji' import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
import { EMOJI_NAMES } from '../lib/emojiNames' import { EMOJI_NAMES } from '../lib/emojiNames'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import './EmojiPicker.css' import './EmojiPicker.css'
interface EmojiPickerProps { interface EmojiPickerProps {
@@ -39,6 +40,16 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const searchResults = useMemo(() => searchEmoji(query), [query]) const searchResults = useMemo(() => searchEmoji(query), [query])
const searching = query.trim().length > 0 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 ( return (
<> <>
@@ -65,7 +76,7 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
role="menuitem" role="menuitem"
className="emoji-picker-item" className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name} title={EMOJI_NAMES[emoji]?.name}
onClick={() => onPick(emoji)} onClick={() => pick(emoji)}
> >
{emoji} {emoji}
</button> </button>
@@ -75,7 +86,27 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
<div className="emoji-picker-no-results">No emoji found</div> <div className="emoji-picker-no-results">No emoji found</div>
) )
) : ( ) : (
EMOJI_CATEGORIES.map((category) => ( <>
{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>
)}
{EMOJI_CATEGORIES.map((category) => (
<div key={category.label} className="emoji-picker-category"> <div key={category.label} className="emoji-picker-category">
<div className="emoji-picker-category-label">{category.label}</div> <div className="emoji-picker-category-label">{category.label}</div>
<div className="emoji-picker-grid"> <div className="emoji-picker-grid">
@@ -86,14 +117,15 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
role="menuitem" role="menuitem"
className="emoji-picker-item" className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name} title={EMOJI_NAMES[emoji]?.name}
onClick={() => onPick(emoji)} onClick={() => pick(emoji)}
> >
{emoji} {emoji}
</button> </button>
))} ))}
</div> </div>
</div> </div>
)) ))}
</>
)} )}
</div> </div>
</> </>
+26
View File
@@ -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.
}
}