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("πŸŽ‰"); + } +});