Skip to content
Open
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
27 changes: 2 additions & 25 deletions desktop/src/features/custom-emoji/ui/EmojiPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,9 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { init } from "emoji-mart";
import * as React from "react";

import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";

// emoji-mart builds its searchable index synchronously inside `init`, which
// `<Picker>` calls on mount — so the first reaction popover open paid the full
// ~1.8k-emoji index build and froze the cursor. Warm `init({ data })` once at
// idle so the index is prebuilt; `init` is a no-op after the first call (its
// `Data` singleton guards the rebuild), so the Picker's mount-time `init` skips
// the heavy work. Search still reads the prebuilt index — no first-keystroke
// hitch. Module-level so it fires regardless of when a picker first mounts.
let warmStarted = false;
function warmEmojiIndex() {
if (warmStarted) {
return;
}
warmStarted = true;
const warm = () => void init({ data });
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(warm, { timeout: 1_500 });
} else {
globalThis.setTimeout(warm, 250);
}
}
warmEmojiIndex();
import { emojiMartData } from "@/features/custom-emoji/ui/emojiMartPrewarm";

/**
* Reach into the `em-emoji-picker` shadow root and disable spellcheck,
Expand Down Expand Up @@ -128,7 +105,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({
<Picker
autoFocus={autoFocus}
custom={custom}
data={data}
data={emojiMartData}
maxFrequentRows={2}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
// Standard emoji carry a `native` glyph. Custom emoji don't — emit
Expand Down
35 changes: 35 additions & 0 deletions desktop/src/features/custom-emoji/ui/emojiMartPrewarm.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";

const entries = new Map([
["emoji-mart.frequently", "{}"],
["emoji-mart.last", "thumbsup"],
]);
const idleCalls = [];
const initCalls = [];

globalThis.window = {
localStorage: {
getItem: (key) => entries.get(key) ?? null,
removeItem: (key) => entries.delete(key),
},
requestIdleCallback: (callback, options) => {
idleCalls.push({ callback, options });
return 1;
},
};
globalThis.__BUZZ_TEST_EMOJI_MART_INIT__ = (...args) => initCalls.push(args);

const { emojiMartData } = await import("./emojiMartPrewarm.ts");

test("normalizes poisoned recents before scheduling the emoji index prewarm", () => {
assert.equal(entries.has("emoji-mart.frequently"), false);
assert.equal(entries.has("emoji-mart.last"), false);
assert.equal(initCalls.length, 0);
assert.equal(idleCalls.length, 1);
assert.deepEqual(idleCalls[0].options, { timeout: 1_500 });

idleCalls[0].callback();
assert.equal(initCalls.length, 1);
assert.equal(initCalls[0][0].data, emojiMartData);
});
28 changes: 28 additions & 0 deletions desktop/src/features/custom-emoji/ui/emojiMartPrewarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import data from "@emoji-mart/data";
import { init } from "emoji-mart";

// emoji-mart treats a persisted empty object differently from a missing index:
// a missing index gets its default Frequent row, while `{}` removes the entire
// category from the module-global picker data. Normalize that poisoned state
// before the first picker initializes.
try {
if (window.localStorage.getItem("emoji-mart.frequently") === "{}") {
window.localStorage.removeItem("emoji-mart.frequently");
window.localStorage.removeItem("emoji-mart.last");
}
} catch {
// emoji-mart also tolerates unavailable storage; picker selection still works.
}

// emoji-mart synchronously builds its search index inside `init`. Warm it at
// idle so the first picker open does not pay that cost. This module also owns
// the data passed to every Picker, making the prewarm part of that import path
// rather than a disconnected best-effort call.
const warm = () => void init({ data });
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
window.requestIdleCallback(warm, { timeout: 1_500 });
} else {
globalThis.setTimeout(warm, 250);
}

export { data as emojiMartData };
2 changes: 1 addition & 1 deletion desktop/test-loader-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function resolveSourcePath(basePath) {
const stubModules = new Map([
[
"emoji-mart",
"export const init = () => {};\n" +
"export const init = (...args) => globalThis.__BUZZ_TEST_EMOJI_MART_INIT__?.(...args);\n" +
"export const SearchIndex = { search: async () => [] };\n" +
"export default {};\n",
],
Expand Down
35 changes: 35 additions & 0 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,41 @@ test("message quick reaction tray stays neutral after selecting a tray emoji", a
);
});

test("emoji picker keeps Frequently used live within the app session", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("emoji-mart.frequently", "{}");
window.localStorage.removeItem("emoji-mart.last");
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");

const row = reactionTargetRow(page);
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();

let picker = page.locator("em-emoji-picker");
await expect(
picker.getByRole("button", { name: "Frequently used" }),
).toBeVisible();

await picker.locator("input[type='search']").fill("unicorn");
await picker.getByRole("button", { name: "🦄" }).first().click();
await expect(row.getByLabel("Toggle 🦄 reaction")).toBeVisible();

await row.hover();
await row.getByLabel("Open reactions").click();
picker = page.locator("em-emoji-picker");
await picker.getByRole("button", { name: "Frequently used" }).click();
await expect(
picker.getByRole("button", { name: "🦄" }).first(),
).toBeVisible();
});

test("reacting with a custom emoji renders via the loopback media proxy", async ({
page,
}) => {
Expand Down
Loading