From ee09cac2e04d7463bef089cadbc4625ba869130a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Tue, 3 Mar 2026 22:29:17 +0100 Subject: [PATCH] fix: emoji and profile suggestion UX/UI fixes --- src/components/chat/EmojiPickerDialog.tsx | 95 +++++++-- src/components/editor/EmojiSuggestionList.tsx | 17 +- .../editor/ProfileSuggestionList.tsx | 17 +- .../editor/SlashCommandSuggestionList.tsx | 14 +- src/components/editor/SuggestionPopover.tsx | 5 +- .../hooks/useSuggestionRenderer.test.tsx | 199 ++++++++++++++++++ .../editor/hooks/useSuggestionRenderer.tsx | 34 ++- 7 files changed, 316 insertions(+), 65 deletions(-) create mode 100644 src/components/editor/hooks/useSuggestionRenderer.test.tsx diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx index fcd0c68..830bcf7 100644 --- a/src/components/chat/EmojiPickerDialog.tsx +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -46,9 +46,12 @@ function updateReactionHistory(emoji: string): void { /** * EmojiPickerDialog - Searchable emoji picker for reactions * + * Layout: top (recently used) emojis → search bar → scrollable list + * This keeps the dialog close button away from the search input. + * * Features: + * - Recently used emojis shown as quick-pick buttons at the top * - Real-time search using FlexSearch with scrollable virtualized results - * - Frequently used emoji at top when no search query * - Supports both unicode and NIP-30 custom emoji * - Keyboard navigation (arrow keys, enter, escape) * - Tracks usage in localStorage @@ -94,7 +97,7 @@ export function EmojiPickerDialog({ } }, [open]); - // Get frequently used emojis from history + // Get frequently used emojis from history (sorted by use count) const frequentlyUsed = useMemo(() => { const history = getReactionHistory(); return Object.entries(history) @@ -102,6 +105,23 @@ export function EmojiPickerDialog({ .map(([emoji]) => emoji); }, []); + // Resolve top 8 recently used emojis to EmojiSearchResult for rendering + const topEmojis = useMemo(() => { + if (frequentlyUsed.length === 0) return []; + const results: EmojiSearchResult[] = []; + for (const emojiStr of frequentlyUsed.slice(0, 8)) { + if (emojiStr.startsWith(":") && emojiStr.endsWith(":")) { + const shortcode = emojiStr.slice(1, -1); + const custom = service.getByShortcode(shortcode); + if (custom) results.push(custom); + } else { + const found = searchResults.find((r) => r.url === emojiStr); + if (found) results.push(found); + } + } + return results; + }, [frequentlyUsed, searchResults, service]); + // When no search query: show recently used first, then fill with search results // When searching: show search results only const displayEmojis = useMemo(() => { @@ -216,7 +236,7 @@ export function EmojiPickerDialog({ /> )} - + :{item.shortcode}: @@ -227,26 +247,64 @@ export function EmojiPickerDialog({ return ( - - {/* Search input */} -
- - setSearchQuery(e.target.value)} - onKeyDown={handleKeyDown} - className="pl-9" - autoFocus - /> + + {/* Top emojis — recently used quick-picks. + This section also provides natural spacing for the dialog close (X) button, + which is absolutely positioned at top-right of the dialog. */} +
+ {topEmojis.length > 0 ? ( +
+ {topEmojis.map((emoji) => ( + + ))} +
+ ) : ( + + Emoji + + )} +
+ + {/* Search bar */} +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="pl-9" + autoFocus + /> +
{/* Scrollable emoji list */} {displayEmojis.length > 0 ? (
) : ( -
+
No emojis found
)} diff --git a/src/components/editor/EmojiSuggestionList.tsx b/src/components/editor/EmojiSuggestionList.tsx index 24bbf6e..3a6ea99 100644 --- a/src/components/editor/EmojiSuggestionList.tsx +++ b/src/components/editor/EmojiSuggestionList.tsx @@ -103,7 +103,7 @@ export const EmojiSuggestionList = forwardRef< /> )} - + :{item.shortcode}: @@ -112,23 +112,14 @@ export const EmojiSuggestionList = forwardRef< [items, selectedIndex, command], ); - if (items.length === 0) { - return ( -
- No emoji found -
- ); - } + if (items.length === 0) return null; - const listHeight = Math.max( - Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT, - ITEM_HEIGHT + 8, - ); + const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT; return (
{item.nip05 && ( -
+
{item.nip05}
)} @@ -117,23 +117,14 @@ export const ProfileSuggestionList = forwardRef< [items, selectedIndex, command], ); - if (items.length === 0) { - return ( -
- No profiles found -
- ); - } + if (items.length === 0) return null; - const listHeight = Math.max( - Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT, - ITEM_HEIGHT + 8, - ); + const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT; return (
- No commands available -
- ); - } + if (items.length === 0) return null; return (
{items.map((item, index) => (