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 { 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,25 +86,46 @@ 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) => (
|
<>
|
||||||
<div key={category.label} className="emoji-picker-category">
|
{recent.length > 0 && (
|
||||||
<div className="emoji-picker-category-label">{category.label}</div>
|
<div className="emoji-picker-category">
|
||||||
<div className="emoji-picker-grid">
|
<div className="emoji-picker-category-label">Recently used</div>
|
||||||
{category.emoji.map((emoji) => (
|
<div className="emoji-picker-grid">
|
||||||
<button
|
{recent.map((emoji) => (
|
||||||
key={emoji}
|
<button
|
||||||
type="button"
|
key={emoji}
|
||||||
role="menuitem"
|
type="button"
|
||||||
className="emoji-picker-item"
|
role="menuitem"
|
||||||
title={EMOJI_NAMES[emoji]?.name}
|
className="emoji-picker-item"
|
||||||
onClick={() => onPick(emoji)}
|
title={EMOJI_NAMES[emoji]?.name}
|
||||||
>
|
onClick={() => pick(emoji)}
|
||||||
{emoji}
|
>
|
||||||
</button>
|
{emoji}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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>
|
</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