fix: emoji and profile suggestion UX/UI fixes

This commit is contained in:
Alejandro Gómez
2026-03-03 22:29:17 +01:00
parent 15fe8b6c59
commit ee09cac2e0
7 changed files with 316 additions and 65 deletions

View File

@@ -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<EmojiSearchResult[]>(() => {
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({
/>
)}
</span>
<span className="truncate text-sm text-popover-foreground/80">
<span className="truncate text-sm text-popover-foreground">
:{item.shortcode}:
</span>
</button>
@@ -227,26 +247,64 @@ export function EmojiPickerDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xs p-4 gap-2">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search emojis..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-9"
autoFocus
/>
<DialogContent className="max-w-xs p-0 gap-0 overflow-hidden">
{/* 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. */}
<div className="flex items-center gap-1 px-3 pt-3 pb-2 pr-10 min-h-[48px]">
{topEmojis.length > 0 ? (
<div
className="flex items-center gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{topEmojis.map((emoji) => (
<button
key={emoji.shortcode}
onClick={() => handleEmojiClick(emoji)}
title={`:${emoji.shortcode}:`}
className="flex size-8 items-center justify-center rounded hover:bg-muted/60 transition-colors flex-shrink-0"
>
{emoji.source === "unicode" ? (
<span className="text-lg leading-none">{emoji.url}</span>
) : (
<img
src={emoji.url}
alt={`:${emoji.shortcode}:`}
className="size-6 object-contain"
loading="lazy"
/>
)}
</button>
))}
</div>
) : (
<span className="text-xs font-medium text-muted-foreground select-none">
Emoji
</span>
)}
</div>
{/* Search bar */}
<div className="px-3 pb-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search emojis..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-9"
autoFocus
/>
</div>
</div>
{/* Scrollable emoji list */}
{displayEmojis.length > 0 ? (
<div
role="listbox"
className="rounded-md border border-border/50 bg-popover text-popover-foreground overflow-hidden"
className="border-t border-border/50 bg-popover text-popover-foreground"
>
<Virtuoso
ref={virtuosoRef}
@@ -257,11 +315,12 @@ export function EmojiPickerDialog({
overflow:
displayEmojis.length <= MAX_VISIBLE ? "hidden" : "auto",
}}
className="[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/60 [&::-webkit-scrollbar-track]:bg-transparent"
itemContent={renderItem}
/>
</div>
) : (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground border-t border-border/50">
No emojis found
</div>
)}

View File

@@ -103,7 +103,7 @@ export const EmojiSuggestionList = forwardRef<
/>
)}
</span>
<span className="truncate text-sm text-popover-foreground/80">
<span className="truncate text-sm text-popover-foreground">
:{item.shortcode}:
</span>
</button>
@@ -112,23 +112,14 @@ export const EmojiSuggestionList = forwardRef<
[items, selectedIndex, command],
);
if (items.length === 0) {
return (
<div className="rounded-md border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
No emoji found
</div>
);
}
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 (
<div
role="listbox"
className="w-[260px] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
className="w-[260px] max-w-full rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
>
<Virtuoso
ref={virtuosoRef}

View File

@@ -106,7 +106,7 @@ export const ProfileSuggestionList = forwardRef<
<UserName pubkey={item.pubkey} />
</div>
{item.nip05 && (
<div className="truncate text-xs text-popover-foreground/60">
<div className="truncate text-xs text-muted-foreground">
{item.nip05}
</div>
)}
@@ -117,23 +117,14 @@ export const ProfileSuggestionList = forwardRef<
[items, selectedIndex, command],
);
if (items.length === 0) {
return (
<div className="rounded-md border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
No profiles found
</div>
);
}
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 (
<div
role="listbox"
className="w-[320px] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
className="w-[320px] max-w-full rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
>
<Virtuoso
ref={virtuosoRef}

View File

@@ -69,19 +69,13 @@ export const SlashCommandSuggestionList = forwardRef<
setSelectedIndex(0);
}, [items]);
if (items.length === 0) {
return (
<div className="border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
No commands available
</div>
);
}
if (items.length === 0) return null;
return (
<div
ref={listRef}
role="listbox"
className="max-h-[300px] w-full max-w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
className="max-h-[300px] w-full max-w-[320px] overflow-y-auto rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md"
>
{items.map((item, index) => (
<button
@@ -94,12 +88,12 @@ export const SlashCommandSuggestionList = forwardRef<
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>
<Terminal className="size-5 md:size-4 flex-shrink-0 text-popover-foreground/60" />
<Terminal className="size-5 md:size-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium font-mono">
/{item.name}
</div>
<div className="truncate text-xs text-popover-foreground/60">
<div className="truncate text-xs text-muted-foreground">
{item.description}
</div>
</div>

View File

@@ -81,7 +81,10 @@ export function SuggestionPopover({
}, [clientRect, refs, update]);
return createPortal(
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 50 }}>
<div
ref={refs.setFloating}
style={{ ...floatingStyles, zIndex: 50, maxWidth: "calc(100vw - 16px)" }}
>
{children}
</div>,
document.body,

View File

@@ -0,0 +1,199 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeAll } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { forwardRef, useImperativeHandle } from "react";
import {
useSuggestionRenderer,
type SuggestionListHandle,
type SuggestionListProps,
} from "./useSuggestionRenderer";
// floating-ui requires layout APIs jsdom lacks
beforeAll(() => {
const rect = {
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => ({}),
};
HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect;
Range.prototype.getBoundingClientRect = () => rect as DOMRect;
Range.prototype.getClientRects = (() => []) as any;
document.elementFromPoint = (() => null) as any;
});
// Minimal suggestion list component
const MockList = forwardRef<SuggestionListHandle, SuggestionListProps<string>>(
({ items }, ref) => {
useImperativeHandle(ref, () => ({
onKeyDown: () => false,
}));
return <div>{items.join(",")}</div>;
},
);
MockList.displayName = "MockList";
/** Build the minimal props shape Tiptap passes to onStart/onUpdate/onExit */
function makeProps(items: string[]) {
return {
items,
command: vi.fn(),
clientRect: () => new DOMRect(),
editor: {} as any,
range: { from: 0, to: 0 },
query: "",
text: "",
decorationNode: null,
event: null,
};
}
describe("useSuggestionRenderer", () => {
it("renders portal when suggestion starts with items", () => {
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps(["alice", "bob"]));
});
expect(result.current.portal).not.toBeNull();
});
it("does not render portal when items array is empty", () => {
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps([]));
});
// Empty items → no popup (prevents "No profiles found" flash)
expect(result.current.portal).toBeNull();
});
it("hides portal after onExit", () => {
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps(["alice"]));
});
expect(result.current.portal).not.toBeNull();
act(() => {
handlers.onExit!(makeProps([]));
});
expect(result.current.portal).toBeNull();
});
it("ignores stale onUpdate with items after onExit (async race condition)", () => {
// Reproduce the bug:
// 1. User types "@alice" → suggestion shows
// 2. User selects a profile → onExit fires
// 3. A stale async items() resolves and fires onUpdate → popup must NOT reopen
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps(["alice"]));
});
expect(result.current.portal).not.toBeNull();
// User completes the suggestion
act(() => {
handlers.onExit!(makeProps([]));
});
expect(result.current.portal).toBeNull();
// Stale async onUpdate arrives after exit
act(() => {
handlers.onUpdate!(makeProps(["bob"]));
});
// Portal must remain closed
expect(result.current.portal).toBeNull();
});
it("ignores stale onUpdate with empty items after onExit", () => {
// Reproduce: Tiptap fires onUpdate([]) before onExit when space is pressed
// → "No profiles found" must NOT appear
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps(["alice"]));
});
act(() => {
handlers.onExit!(makeProps([]));
});
// onUpdate with empty items fires (stale Tiptap cycle)
act(() => {
handlers.onUpdate!(makeProps([]));
});
expect(result.current.portal).toBeNull();
});
it("updates items while suggestion is active", () => {
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps(["alice"]));
});
expect(result.current.portal).not.toBeNull();
// User keeps typing → items are refined
act(() => {
handlers.onUpdate!(makeProps(["alice-updated"]));
});
expect(result.current.portal).not.toBeNull();
});
it("hides portal when onUpdate delivers empty items during active session", () => {
// When user's query genuinely yields no results, hide the popup
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
act(() => {
handlers.onStart!(makeProps(["alice"]));
});
expect(result.current.portal).not.toBeNull();
act(() => {
handlers.onUpdate!(makeProps([]));
});
// No items → hide the popup (cleaner than showing "No results")
expect(result.current.portal).toBeNull();
});
it("allows a new suggestion session after a previous one exits", () => {
const { result } = renderHook(() => useSuggestionRenderer(MockList as any));
const handlers = result.current.render();
// First session
act(() => {
handlers.onStart!(makeProps(["alice"]));
});
act(() => {
handlers.onExit!(makeProps([]));
});
expect(result.current.portal).toBeNull();
// Second session — user types @ again
const handlers2 = result.current.render();
act(() => {
handlers2.onStart!(makeProps(["bob"]));
});
expect(result.current.portal).not.toBeNull();
});
});

View File

@@ -61,10 +61,16 @@ export function useSuggestionRenderer<T>(
const onModEnterRef = useRef(options?.onModEnter);
onModEnterRef.current = options?.onModEnter;
// Track whether a suggestion session is currently active.
// Guards against stale onUpdate calls that arrive after onExit fires
// (e.g. async items() resolving after the suggestion was dismissed).
const isActiveRef = useRef(false);
// Stable render factory — uses setState which is guaranteed stable by React
const render = useCallback(
(): ReturnType<NonNullable<SuggestionOptions["render"]>> => ({
onStart: (props) => {
isActiveRef.current = true;
setState({
items: props.items as T[],
command: props.command as (item: T) => void,
@@ -73,6 +79,9 @@ export function useSuggestionRenderer<T>(
},
onUpdate: (props) => {
// Ignore updates that arrive after the suggestion session ended.
// This prevents "No profiles found" from reappearing after autocomplete.
if (!isActiveRef.current) return;
setState({
items: props.items as T[],
command: props.command as (item: T) => void,
@@ -100,6 +109,7 @@ export function useSuggestionRenderer<T>(
},
onExit: () => {
isActiveRef.current = false;
setState(null);
},
}),
@@ -108,16 +118,20 @@ export function useSuggestionRenderer<T>(
const placement = options?.placement ?? "bottom-start";
const portal = state ? (
<SuggestionPopover clientRect={state.clientRect} placement={placement}>
<Component
ref={componentRef}
items={state.items}
command={state.command}
onClose={() => setState(null)}
/>
</SuggestionPopover>
) : null;
// Only render the portal when there are items to show.
// When items is empty the popup is hidden — this prevents a "No results found"
// message from flashing briefly when Tiptap fires onUpdate([]) just before onExit.
const portal =
state && state.items.length > 0 ? (
<SuggestionPopover clientRect={state.clientRect} placement={placement}>
<Component
ref={componentRef}
items={state.items}
command={state.command}
onClose={() => setState(null)}
/>
</SuggestionPopover>
) : null;
return { render, portal };
}