Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions desktop/src/features/messages/lib/useEmojiAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { init, SearchIndex } from "emoji-mart";
import data from "@emoji-mart/data";

import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import { fuzzyStandardEmoji, rankByShortcode } from "@/shared/lib/emojiSearch";
import {
fuzzyStandardEmoji,
rankByShortcode,
rankShortcodeMatchesFirst,
} from "@/shared/lib/emojiSearch";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import type { AutocompleteEdit } from "./useRichTextEditor";

Expand All @@ -18,7 +22,7 @@ export type EmojiSuggestion = {

const EMOJI_DEBOUNCE_MS = 120;
const MIN_QUERY_LENGTH = 2;
const MAX_RESULTS = 8;
const UNLIMITED_RESULTS = Number.POSITIVE_INFINITY;

init({ data });

Expand Down Expand Up @@ -81,15 +85,18 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) {
emojiQuery,
customEmojiRef.current,
(e) => e.shortcode,
MAX_RESULTS,
UNLIMITED_RESULTS,
).map((e) => ({
id: e.shortcode,
name: e.shortcode,
native: "",
url: rewriteRelayUrl(e.url),
}));

SearchIndex.search(emojiQuery)
SearchIndex.search(emojiQuery, {
caller: "useEmojiAutocomplete",
maxResults: UNLIMITED_RESULTS,
Comment thread
klopez4212 marked this conversation as resolved.
})
.then(
(
results: Array<{
Expand All @@ -106,23 +113,28 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) {
native: emoji.skins[0]?.native ?? "",
}))
.filter((e) => e.native !== "");
// Top up remaining slots with fuzzy shortcode matches emoji-mart
// missed — its token-prefix search can't cross `_` (so `pointup`
// finds nothing). Skip ids already shown to avoid duplicates.
// Add fuzzy shortcode matches emoji-mart missed — its token-prefix
// search can't cross `_` (so `pointup` finds nothing). Skip ids
// already shown to avoid duplicates.
const shown = new Set<string>(
[...customMatches, ...standard].map((e) => e.id),
);
const fuzzy: EmojiSuggestion[] = fuzzyStandardEmoji(
emojiQuery,
MAX_RESULTS - customMatches.length - standard.length,
UNLIMITED_RESULTS,
shown,
).map((e) => ({ id: e.id, name: e.name, native: e.native }));
// Custom emoji first (community-specific), then standard, then fuzzy.
const merged = [...customMatches, ...standard, ...fuzzy].slice(
0,
MAX_RESULTS,
// Rank exact/prefix shortcode matches across custom and standard emoji
// before semantic and weaker matches (for example, `joy` before
// `bufo_joy`). Keep emoji-mart's name/keyword results ahead of loose
// substring and subsequence matches.
setSuggestions(
rankShortcodeMatchesFirst(
emojiQuery,
[...standard, ...customMatches, ...fuzzy],
(emoji) => emoji.id,
),
);
setSuggestions(merged);
setEmojiSelectedIndex(0);
},
)
Expand Down
94 changes: 56 additions & 38 deletions desktop/src/features/messages/ui/EmojiAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import * as React from "react";

import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete";
import { cn } from "@/shared/lib/cn";
import {
type ListVirtualizer,
VirtualizedList,
} from "@/shared/ui/VirtualizedList";
import {
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
POPOVER_SHADOW_STYLE,
Expand All @@ -21,15 +25,21 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({
onSelect,
position = "above",
}: EmojiAutocompleteProps) {
const listRef = React.useRef<HTMLDivElement>(null);
const listVirtualizerRef = React.useRef<ListVirtualizer | null>(null);

React.useEffect(() => {
const activeItem = listRef.current?.children[selectedIndex] as
| HTMLElement
| undefined;
activeItem?.scrollIntoView({ block: "nearest" });
listVirtualizerRef.current?.scrollToIndex(selectedIndex, {
align: "auto",
});
}, [selectedIndex]);

const handleVirtualizer = React.useCallback(
(virtualizer: ListVirtualizer) => {
listVirtualizerRef.current = virtualizer;
},
[],
);

if (suggestions.length === 0) {
return null;
}
Expand All @@ -43,47 +53,55 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({
>
<div
className={cn(
"max-h-48 overflow-y-auto rounded-xl p-1",
"rounded-xl p-1",
POPOVER_CUSTOM_ENTER_MOTION_CLASS,
position === "below"
? "origin-top slide-in-from-top-1"
: "origin-bottom slide-in-from-bottom-1",
POPOVER_SURFACE_CLASS,
)}
ref={listRef}
data-testid="emoji-autocomplete"
style={POPOVER_SHADOW_STYLE}
>
{suggestions.map((suggestion, index) => (
<button
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/50",
)}
key={suggestion.id}
onMouseDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}
tabIndex={-1}
type="button"
>
{suggestion.url ? (
<img
alt={`:${suggestion.id}:`}
src={suggestion.url}
className="h-5 w-5 object-contain"
draggable={false}
/>
) : (
<span className="text-lg leading-none">{suggestion.native}</span>
)}
<span className="truncate text-muted-foreground">
:{suggestion.id}:
</span>
</button>
))}
<VirtualizedList
className="max-h-48"
estimateSize={36}
getItemKey={(suggestion) => suggestion.id}
items={suggestions}
onVirtualizer={handleVirtualizer}
renderItem={(suggestion, index) => (
<button
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/50",
)}
onMouseDown={(event) => {
event.preventDefault();
onSelect(suggestion);
}}
tabIndex={-1}
type="button"
>
{suggestion.url ? (
<img
alt={`:${suggestion.id}:`}
src={suggestion.url}
className="h-5 w-5 object-contain"
draggable={false}
/>
) : (
<span className="text-lg leading-none">
{suggestion.native}
</span>
)}
<span className="truncate text-muted-foreground">
:{suggestion.id}:
</span>
</button>
)}
/>
</div>
</div>
);
Expand Down
30 changes: 30 additions & 0 deletions desktop/src/shared/lib/emojiSearch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fuzzyStandardEmoji,
normalizeShortcode,
rankByShortcode,
rankShortcodeMatchesFirst,
scoreShortcodeMatch,
} from "./emojiSearch.ts";

Expand Down Expand Up @@ -77,6 +78,35 @@ test("rankByShortcode respects the limit", () => {
assert.equal(ranked.length, 2);
});

test("exact shortcode matches rank ahead of weaker custom shortcode matches", () => {
const items = [
{ code: "bufo_joy", source: "custom" },
{ code: "joy", source: "standard" },
{ code: "joy_cat", source: "custom" },
{ code: "face_with_tears_of_joy", source: "standard" },
];
const ranked = rankShortcodeMatchesFirst("joy", items, (item) => item.code);

assert.deepEqual(
ranked.map((item) => item.code),
["joy", "joy_cat", "bufo_joy", "face_with_tears_of_joy"],
);
});

test("semantic results stay ahead of loose shortcode matches", () => {
const items = [
{ code: "frowning_face", source: "semantic" },
{ code: "sandwich", source: "custom" },
{ code: "sad", source: "standard" },
];
const ranked = rankShortcodeMatchesFirst("sad", items, (item) => item.code);

assert.deepEqual(
ranked.map((item) => item.code),
["sad", "frowning_face", "sandwich"],
);
});

test("fuzzyStandardEmoji surfaces point_up for `pointup`", () => {
const hits = fuzzyStandardEmoji("pointup", 8, new Set());
const ids = hits.map((e) => e.id);
Expand Down
27 changes: 27 additions & 0 deletions desktop/src/shared/lib/emojiSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,33 @@ export function rankByShortcode<T>(
return scored.slice(0, limit).map((s) => s.item);
}

/**
* Place exact and prefix shortcode matches ahead of items that only matched an
* emoji name or keyword. This lets an exact standard emoji like `joy` beat a
* weaker custom shortcode match such as `bufo_joy`, while retaining emoji-mart's
* order for name- and keyword-only results ahead of loose shortcode matches.
*/
export function rankShortcodeMatchesFirst<T>(
query: string,
items: readonly T[],
shortcodeOf: (item: T) => string,
): T[] {
const strongShortcodeMatches = rankByShortcode(
query,
items,
shortcodeOf,
Number.POSITIVE_INFINITY,
).filter((item) => {
const match = scoreShortcodeMatch(query, shortcodeOf(item));
return match !== null && match.tier <= TIER_PREFIX;
});
const matchedItems = new Set(strongShortcodeMatches);
return [
...strongShortcodeMatches,
...items.filter((item) => !matchedItems.has(item)),
Comment thread
klopez4212 marked this conversation as resolved.
];
}

export interface StandardEmoji {
id: string;
name: string;
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,8 +913,8 @@ function createMockRelayMembershipEvent(): RelayEvent {
* sets from distinct pubkeys so the e2e exercises the union/collapse path, not
* a single relay-owned set. `:buzz:` is the stable shortcode exercised by
* custom-emoji.spec.ts (claimed by BOTH members with different URLs, so the
* palette must collapse it to one deterministic winner); `:narf:` proves a
* second member's distinct emoji unions in.
* palette must collapse it to one deterministic winner); `:narf:` and
* `:bufo_joy:` prove a second member's distinct emoji unions in.
*/
function createMockCustomEmojiSetEvents(): RelayEvent[] {
return [
Expand All @@ -941,6 +941,7 @@ function createMockCustomEmojiSetEvents(): RelayEvent[] {
// member B claims :buzz: with a DIFFERENT url — unionCustomEmoji must
// collapse it to one deterministic winner, never expose two URLs.
["emoji", "buzz", "https://example.com/e2e/buzz-b.png"],
["emoji", "bufo_joy", "https://example.com/e2e/bufo-joy.png"],
],
"b".repeat(64),
),
Expand Down
52 changes: 52 additions & 0 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { expect, test } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";

import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";

// Custom-emoji end-to-end guard.
//
Expand Down Expand Up @@ -77,6 +80,55 @@ test("typing a known :shortcode: renders an inline emoji node in the composer",
await expect(input).not.toContainText(`:${SHORTCODE}:`);
});

test("emoji autocomplete ranks an exact standard shortcode before a custom substring", async ({
page,
}, testInfo) => {
await openGeneral(page);

const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(":joy");

const autocomplete = page.getByTestId("emoji-autocomplete");
await expect(autocomplete).toBeVisible();
const labels = await autocomplete.locator("button").allTextContents();
expect(labels[0]).toContain(":joy:");
expect(
labels.findIndex((label) => label.includes(":bufo_joy:")),
).toBeGreaterThan(0);

const screenshotDir = path.resolve(
"test-results/emoji-autocomplete-screenshots",
);
fs.mkdirSync(screenshotDir, { recursive: true });
const screenshotPath = path.join(
screenshotDir,
"joy-exact-before-custom-substring.png",
);
await waitForAnimations(page);
await autocomplete.screenshot({ path: screenshotPath });
await testInfo.attach("joy-exact-before-custom-substring", {
path: screenshotPath,
contentType: "image/png",
});
});

test("emoji autocomplete keeps semantic matches ahead of loose shortcode fallbacks", async ({
page,
}) => {
await openGeneral(page);

const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(":sad");

const autocomplete = page.getByTestId("emoji-autocomplete");
await expect(autocomplete).toBeVisible();
await expect(autocomplete.locator("button").first()).not.toContainText(
":sandwich:",
);
});

test("custom emoji deletes as a single unit (like a built-in emoji)", async ({
page,
}) => {
Expand Down
Loading