diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 701c767b329..419294686b3 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -117,6 +117,7 @@ export default defineConfig({
"**/human-edit-agent-content.spec.ts",
"**/empty-edit-delete.spec.ts",
"**/reaction-order.spec.ts",
+ "**/quick-reaction-sharing.spec.ts",
"**/reaction-names.spec.ts",
"**/inbox-reactions.spec.ts",
"**/inbox-edit.spec.ts",
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index da0fbf65c49..1832109fafb 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -22,6 +22,7 @@ import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
import { CommunityThemeController } from "@/shared/theme/CommunityThemeController";
import { useReloadShortcut } from "@/app/useReloadShortcut";
import { useCloseWindowShortcut } from "@/app/useCloseWindowShortcut";
+import { QuickReactionProvider } from "@/features/messages/ui/QuickReactionProvider";
import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
import { useAppOnboardingState } from "@/features/onboarding/hooks";
@@ -299,9 +300,11 @@ function CommunityIdentityReplacementSentinel({
}
function AppReady({
+ communityScope,
isSharedIdentity,
isCommunitySwitch,
}: {
+ communityScope: string | null;
isSharedIdentity: boolean;
isCommunitySwitch: boolean;
}) {
@@ -343,9 +346,11 @@ function AppReady({
})
}
>
-
-
-
+
+
+
+
+
);
}
@@ -636,6 +641,7 @@ function CommunityApp({
/>
- quickReactionEmojis
- .map((emoji) => ({
- customEmojiUrl: reactionEmojiUrl(emoji, customEmoji),
- emoji,
- }))
- .filter(
- (item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl,
- ),
- [customEmoji, quickReactionEmojis],
- );
+ const quickReactionItems = useQuickReactionItems();
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
diff --git a/desktop/src/features/messages/ui/QuickReactionProvider.test.mjs b/desktop/src/features/messages/ui/QuickReactionProvider.test.mjs
new file mode 100644
index 00000000000..087099c1228
--- /dev/null
+++ b/desktop/src/features/messages/ui/QuickReactionProvider.test.mjs
@@ -0,0 +1,487 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const STORAGE_KEY = "buzz.quick-reaction-emojis.v1";
+const storageKey = (scope) => (scope ? `${STORAGE_KEY}:${scope}` : STORAGE_KEY);
+const entry = (emoji, count = 1, lastUsedAt = 1) => ({
+ emoji,
+ count,
+ lastUsedAt,
+});
+const emojiNames = (items) => items.map((item) => item.emoji);
+
+async function harness(
+ t,
+ {
+ scope = "community-a",
+ palette = [],
+ recents = [],
+ actionBars = false,
+ } = {},
+) {
+ const dom = new JSDOM(
+ "",
+ {
+ url: "https://buzz.example.test",
+ },
+ );
+ const globals = {
+ window: dom.window,
+ document: dom.window.document,
+ localStorage: dom.window.localStorage,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ };
+ const originals = new Map(
+ Object.keys(globals).map((key) => [
+ key,
+ Object.getOwnPropertyDescriptor(globalThis, key),
+ ]),
+ );
+ Object.assign(globalThis, globals);
+ let root;
+ const clients = [];
+ let act;
+ t.after(async () => {
+ try {
+ if (root) await act(async () => root.unmount());
+ for (const client of clients) client.clear();
+ } finally {
+ t.mock.restoreAll();
+ dom.window.close();
+ for (const [key, descriptor] of originals) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else delete globalThis[key];
+ }
+ }
+ });
+
+ // Import after installing the DOM: React Query and focus stores detect it at load time.
+ const React = await import("react");
+ ({ act } = React);
+ const { createRoot } = await import("react-dom/client");
+ const { QueryClient, QueryClientProvider } = await import(
+ "@tanstack/react-query"
+ );
+ const { customEmojiQueryKey } = await import("@/features/custom-emoji/hooks");
+ const { QuickReactionProvider, useQuickReactionItems } = await import(
+ "./QuickReactionProvider.tsx"
+ );
+ const { recordQuickReactionEmoji } = await import(
+ "./useQuickReactionEmojis.ts"
+ );
+ // Real action bars keep their query hooks, quick buttons and closed menus.
+ // Only fetching is disabled below; the production observer path is not mocked.
+ const { MessageActionBar } = actionBars
+ ? await import("./MessageActionBar.tsx")
+ : {};
+ const { TooltipProvider } = actionBars
+ ? await import("@/shared/ui/tooltip.tsx")
+ : {};
+ const selectReaction = async () => {};
+ function ActionBar({ id }) {
+ return React.createElement(MessageActionBar, {
+ message: {
+ id: `message-${id}`,
+ author: "Test author",
+ pubkey: "a".repeat(64),
+ createdAt: 1,
+ time: "12:00",
+ body: "Quick-reaction sharing regression",
+ depth: 0,
+ pending: false,
+ kind: 9,
+ tags: [],
+ },
+ reactions: [],
+ onReactionSelect: selectReaction,
+ });
+ }
+ const makeClient = (data) => {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: {
+ enabled: false,
+ retry: false,
+ gcTime: Infinity,
+ // Retain instrumented fixture getters; still use the actual query and observer.
+ structuralSharing: false,
+ },
+ },
+ });
+ client.setQueryData(customEmojiQueryKey, data);
+ clients.push(client);
+ return client;
+ };
+ const seed = (community, values) => {
+ dom.window.localStorage.setItem(
+ storageKey(community),
+ JSON.stringify(values),
+ );
+ };
+ const activate = (community) => {
+ if (community)
+ dom.window.localStorage.setItem("buzz-active-community-id", community);
+ else dom.window.localStorage.removeItem("buzz-active-community-id");
+ };
+ seed(scope, recents);
+ activate(scope);
+
+ const storageListeners = new Set();
+ const addListener = dom.window.addEventListener.bind(dom.window);
+ const removeListener = dom.window.removeEventListener.bind(dom.window);
+ t.mock.method(dom.window, "addEventListener", (type, callback, options) => {
+ if (type === "storage") storageListeners.add(callback);
+ return addListener(type, callback, options);
+ });
+ t.mock.method(
+ dom.window,
+ "removeEventListener",
+ (type, callback, options) => {
+ if (type === "storage") storageListeners.delete(callback);
+ return removeListener(type, callback, options);
+ },
+ );
+
+ const snapshots = new Map();
+ function Consumer({ id, tick }) {
+ const items = useQuickReactionItems();
+ snapshots.set(id, items);
+ return React.createElement(
+ "output",
+ { "data-consumer": id, "data-tick": tick },
+ emojiNames(items).join(" "),
+ );
+ }
+ const client = makeClient(palette);
+ let options = {
+ scope,
+ client,
+ count: 1,
+ key: `${scope}:identity-a`,
+ tick: 0,
+ };
+ root = createRoot(dom.window.document.getElementById("root"));
+ const render = async (updates = {}) => {
+ options = { ...options, ...updates, tick: options.tick + 1 };
+ snapshots.clear();
+ await act(async () => {
+ root.render(
+ React.createElement(
+ QueryClientProvider,
+ { client: options.client, key: options.key },
+ React.createElement(
+ QuickReactionProvider,
+ { communityScope: options.scope },
+ React.createElement(
+ actionBars ? TooltipProvider : React.Fragment,
+ null,
+ Array.from({ length: options.count }, (_, id) =>
+ React.createElement(actionBars ? ActionBar : Consumer, {
+ id,
+ key: id,
+ tick: options.tick,
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+ });
+ };
+ const items = () => {
+ assert.ok(
+ snapshots.has(0),
+ "the real provider must publish a consumer snapshot",
+ );
+ return snapshots.get(0);
+ };
+ const updatePalette = async (data, target = options.client) => {
+ await act(async () => {
+ target.setQueryData(customEmojiQueryKey, data);
+ // Drain React Query's scheduled notification, not a timing/performance assertion.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ };
+ const storageEvent = async (community) => {
+ await act(async () => {
+ dom.window.dispatchEvent(
+ new dom.window.StorageEvent("storage", {
+ key: storageKey(community),
+ newValue: dom.window.localStorage.getItem(storageKey(community)),
+ storageArea: dom.window.localStorage,
+ }),
+ );
+ });
+ };
+ return {
+ act,
+ activate,
+ client,
+ dom,
+ items,
+ makeClient,
+ recordQuickReactionEmoji,
+ render,
+ seed,
+ snapshots,
+ storageEvent,
+ storageListeners,
+ updatePalette,
+ query: (target = client) =>
+ target.getQueryCache().find({ queryKey: customEmojiQueryKey }),
+ async unmount() {
+ await act(async () => root.unmount());
+ root = undefined;
+ },
+ };
+}
+
+test("many quick-reaction consumers reuse one preparation, query observer and storage listener", async (t) => {
+ let paletteReads = 0;
+ const palette = Array.from({ length: 128 }, (_, index) => ({
+ get shortcode() {
+ paletteReads += 1;
+ return index === 0 ? "shipit" : `custom_${index}`;
+ },
+ url: `https://cdn.example.test/emoji/${index}.png`,
+ }));
+ const h = await harness(t, { palette, recents: [entry(":shipit:", 4)] });
+ await h.render();
+ assert.deepEqual(emojiNames(h.items()), [":shipit:", "π", "β€οΈ"]);
+ assert.equal(h.items()[0].customEmojiUrl, palette[0].url);
+ const prepared = h.items();
+ const readsAfterPreparation = paletteReads;
+ assert.ok(
+ readsAfterPreparation > 0,
+ "the production preparation must inspect the palette",
+ );
+ await h.render({ count: 64 });
+ await h.render();
+ assert.equal(h.snapshots.size, 64);
+ for (const items of h.snapshots.values()) assert.strictEqual(items, prepared);
+ assert.equal(
+ paletteReads,
+ readsAfterPreparation,
+ "row mounts/rerenders must not repeat palette preparation",
+ );
+ assert.equal(h.query().getObserversCount(), 1);
+ assert.equal(h.storageListeners.size, 1);
+ assert.equal(h.dom.window.document.querySelectorAll("output").length, 64);
+ // A refetch may replace the palette array without changing any tray item.
+ await h.updatePalette([
+ { shortcode: "shipit", url: palette[0].url },
+ { shortcode: "unrelated", url: "https://cdn.example.test/unrelated.png" },
+ ]);
+ assert.strictEqual(h.items(), prepared);
+ await h.storageEvent("community-a");
+ assert.strictEqual(h.items(), prepared);
+ await h.unmount();
+ assert.equal(h.query().getObserversCount(), 0);
+ assert.equal(h.storageListeners.size, 0);
+});
+
+test("real MessageActionBars share quick-tray preparation, observer and storage listener", async (t) => {
+ let paletteReads = 0;
+ const palette = Array.from({ length: 128 }, (_, index) => ({
+ get shortcode() {
+ paletteReads += 1;
+ return index === 0 ? "shipit" : `custom_${index}`;
+ },
+ url: `https://cdn.example.test/emoji/${index}.png`,
+ }));
+ const h = await harness(t, {
+ actionBars: true,
+ palette,
+ recents: [entry(":shipit:", 4)],
+ });
+ await h.render();
+ const readsAfterPreparation = paletteReads;
+ assert.ok(readsAfterPreparation > 0);
+ for (const count of [16, 16, 1, 16]) {
+ await h.render({ count });
+ const bars = h.dom.window.document.querySelectorAll(
+ '[data-testid^="message-action-bar-"]',
+ );
+ assert.equal(bars.length, count, "mount the real production action bars");
+ for (const bar of bars) {
+ const buttons = bar.querySelectorAll('button[aria-label^="React with "]');
+ assert.equal(buttons.length, 3, "render the actual quick buttons");
+ assert.equal(
+ buttons[0].querySelector("img")?.getAttribute("alt"),
+ ":shipit:",
+ );
+ assert.equal(
+ buttons[0].querySelector("img")?.getAttribute("src"),
+ palette[0].url,
+ );
+ }
+ assert.equal(
+ h.query().getObserversCount(),
+ 1,
+ "real rows must not add palette observers",
+ );
+ assert.equal(
+ h.storageListeners.size,
+ 1,
+ "real rows must not add storage listeners",
+ );
+ assert.equal(
+ paletteReads,
+ readsAfterPreparation,
+ "real row mounts/rerenders must not prepare the palette",
+ );
+ }
+ await h.unmount();
+ assert.equal(h.query().getObserversCount(), 0);
+ assert.equal(h.storageListeners.size, 0);
+});
+
+test("recording persists recents without reshuffling mounted or newly mounted trays", async (t) => {
+ const h = await harness(t, {
+ palette: [{ shortcode: "shipit", url: "https://cdn.example.test/old.png" }],
+ recents: [entry("π", 2), entry(":shipit:", 4), entry("π₯", 3)],
+ });
+ await h.render({ count: 4 });
+ const prepared = h.items();
+ await h.act(async () => {
+ for (let i = 0; i < 6; i++) h.recordQuickReactionEmoji(" π ");
+ });
+ const saved = JSON.parse(
+ h.dom.window.localStorage.getItem(storageKey("community-a")),
+ );
+ assert.equal(saved[0].emoji, "π");
+ assert.equal(saved[0].count, 6);
+ await h.render({ count: 20 });
+ for (const items of h.snapshots.values()) assert.strictEqual(items, prepared);
+ await h.updatePalette([
+ { shortcode: "shipit", url: "https://cdn.example.test/new.png" },
+ ]);
+ assert.deepEqual(emojiNames(h.items()), [":shipit:", "π₯", "π"]);
+ assert.equal(h.items()[0].customEmojiUrl, "https://cdn.example.test/new.png");
+ for (const items of h.snapshots.values())
+ assert.strictEqual(items, h.items());
+ // A fresh identity/session owner reads persisted history rather than a module-global tray.
+ await h.render({ key: "community-a:identity-b" });
+ assert.deepEqual(emojiNames(h.items()), ["π", ":shipit:", "π₯"]);
+});
+
+test("storage refresh is community-scoped and removal restores defaults", async (t) => {
+ const h = await harness(t, { recents: [entry("π₯", 4)] });
+ await h.render({ count: 8 });
+ const prepared = h.items();
+ h.seed("community-a", [entry("β
", 10, 1), entry("π", 10, 2)]);
+ h.seed("community-b", [entry("π§", 12)]);
+ await h.storageEvent("community-b");
+ assert.strictEqual(h.items(), prepared);
+ await h.storageEvent("community-a");
+ assert.deepEqual(emojiNames(h.items()), ["π", "β
", "π"]);
+ for (const items of h.snapshots.values())
+ assert.strictEqual(items, h.items());
+ h.dom.window.localStorage.removeItem(storageKey("community-a"));
+ await h.storageEvent("community-a");
+ assert.deepEqual(emojiNames(h.items()), ["π", "β€οΈ", "π"]);
+});
+
+test("palette availability backfills stale custom emoji and restores their frozen rank", async (t) => {
+ const h = await harness(t, {
+ recents: [
+ entry(":gone:", 20),
+ entry(":SHIPIT:", 10),
+ entry(":SHIPIT:", 9),
+ entry("π₯", 8),
+ entry("π", 7),
+ ],
+ });
+ await h.render();
+ assert.deepEqual(emojiNames(h.items()), ["π₯", "π", "β€οΈ"]);
+ const available = [
+ { shortcode: "shipit", url: "https://cdn.example.test/shipit.png" },
+ ];
+ await h.updatePalette(available);
+ assert.deepEqual(emojiNames(h.items()), [":SHIPIT:", "π₯", "π"]);
+ assert.equal(h.items()[0].customEmojiUrl, available[0].url);
+ await h.updatePalette([]);
+ assert.deepEqual(emojiNames(h.items()), ["π₯", "π", "β€οΈ"]);
+ assert.ok(h.items().every((item) => item.customEmojiUrl === undefined));
+ await h.updatePalette(available);
+ assert.deepEqual(emojiNames(h.items()), [":SHIPIT:", "π₯", "π"]);
+});
+
+test("keyed community replacement releases old observers/listeners and cannot reuse old custom URLs", async (t) => {
+ const h = await harness(t, {
+ palette: [
+ { shortcode: "shipit", url: "https://a.example.test/shipit.png" },
+ ],
+ recents: [entry(":shipit:", 5)],
+ });
+ await h.render({ count: 16 });
+ const aListener = [...h.storageListeners][0];
+ const b = h.makeClient([
+ { shortcode: "shipit", url: "https://b.example.test/shipit.png" },
+ ]);
+ h.seed("community-b", [entry("β
", 10), entry(":shipit:", 5)]);
+ h.activate("community-b");
+ await h.render({
+ scope: "community-b",
+ key: "community-b:identity-a",
+ client: b,
+ });
+ assert.deepEqual(emojiNames(h.items()), ["β
", ":shipit:", "π"]);
+ assert.equal(
+ h.items()[1].customEmojiUrl,
+ "https://b.example.test/shipit.png",
+ );
+ assert.equal(h.query().getObserversCount(), 0);
+ assert.equal(h.query(b).getObserversCount(), 1);
+ assert.equal(h.storageListeners.size, 1);
+ assert.ok(!h.storageListeners.has(aListener));
+ const bItems = h.items();
+ h.seed("community-a", [entry("π§", 100)]);
+ await h.storageEvent("community-a");
+ await h.updatePalette([], h.client);
+ assert.strictEqual(h.items(), bItems);
+ await h.unmount();
+ assert.equal(h.storageListeners.size, 0);
+ assert.equal(h.query(b).getObserversCount(), 0);
+});
+
+test("null community scope reads and refreshes the legacy unscoped history", async (t) => {
+ const h = await harness(t, { scope: null, recents: [entry("π", 3)] });
+ await h.render();
+ assert.deepEqual(emojiNames(h.items()), ["π", "π", "β€οΈ"]);
+ h.seed(null, [entry("β
", 5)]);
+ await h.storageEvent(null);
+ assert.deepEqual(emojiNames(h.items()), ["β
", "π", "β€οΈ"]);
+});
+
+test("malformed history and inaccessible storage keep a usable default tray", async (t) => {
+ const h = await harness(t);
+ h.dom.window.localStorage.setItem(storageKey("community-a"), "{not json");
+ await h.render();
+ assert.deepEqual(emojiNames(h.items()), ["π", "β€οΈ", "π"]);
+ h.dom.window.localStorage.setItem(
+ storageKey("community-a"),
+ JSON.stringify({ emoji: "π₯" }),
+ );
+ await h.storageEvent("community-a");
+ assert.deepEqual(emojiNames(h.items()), ["π", "β€οΈ", "π"]);
+ const descriptor = Object.getOwnPropertyDescriptor(
+ h.dom.window,
+ "localStorage",
+ );
+ Object.defineProperty(h.dom.window, "localStorage", {
+ configurable: true,
+ get() {
+ throw new h.dom.window.DOMException("Storage denied", "SecurityError");
+ },
+ });
+ try {
+ await h.render({ key: "community-a:storage-denied" });
+ assert.deepEqual(emojiNames(h.items()), ["π", "β€οΈ", "π"]);
+ assert.doesNotThrow(() => h.recordQuickReactionEmoji("π₯"));
+ } finally {
+ Object.defineProperty(h.dom.window, "localStorage", descriptor);
+ }
+});
diff --git a/desktop/src/features/messages/ui/QuickReactionProvider.tsx b/desktop/src/features/messages/ui/QuickReactionProvider.tsx
new file mode 100644
index 00000000000..bc0866e940f
--- /dev/null
+++ b/desktop/src/features/messages/ui/QuickReactionProvider.tsx
@@ -0,0 +1,93 @@
+import * as React from "react";
+
+import { useCustomEmojiQuery } from "@/features/custom-emoji/hooks";
+import { reactionEmojiUrl } from "@/shared/api/customEmoji";
+import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
+import {
+ quickReactionStorageKey,
+ readQuickReactionEntries,
+ resolveQuickReactionEmojis,
+} from "./useQuickReactionEmojis";
+
+type QuickReactionItem = Readonly<{
+ emoji: string;
+ customEmojiUrl: string | undefined;
+}>;
+const EMPTY_PALETTE: readonly CustomEmoji[] = [];
+const DEFAULT_ITEMS: readonly QuickReactionItem[] = resolveQuickReactionEmojis(
+ [],
+ 3,
+).map((emoji) => ({ emoji, customEmojiUrl: undefined }));
+const QuickReactionContext =
+ React.createContext(DEFAULT_ITEMS);
+
+/**
+ * One quick-tray snapshot per community/identity session. Rows read context;
+ * they do not observe the palette query, scan storage, or prepare emoji URLs.
+ * Mount under AppReady's keyed community boundary so retained recents and the
+ * single storage listener leave with that session, not with individual rows.
+ */
+export function QuickReactionProvider({
+ communityScope,
+ children,
+}: {
+ communityScope: string | null;
+ children: React.ReactNode;
+}) {
+ const customEmoji = useCustomEmojiQuery().data ?? EMPTY_PALETTE;
+ const storageKey = quickReactionStorageKey(communityScope);
+ const [entries, setEntries] = React.useState(() =>
+ readQuickReactionEntries(storageKey),
+ );
+
+ React.useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === storageKey) {
+ setEntries(readQuickReactionEntries(storageKey));
+ }
+ };
+ window.addEventListener("storage", handleStorage);
+ return () => window.removeEventListener("storage", handleStorage);
+ }, [storageKey]);
+
+ // Same-window reactions only persist recents. Keep the tray steady until
+ // reload or another window's storage event; palette updates still refresh
+ // availability and URLs without replacing the frozen ranking inputs.
+ const items = React.useMemo(
+ () =>
+ resolveQuickReactionEmojis(entries, 3, customEmoji)
+ .map((emoji) => ({
+ emoji,
+ customEmojiUrl: reactionEmojiUrl(emoji, customEmoji),
+ }))
+ .filter(
+ ({ emoji, customEmojiUrl }) =>
+ !emoji.startsWith(":") || !emoji.endsWith(":") || customEmojiUrl,
+ ),
+ [customEmoji, entries],
+ );
+ const stableItems = React.useRef(items);
+ if (
+ items.length !== stableItems.current.length ||
+ items.some(
+ (item, index) =>
+ item.emoji !== stableItems.current[index].emoji ||
+ item.customEmojiUrl !== stableItems.current[index].customEmojiUrl,
+ )
+ ) {
+ stableItems.current = items;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** Read shared, content-stable quick items without adding per-row observers. */
+export function useQuickReactionItems(): readonly QuickReactionItem[] {
+ return React.useContext(QuickReactionContext);
+}
diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts
index 457c659141d..96731a0cd43 100644
--- a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts
+++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts
@@ -1,5 +1,3 @@
-import * as React from "react";
-
import {
loadActiveCommunityId,
loadCommunities,
@@ -9,7 +7,6 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1";
const DEFAULT_QUICK_REACTIONS = ["π", "β€οΈ", "π", "π"] as const;
const MAX_STORED_REACTIONS = 24;
-const sessionQuickReactionEmojis = new Map();
type QuickReactionEntry = {
count: number;
@@ -37,20 +34,13 @@ function getActiveCommunityScope() {
}
}
-function quickReactionStorageKey(communityScope: string | null) {
+/** Persisted recents retain the existing per-community storage format. */
+export function quickReactionStorageKey(communityScope: string | null) {
return communityScope
? `${QUICK_REACTION_STORAGE_KEY}:${communityScope}`
: QUICK_REACTION_STORAGE_KEY;
}
-function quickReactionSessionKey(
- limit: number,
- communityScope: string | null,
- customEmojiSignature: string,
-) {
- return `${communityScope ?? "global"}:${customEmojiSignature}:${limit}`;
-}
-
function normalizeEntry(entry: unknown): QuickReactionEntry | null {
if (!entry || typeof entry !== "object") return null;
@@ -77,7 +67,8 @@ function sortEntries(entries: QuickReactionEntry[]) {
});
}
-function readQuickReactionEntries(storageKey: string) {
+/** Read the ranked recents once for a tray session or a storage refresh. */
+export function readQuickReactionEntries(storageKey: string) {
if (!canUseLocalStorage()) return [];
try {
@@ -110,17 +101,6 @@ function writeQuickReactionEntries(
}
}
-function customEmojiSignature(customEmoji: ReadonlyArray) {
- return customEmoji
- .map((emoji) => emoji.shortcode.toLowerCase())
- .sort()
- .join(",");
-}
-
-function customEmojiShortcodesFromSignature(signature: string) {
- return new Set(signature ? signature.split(",") : []);
-}
-
function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}
@@ -161,6 +141,7 @@ function resolveQuickReactionEmojisWithShortcodes(
return next;
}
+/** Resolve available recents and default backfill without mutating the palette. */
export function resolveQuickReactionEmojis(
entries: ReadonlyArray>,
limit: number,
@@ -169,44 +150,11 @@ export function resolveQuickReactionEmojis(
return resolveQuickReactionEmojisWithShortcodes(
entries,
limit,
- customEmojiShortcodesFromSignature(customEmojiSignature(customEmoji)),
- );
-}
-
-function getQuickReactionEmojis(
- limit: number,
- communityScope: string | null,
- customEmojiSignature: string,
-) {
- return resolveQuickReactionEmojisWithShortcodes(
- readQuickReactionEntries(quickReactionStorageKey(communityScope)),
- limit,
- customEmojiShortcodesFromSignature(customEmojiSignature),
- );
-}
-
-function getSessionQuickReactionEmojis(
- limit: number,
- communityScope: string | null,
- customEmojiSignature: string,
-) {
- const sessionKey = quickReactionSessionKey(
- limit,
- communityScope,
- customEmojiSignature,
- );
- const cached = sessionQuickReactionEmojis.get(sessionKey);
- if (cached) return cached;
-
- const emojis = getQuickReactionEmojis(
- limit,
- communityScope,
- customEmojiSignature,
+ new Set(customEmoji.map((emoji) => emoji.shortcode.toLowerCase())),
);
- sessionQuickReactionEmojis.set(sessionKey, emojis);
- return emojis;
}
+/** Record a reaction without reshuffling the current sessionβs quick tray. */
export function recordQuickReactionEmoji(emoji: string) {
const trimmed = emoji.trim();
if (!trimmed) return;
@@ -230,48 +178,3 @@ export function recordQuickReactionEmoji(emoji: string) {
// when another tab updates this community's quick reactions.
writeQuickReactionEntries(entries, storageKey);
}
-
-export function useQuickReactionEmojis(
- limit = 4,
- customEmoji: ReadonlyArray = [],
-) {
- const communityScope = getActiveCommunityScope();
- const customEmojiCacheKey = customEmojiSignature(customEmoji);
- const [emojis, setEmojis] = React.useState(() =>
- getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey),
- );
-
- React.useEffect(() => {
- if (typeof window === "undefined") return;
-
- const storageKey = quickReactionStorageKey(communityScope);
- const sessionKey = quickReactionSessionKey(
- limit,
- communityScope,
- customEmojiCacheKey,
- );
- const handleStorage = (event: StorageEvent) => {
- if (event.key === storageKey) {
- sessionQuickReactionEmojis.delete(sessionKey);
- setEmojis(
- getSessionQuickReactionEmojis(
- limit,
- communityScope,
- customEmojiCacheKey,
- ),
- );
- }
- };
-
- window.addEventListener("storage", handleStorage);
- setEmojis(
- getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey),
- );
-
- return () => {
- window.removeEventListener("storage", handleStorage);
- };
- }, [customEmojiCacheKey, limit, communityScope]);
-
- return emojis;
-}
diff --git a/desktop/tests/e2e/quick-reaction-sharing.spec.ts b/desktop/tests/e2e/quick-reaction-sharing.spec.ts
new file mode 100644
index 00000000000..6d5b7ff71f0
--- /dev/null
+++ b/desktop/tests/e2e/quick-reaction-sharing.spec.ts
@@ -0,0 +1,120 @@
+import { expect, test } from "@playwright/test";
+import { installMockBridge } from "../helpers/bridge";
+
+const STORAGE_KEY = "buzz.quick-reaction-emojis.v1:e2e-default-community";
+
+// Exercise AppReady's provider wiring and real action bars, not a test-only
+// provider tree. A missing provider would silently show the default tray.
+test("quick trays share prepared custom items across channel remounts", async ({
+ page,
+}) => {
+ await page.addInitScript((key) => {
+ localStorage.setItem(
+ key,
+ JSON.stringify([
+ { emoji: ":buzz:", count: 10, lastUsedAt: 1 },
+ { emoji: "π₯", count: 5, lastUsedAt: 1 },
+ ]),
+ );
+ }, STORAGE_KEY);
+ await installMockBridge(page);
+ await page.route("https://example.com/e2e/**", (route) =>
+ route.fulfill({
+ contentType: "image/svg+xml",
+ body: '',
+ }),
+ );
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ const row = page
+ .getByTestId("message-row")
+ .filter({ hasText: "React to me with a custom emoji" })
+ .last();
+ await expect(row).toBeVisible();
+ await row.hover();
+ const tray = row.locator('[data-testid^="message-action-bar-"]');
+ const quick = tray.getByRole("button", { name: /^React with / });
+ await expect(quick).toHaveCount(3);
+ await expect(quick.first().locator("img")).toHaveAttribute("alt", ":buzz:");
+ const originalLabels = await quick.evaluateAll((buttons) =>
+ buttons.map((button) => button.getAttribute("aria-label")),
+ );
+
+ // Keyboard activation goes through the unchanged action handler and persists
+ // recents, but must not reshuffle this session's tray.
+ await quick.nth(1).focus();
+ await page.keyboard.press("Enter");
+ await expect(
+ row
+ .getByTestId("message-reactions")
+ .getByRole("button", { name: "Toggle π₯ reaction" }),
+ ).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) =>
+ JSON.parse(localStorage.getItem(key) ?? "[]").find(
+ (entry: { emoji: string }) => entry.emoji === "π₯",
+ )?.count,
+ STORAGE_KEY,
+ ),
+ )
+ .toBe(6);
+ expect(
+ await quick.evaluateAll((buttons) =>
+ buttons.map((button) => button.getAttribute("aria-label")),
+ ),
+ ).toEqual(originalLabels);
+
+ // A palette URL update reaches the actual button without changing rank.
+ await page.evaluate(() => {
+ const query = window.__BUZZ_E2E_QUERY_CLIENT__;
+ const palette = query?.getQueryData<
+ Array<{ shortcode: string; url: string }>
+ >(["custom-emoji"]);
+ if (!query || !palette) throw new Error("palette query not ready");
+ query.setQueryData(
+ ["custom-emoji"],
+ palette.map((item) =>
+ item.shortcode === "buzz"
+ ? { ...item, url: "https://example.com/e2e/buzz-new.png" }
+ : item,
+ ),
+ );
+ });
+ await expect(quick.first().locator("img")).toHaveAttribute(
+ "src",
+ "https://example.com/e2e/buzz-new.png",
+ );
+
+ await page.getByTestId("channel-random").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await page.getByTestId("channel-general").click();
+ await expect(row).toBeVisible();
+ await row.hover();
+ expect(
+ await quick.evaluateAll((buttons) =>
+ buttons.map((button) => button.getAttribute("aria-label")),
+ ),
+ ).toEqual(originalLabels);
+
+ // Other-window storage notifications refresh all mounted trays.
+ await page.evaluate((key) => {
+ const newValue = JSON.stringify([
+ { emoji: "π", count: 100, lastUsedAt: 2 },
+ ]);
+ localStorage.setItem(key, newValue);
+ window.dispatchEvent(
+ new StorageEvent("storage", { key, newValue, storageArea: localStorage }),
+ );
+ }, STORAGE_KEY);
+ await expect(quick.first()).toContainText("π");
+ const trays = page.locator('[data-testid^="message-action-bar-"]');
+ expect(await trays.count()).toBeGreaterThan(1);
+ for (const bar of await trays.all()) {
+ const buttons = bar.getByRole("button", { name: /^React with / });
+ if (await buttons.count())
+ await expect(buttons.first()).toContainText("π");
+ }
+});