mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
395
desktop/src/features/messages/ui/QuickReactionProvider.test.mjs
Normal file
395
desktop/src/features/messages/ui/QuickReactionProvider.test.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
93
desktop/src/features/messages/ui/QuickReactionProvider.tsx
Normal file
93
desktop/src/features/messages/ui/QuickReactionProvider.tsx
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
120
desktop/tests/e2e/quick-reaction-sharing.spec.ts
Normal file
120
desktop/tests/e2e/quick-reaction-sharing.spec.ts
Normal 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("🎉");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user