Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 10 additions & 20 deletions desktop/src/features/custom-emoji/ui/EmojiPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,22 @@
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);
// 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.
}
warmEmojiIndex();

/**
* Reach into the `em-emoji-picker` shadow root and disable spellcheck,
Expand Down
35 changes: 34 additions & 1 deletion desktop/src/features/messages/ui/useQuickReactionEmojis.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";

import { resolveQuickReactionEmojis } from "./useQuickReactionEmojis.ts";
import {
recordQuickReactionEmoji,
resolveQuickReactionEmojis,
useQuickReactionEmojis,
} from "./useQuickReactionEmojis.ts";

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.HTMLElement = dom.window.HTMLElement;

function entry(emoji) {
return { emoji };
Expand Down Expand Up @@ -40,3 +53,23 @@ test("quick reactions skip stale custom emoji before applying the limit", () =>
[":shipit:", "🔥", "👍", "❤️"],
);
});

test("recording an emoji updates mounted quick reactions immediately", async () => {
window.localStorage.clear();
const { act, cleanup, renderHook } = await import("@testing-library/react");

try {
const { result } = renderHook(() => useQuickReactionEmojis());
assert.deepEqual(result.current, ["👍", "❤️", "😂", "🎉"]);

act(() => recordQuickReactionEmoji("🔥"));
assert.deepEqual(result.current, ["🔥", "👍", "❤️", "😂"]);

act(() => recordQuickReactionEmoji("🎉"));
act(() => recordQuickReactionEmoji("🎉"));
assert.deepEqual(result.current, ["🎉", "🔥", "👍", "❤️"]);
} finally {
cleanup();
window.localStorage.clear();
}
});
43 changes: 41 additions & 2 deletions desktop/src/features/messages/ui/useQuickReactionEmojis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";

const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1";
const QUICK_REACTION_UPDATED_EVENT = "buzz:quick-reaction-emojis-updated";
const DEFAULT_QUICK_REACTIONS = ["👍", "❤️", "😂", "🎉"] as const;
const MAX_STORED_REACTIONS = 24;
const sessionQuickReactionEmojis = new Map<string, string[]>();
Expand Down Expand Up @@ -207,6 +208,24 @@ function getSessionQuickReactionEmojis(
return emojis;
}

function invalidateSessionQuickReactions(communityScope: string | null) {
const prefix = `${communityScope ?? "global"}:`;
for (const key of sessionQuickReactionEmojis.keys()) {
if (key.startsWith(prefix)) {
sessionQuickReactionEmojis.delete(key);
}
}
}

function notifyQuickReactionUpdate(communityScope: string | null) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(QUICK_REACTION_UPDATED_EVENT, {
detail: { communityScope },
}),
);
}

export function recordQuickReactionEmoji(emoji: string) {
const trimmed = emoji.trim();
if (!trimmed) return;
Expand All @@ -226,9 +245,9 @@ export function recordQuickReactionEmoji(emoji: string) {
});
}

// Keep the current hover tray stable; the stored recents apply on reload or
// when another tab updates this community's quick reactions.
writeQuickReactionEntries(entries, storageKey);
invalidateSessionQuickReactions(communityScope);
notifyQuickReactionUpdate(communityScope);
}

export function useQuickReactionEmojis(
Expand Down Expand Up @@ -263,13 +282,33 @@ export function useQuickReactionEmojis(
}
};

const handleLocalUpdate = (event: Event) => {
if (
event instanceof CustomEvent &&
event.detail?.communityScope === communityScope
) {
setEmojis(
getSessionQuickReactionEmojis(
limit,
communityScope,
customEmojiCacheKey,
),
);
}
};

window.addEventListener("storage", handleStorage);
window.addEventListener(QUICK_REACTION_UPDATED_EVENT, handleLocalUpdate);
setEmojis(
getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey),
);

return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(
QUICK_REACTION_UPDATED_EVENT,
handleLocalUpdate,
);
};
}, [customEmojiCacheKey, limit, communityScope]);

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