perf(desktop): share quick-reaction preparation per app session

Replace per-action-bar palette preparation, query observers and storage
listeners with one content-stable tray snapshot below the keyed community
and identity boundary. Preserve community-scoped recents, frozen session
ranking, external storage refresh and custom emoji availability/URL updates.

Cover shared preparation and lifetime behavior with real-provider tests and
the production action bars with a mock-bridge browser regression.

Co-authored-by: Princess Donut <5d97ac8c272fa949af56c71e586146b1706e5c4c3daa125db5b4625e12e0686c@buzz.block.builderlab.xyz>
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
Carl
2026-09-01 11:05:50 -07:00
parent cec5c8fd92
commit 76d497ff62
7 changed files with 628 additions and 131 deletions

View File

@@ -127,6 +127,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",

View File

@@ -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({
})
}
>
<KnownAgentPubkeysProvider>
<RouterProvider router={router} />
</KnownAgentPubkeysProvider>
<QuickReactionProvider communityScope={communityScope}>
<KnownAgentPubkeysProvider>
<RouterProvider router={router} />
</KnownAgentPubkeysProvider>
</QuickReactionProvider>
</EncryptedBackupProvider>
);
}
@@ -636,6 +641,7 @@ function CommunityApp({
/>
<CommunityThemeController />
<AppReady
communityScope={activeCommunity?.id ?? null}
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}

View File

@@ -18,7 +18,6 @@ import { toast } from "sonner";
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { buildMentionClipboardHtml } from "@/features/messages/lib/mentionClipboard";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useMessageMentionIdentities } from "@/features/messages/lib/useMessageMentionIdentities";
@@ -29,11 +28,8 @@ import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import {
recordQuickReactionEmoji,
useQuickReactionEmojis,
} from "@/features/messages/ui/useQuickReactionEmojis";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import { useQuickReactionItems } from "./QuickReactionProvider";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { emojiDisplayName } from "@/shared/lib/emojiName";
@@ -389,10 +385,6 @@ function QuickReactionButton({
);
}
function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}
export const MessageActionBar = React.memo(function MessageActionBar({
channelId,
message,
@@ -443,20 +435,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const customEmoji = useCustomEmoji();
const quickReactionEmojis = useQuickReactionEmojis(3, customEmoji);
const quickReactionItems = React.useMemo(
() =>
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);

View File

@@ -0,0 +1,395 @@
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 = [] } = {},
) {
const dom = new JSDOM(
"<!doctype html><html><body><div id='root'></div></body></html>",
{
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"
);
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 },
Array.from({ length: options.count }, (_, id) =>
React.createElement(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("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);
}
});

View File

@@ -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<readonly QuickReactionItem[]>(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<readonly QuickReactionItem[]>(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 (
<QuickReactionContext.Provider value={stableItems.current}>
{children}
</QuickReactionContext.Provider>
);
}
/** Read shared, content-stable quick items without adding per-row observers. */
export function useQuickReactionItems(): readonly QuickReactionItem[] {
return React.useContext(QuickReactionContext);
}

View File

@@ -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<string, string[]>();
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<CustomEmoji>) {
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<Pick<QuickReactionEntry, "emoji">>,
limit: number,
@@ -169,44 +150,11 @@ export function resolveQuickReactionEmojis(
return resolveQuickReactionEmojisWithShortcodes(
entries,
limit,
customEmojiShortcodesFromSignature(customEmojiSignature(customEmoji)),
new Set(customEmoji.map((emoji) => emoji.shortcode.toLowerCase())),
);
}
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,
);
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<CustomEmoji> = [],
) {
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;
}

View File

@@ -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: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"/>',
}),
);
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("🎉");
}
});