mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 06:57:07 +02:00
fix: emoji and profile suggestion UX/UI fixes
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
199
src/components/editor/hooks/useSuggestionRenderer.test.tsx
Normal file
199
src/components/editor/hooks/useSuggestionRenderer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user