@@ -46,9 +46,12 @@ function updateReactionHistory(emoji: string): void {
4646/**
4747 * EmojiPickerDialog - Searchable emoji picker for reactions
4848 *
49+ * Layout: top (recently used) emojis → search bar → scrollable list
50+ * This keeps the dialog close button away from the search input.
51+ *
4952 * Features:
53+ * - Recently used emojis shown as quick-pick buttons at the top
5054 * - Real-time search using FlexSearch with scrollable virtualized results
51- * - Frequently used emoji at top when no search query
5255 * - Supports both unicode and NIP-30 custom emoji
5356 * - Keyboard navigation (arrow keys, enter, escape)
5457 * - Tracks usage in localStorage
@@ -94,14 +97,31 @@ export function EmojiPickerDialog({
9497 }
9598 } , [ open ] ) ;
9699
97- // Get frequently used emojis from history
100+ // Get frequently used emojis from history (sorted by use count)
98101 const frequentlyUsed = useMemo ( ( ) => {
99102 const history = getReactionHistory ( ) ;
100103 return Object . entries ( history )
101104 . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] )
102105 . map ( ( [ emoji ] ) => emoji ) ;
103106 } , [ ] ) ;
104107
108+ // Resolve top 8 recently used emojis to EmojiSearchResult for rendering
109+ const topEmojis = useMemo < EmojiSearchResult [ ] > ( ( ) => {
110+ if ( frequentlyUsed . length === 0 ) return [ ] ;
111+ const results : EmojiSearchResult [ ] = [ ] ;
112+ for ( const emojiStr of frequentlyUsed . slice ( 0 , 8 ) ) {
113+ if ( emojiStr . startsWith ( ":" ) && emojiStr . endsWith ( ":" ) ) {
114+ const shortcode = emojiStr . slice ( 1 , - 1 ) ;
115+ const custom = service . getByShortcode ( shortcode ) ;
116+ if ( custom ) results . push ( custom ) ;
117+ } else {
118+ const found = searchResults . find ( ( r ) => r . url === emojiStr ) ;
119+ if ( found ) results . push ( found ) ;
120+ }
121+ }
122+ return results ;
123+ } , [ frequentlyUsed , searchResults , service ] ) ;
124+
105125 // When no search query: show recently used first, then fill with search results
106126 // When searching: show search results only
107127 const displayEmojis = useMemo ( ( ) => {
@@ -216,7 +236,7 @@ export function EmojiPickerDialog({
216236 />
217237 ) }
218238 </ span >
219- < span className = "truncate text-sm text-popover-foreground/80 " >
239+ < span className = "truncate text-sm text-popover-foreground" >
220240 :{ item . shortcode } :
221241 </ span >
222242 </ button >
@@ -227,26 +247,64 @@ export function EmojiPickerDialog({
227247
228248 return (
229249 < Dialog open = { open } onOpenChange = { onOpenChange } >
230- < DialogContent className = "max-w-xs p-4 gap-2" >
231- { /* Search input */ }
232- < div className = "relative" >
233- < Search className = "absolute left-3 top-1/2 transform -translate-y-1/2 size-4 text-muted-foreground" />
234- < Input
235- type = "text"
236- placeholder = "Search emojis..."
237- value = { searchQuery }
238- onChange = { ( e ) => setSearchQuery ( e . target . value ) }
239- onKeyDown = { handleKeyDown }
240- className = "pl-9"
241- autoFocus
242- />
250+ < DialogContent className = "max-w-xs p-0 gap-0 overflow-hidden" >
251+ { /* Top emojis — recently used quick-picks.
252+ This section also provides natural spacing for the dialog close (X) button,
253+ which is absolutely positioned at top-right of the dialog. */ }
254+ < div className = "flex items-center gap-1 px-3 pt-3 pb-2 pr-10 min-h-[48px]" >
255+ { topEmojis . length > 0 ? (
256+ < div
257+ className = "flex items-center gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden"
258+ style = { { scrollbarWidth : "none" } }
259+ >
260+ { topEmojis . map ( ( emoji ) => (
261+ < button
262+ key = { emoji . shortcode }
263+ onClick = { ( ) => handleEmojiClick ( emoji ) }
264+ title = { `:${ emoji . shortcode } :` }
265+ className = "flex size-8 items-center justify-center rounded hover:bg-muted/60 transition-colors flex-shrink-0"
266+ >
267+ { emoji . source === "unicode" ? (
268+ < span className = "text-lg leading-none" > { emoji . url } </ span >
269+ ) : (
270+ < img
271+ src = { emoji . url }
272+ alt = { `:${ emoji . shortcode } :` }
273+ className = "size-6 object-contain"
274+ loading = "lazy"
275+ />
276+ ) }
277+ </ button >
278+ ) ) }
279+ </ div >
280+ ) : (
281+ < span className = "text-xs font-medium text-muted-foreground select-none" >
282+ Emoji
283+ </ span >
284+ ) }
285+ </ div >
286+
287+ { /* Search bar */ }
288+ < div className = "px-3 pb-2" >
289+ < div className = "relative" >
290+ < Search className = "absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
291+ < Input
292+ type = "text"
293+ placeholder = "Search emojis..."
294+ value = { searchQuery }
295+ onChange = { ( e ) => setSearchQuery ( e . target . value ) }
296+ onKeyDown = { handleKeyDown }
297+ className = "pl-9"
298+ autoFocus
299+ />
300+ </ div >
243301 </ div >
244302
245303 { /* Scrollable emoji list */ }
246304 { displayEmojis . length > 0 ? (
247305 < div
248306 role = "listbox"
249- className = "rounded-md border border -border/50 bg-popover text-popover-foreground overflow-hidden "
307+ className = "border-t border-border/50 bg-popover text-popover-foreground"
250308 >
251309 < Virtuoso
252310 ref = { virtuosoRef }
@@ -257,11 +315,12 @@ export function EmojiPickerDialog({
257315 overflow :
258316 displayEmojis . length <= MAX_VISIBLE ? "hidden" : "auto" ,
259317 } }
318+ className = "[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/60 [&::-webkit-scrollbar-track]:bg-transparent"
260319 itemContent = { renderItem }
261320 />
262321 </ div >
263322 ) : (
264- < div className = "flex items-center justify-center py-6 text-sm text-muted-foreground" >
323+ < div className = "flex items-center justify-center py-6 text-sm text-muted-foreground border-t border-border/50 " >
265324 No emojis found
266325 </ div >
267326 ) }
0 commit comments