From f5cf258138767637d0157110e13fab61ef40dd99 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 16:51:08 +0100 Subject: [PATCH 1/2] Improve emoji autocomplete matching Signed-off-by: kenny lopez --- .../messages/lib/useEmojiAutocomplete.ts | 36 ++++++++++++------- .../messages/ui/EmojiAutocomplete.tsx | 1 + desktop/src/shared/lib/emojiSearch.test.mjs | 16 +++++++++ desktop/src/shared/lib/emojiSearch.ts | 24 +++++++++++++ desktop/src/testing/e2eBridge.ts | 5 +-- desktop/tests/e2e/custom-emoji.spec.ts | 36 +++++++++++++++++++ 6 files changed, 103 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 53ef44b28d..424652f6b9 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -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"; @@ -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 }); @@ -81,7 +85,7 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { emojiQuery, customEmojiRef.current, (e) => e.shortcode, - MAX_RESULTS, + UNLIMITED_RESULTS, ).map((e) => ({ id: e.shortcode, name: e.shortcode, @@ -89,7 +93,10 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { url: rewriteRelayUrl(e.url), })); - SearchIndex.search(emojiQuery) + SearchIndex.search(emojiQuery, { + caller: "useEmojiAutocomplete", + maxResults: UNLIMITED_RESULTS, + }) .then( ( results: Array<{ @@ -106,23 +113,26 @@ 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( [...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 weaker matches (for example, `joy` before `bufo_joy`). + setSuggestions( + rankShortcodeMatchesFirst( + emojiQuery, + [...customMatches, ...standard, ...fuzzy], + (emoji) => emoji.id, + ), ); - setSuggestions(merged); setEmojiSelectedIndex(0); }, ) diff --git a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx index 3b6fa3a2d2..6ccf1b7fc2 100644 --- a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx +++ b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx @@ -50,6 +50,7 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ : "origin-bottom slide-in-from-bottom-1", POPOVER_SURFACE_CLASS, )} + data-testid="emoji-autocomplete" ref={listRef} style={POPOVER_SHADOW_STYLE} > diff --git a/desktop/src/shared/lib/emojiSearch.test.mjs b/desktop/src/shared/lib/emojiSearch.test.mjs index 93cb1e286a..80271b8fcb 100644 --- a/desktop/src/shared/lib/emojiSearch.test.mjs +++ b/desktop/src/shared/lib/emojiSearch.test.mjs @@ -5,6 +5,7 @@ import { fuzzyStandardEmoji, normalizeShortcode, rankByShortcode, + rankShortcodeMatchesFirst, scoreShortcodeMatch, } from "./emojiSearch.ts"; @@ -77,6 +78,21 @@ 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("fuzzyStandardEmoji surfaces point_up for `pointup`", () => { const hits = fuzzyStandardEmoji("pointup", 8, new Set()); const ids = hits.map((e) => e.id); diff --git a/desktop/src/shared/lib/emojiSearch.ts b/desktop/src/shared/lib/emojiSearch.ts index 2a0d74b9bb..39d10b5e07 100644 --- a/desktop/src/shared/lib/emojiSearch.ts +++ b/desktop/src/shared/lib/emojiSearch.ts @@ -114,6 +114,30 @@ export function rankByShortcode( return scored.slice(0, limit).map((s) => s.item); } +/** + * Place 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. + */ +export function rankShortcodeMatchesFirst( + query: string, + items: readonly T[], + shortcodeOf: (item: T) => string, +): T[] { + const shortcodeMatches = rankByShortcode( + query, + items, + shortcodeOf, + Number.POSITIVE_INFINITY, + ); + const matchedItems = new Set(shortcodeMatches); + return [ + ...shortcodeMatches, + ...items.filter((item) => !matchedItems.has(item)), + ]; +} + export interface StandardEmoji { id: string; name: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..4cfc553df1 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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 [ @@ -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), ), diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index aa345570f9..2908b7c9b9 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -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. // @@ -77,6 +80,39 @@ 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("custom emoji deletes as a single unit (like a built-in emoji)", async ({ page, }) => { From 246d933cb5e8714abc5cd23ecd79eefd74f868a4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 29 Jul 2026 19:36:06 +0100 Subject: [PATCH 2/2] Address emoji autocomplete review feedback Signed-off-by: kenny lopez --- .../messages/lib/useEmojiAutocomplete.ts | 6 +- .../messages/ui/EmojiAutocomplete.tsx | 93 +++++++++++-------- desktop/src/shared/lib/emojiSearch.test.mjs | 14 +++ desktop/src/shared/lib/emojiSearch.ts | 19 ++-- desktop/tests/e2e/custom-emoji.spec.ts | 16 ++++ 5 files changed, 100 insertions(+), 48 deletions(-) diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 424652f6b9..57e458bc91 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -125,11 +125,13 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { shown, ).map((e) => ({ id: e.id, name: e.name, native: e.native })); // Rank exact/prefix shortcode matches across custom and standard emoji - // before weaker matches (for example, `joy` before `bufo_joy`). + // 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, - [...customMatches, ...standard, ...fuzzy], + [...standard, ...customMatches, ...fuzzy], (emoji) => emoji.id, ), ); diff --git a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx index 6ccf1b7fc2..d44933aef1 100644 --- a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx +++ b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx @@ -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, @@ -21,15 +25,21 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ onSelect, position = "above", }: EmojiAutocompleteProps) { - const listRef = React.useRef(null); + const listVirtualizerRef = React.useRef(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; } @@ -43,7 +53,7 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ >
- {suggestions.map((suggestion, index) => ( - - ))} + suggestion.id} + items={suggestions} + onVirtualizer={handleVirtualizer} + renderItem={(suggestion, index) => ( + + )} + />
); diff --git a/desktop/src/shared/lib/emojiSearch.test.mjs b/desktop/src/shared/lib/emojiSearch.test.mjs index 80271b8fcb..01a52293f5 100644 --- a/desktop/src/shared/lib/emojiSearch.test.mjs +++ b/desktop/src/shared/lib/emojiSearch.test.mjs @@ -93,6 +93,20 @@ test("exact shortcode matches rank ahead of weaker custom shortcode matches", () ); }); +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); diff --git a/desktop/src/shared/lib/emojiSearch.ts b/desktop/src/shared/lib/emojiSearch.ts index 39d10b5e07..ec4ea529b5 100644 --- a/desktop/src/shared/lib/emojiSearch.ts +++ b/desktop/src/shared/lib/emojiSearch.ts @@ -115,25 +115,28 @@ export function rankByShortcode( } /** - * Place 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. + * 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( query: string, items: readonly T[], shortcodeOf: (item: T) => string, ): T[] { - const shortcodeMatches = rankByShortcode( + const strongShortcodeMatches = rankByShortcode( query, items, shortcodeOf, Number.POSITIVE_INFINITY, - ); - const matchedItems = new Set(shortcodeMatches); + ).filter((item) => { + const match = scoreShortcodeMatch(query, shortcodeOf(item)); + return match !== null && match.tier <= TIER_PREFIX; + }); + const matchedItems = new Set(strongShortcodeMatches); return [ - ...shortcodeMatches, + ...strongShortcodeMatches, ...items.filter((item) => !matchedItems.has(item)), ]; } diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 2908b7c9b9..ae20aaef25 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -113,6 +113,22 @@ test("emoji autocomplete ranks an exact standard shortcode before a custom subst }); }); +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, }) => {