diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 6e121d136..881b5407b 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -135,6 +135,7 @@ export function InboxMessageRow({ onReply={canReply ? () => onSelectReplyTarget(message) : undefined} reactionErrorMessage={reactionErrorMessage} reactions={reactions} + presentation="menu" /> ) : null; diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index eb827ebc5..880296ace 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -159,7 +159,7 @@ function MoreActionsMenu({ {quickReactionTray ? ( <>
{quickReactionTray} @@ -462,7 +462,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(4, customEmoji); + const quickReactionEmojis = useQuickReactionEmojis(5, customEmoji); const quickReactionItems = React.useMemo( () => quickReactionEmojis @@ -507,6 +507,10 @@ export const MessageActionBar = React.memo(function MessageActionBar({ onReactionBadgeBurstRequest?.(emoji); } + if (closeMenu) { + setIsDropdownOpen(false); + } + void onReactionSelect(emoji) .then(() => { recordQuickReactionEmoji(emoji); @@ -516,9 +520,6 @@ export const MessageActionBar = React.memo(function MessageActionBar({ if (closePicker) { setIsReactionPickerOpen(false); } - if (closeMenu) { - setIsDropdownOpen(false); - } }); }, [onReactionBadgeBurstRequest, onReactionSelect, wouldAddReaction], @@ -613,7 +614,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ "flex items-center gap-0.5", presentation === "tray" ? "p-1" - : "gap-0 bg-background [&_button]:h-6 [&_button]:w-6", + : "gap-0 bg-transparent [&_button]:h-6 [&_button]:w-6", )} > {presentation === "menu" ? ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 69a775d0c..dfe216ffb 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -89,7 +89,6 @@ export const MessageRow = React.memo( huddleMemberPubkeys, huddleMemberPubkeysPending = false, hideAgentAccessBadge = false, - actionBarPlacement = "floating", collapseDescendantsLabel, isFollowingThread, isContinuation = false, @@ -532,10 +531,8 @@ export const MessageRow = React.memo( const actionBarNode = ( ; diff --git a/desktop/src/features/messages/ui/MessageRowActions.tsx b/desktop/src/features/messages/ui/MessageRowActions.tsx index f8f841dff..aec2d728d 100644 --- a/desktop/src/features/messages/ui/MessageRowActions.tsx +++ b/desktop/src/features/messages/ui/MessageRowActions.tsx @@ -6,10 +6,8 @@ import { cn } from "@/shared/lib/cn"; import { MessageActionBar } from "./MessageActionBar"; type MessageRowActionsProps = { - actionBarPlacement: "floating" | "inside"; anchorToBubble: boolean; channelId?: string | null; - isContinuation: boolean; isFollowingThread?: boolean; isUnread?: boolean; message: TimelineMessage; @@ -30,10 +28,8 @@ type MessageRowActionsProps = { }; export function MessageRowActions({ - actionBarPlacement, anchorToBubble, channelId, - isContinuation, isFollowingThread, isUnread, message, @@ -52,14 +48,6 @@ export function MessageRowActions({ reactions, showMessageBubbles, }: MessageRowActionsProps) { - const floatingPosition = isContinuation - ? showMessageBubbles - ? "sm:-top-1 sm:-translate-y-1/2" - : "sm:-top-3 sm:-translate-y-1/2" - : showMessageBubbles - ? "sm:top-2 sm:-translate-y-1/2" - : "sm:top-0 sm:-translate-y-1/2"; - return (
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index c05f651de..ac28c7f80 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -583,7 +583,6 @@ export function MessageThreadPanel({ >
{ ); }); +test("quick reactions fill the expanded fifth slot from recent reactions", () => { + assert.deepEqual(resolveQuickReactionEmojis([entry("🔥")], 5, []), [ + "🔥", + "👍", + "❤️", + "😂", + "🎉", + ]); +}); + test("quick reactions skip stale custom emoji before applying the limit", () => { assert.deepEqual( resolveQuickReactionEmojis( diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts index cdb48c00e..263fca98b 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -730,14 +730,14 @@ test("app font size and conversation density apply independently", async ({ ); await expect(fontSizeDescription).toHaveText("Adjust text throughout Buzz."); await expect.poll(readScale).toEqual({ - authorLineHeight: 17.142857, + authorLineHeight: 16, bodyGap: 0.125, - fontSize: "calc(17.142857px * .875)", - lineHeight: "calc(17.142857px * 1.25)", + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", paragraphGap: 0.5, rowPadding: 0.375, - timestampFontSize: "calc(17.142857px * .75)", - timestampLineHeight: 17.142857, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, }); await expect .poll(() => @@ -761,19 +761,15 @@ test("app font size and conversation density apply independently", async ({ return [style.fontSize, style.lineHeight]; }), ) - .toEqual(["15px", "21.4286px"]); + .toEqual(["14px", "20px"]); await expect.poll(readSettingsScale).toEqual({ - fontSize: "15px", - lineHeight: "21.4286px", + fontSize: "14px", + lineHeight: "20px", minHeight: "64px", paddingBlock: "12px", }); - await expect - .poll(readPreviewTimestampScale) - .toEqual(["12.8571px", "17.1429px"]); - await expect - .poll(readSettingsChromeScale) - .toEqual(["25.7143px", "15px", "19.2857px"]); + await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); + await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "18px"]); await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s"); await expect(densityIndicator).toHaveCSS("transition-property", /transform/); await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s"); @@ -798,7 +794,7 @@ test("app font size and conversation density apply independently", async ({ }; }) .toEqual({ - chipMatchesControl: true, + chipMatchesControl: false, previewBackground: "rgba(0, 0, 0, 0)", }); const previewSurfaceBox = await previewSurface.boundingBox(); @@ -835,8 +831,8 @@ test("app font size and conversation density apply independently", async ({ expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual( 17, ); - await expect(previewChip).toHaveCSS("padding-top", "4px"); - await expect(previewChip).toHaveCSS("padding-bottom", "4px"); + await expect(previewChip).toHaveCSS("padding-top", "0px"); + await expect(previewChip).toHaveCSS("padding-bottom", "0px"); await densityIndicator.evaluate((element) => { element.addEventListener( @@ -859,6 +855,37 @@ test("app font size and conversation density apply independently", async ({ ), ) .toBe("compact"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.375, + rowPadding: 0.25, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); + await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "18px"]); + + await larger.click(); + await expect(root).toHaveAttribute("data-conversation-density", "compact"); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(larger).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("larger"); await expect.poll(readScale).toEqual({ authorLineHeight: 17.142857, bodyGap: 0, @@ -869,6 +896,45 @@ test("app font size and conversation density apply independently", async ({ timestampFontSize: "calc(17.142857px * .75)", timestampLineHeight: 17.142857, }); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["15px", "21.4286px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "15px", + lineHeight: "21.4286px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["12.8571px", "17.1429px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "19.2857px"]); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/15-conversation-compact-larger.png`, + }); + + await spacious.click(); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(spacious).toHaveAttribute("aria-pressed", "true"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 17.142857, + bodyGap: 0.25, + fontSize: "calc(17.142857px * .875)", + lineHeight: "calc(17.142857px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(17.142857px * .75)", + timestampLineHeight: 17.142857, + }); await expect.poll(readSettingsScale).toEqual({ fontSize: "15px", lineHeight: "21.4286px", @@ -882,93 +948,19 @@ test("app font size and conversation density apply independently", async ({ .poll(readSettingsChromeScale) .toEqual(["25.7143px", "15px", "19.2857px"]); - await larger.click(); - await expect(root).toHaveAttribute("data-conversation-density", "compact"); - await expect(root).toHaveAttribute("data-font-size", "larger"); - await expect(larger).toHaveAttribute("aria-pressed", "true"); - await expect - .poll(() => - page.evaluate( - (key) => window.localStorage.getItem(key), - FONT_SIZE_STORAGE_KEY, - ), - ) - .toBe("larger"); - await expect.poll(readScale).toEqual({ - authorLineHeight: 18.285714, - bodyGap: 0, - fontSize: "calc(18.285714px * .875)", - lineHeight: "calc(18.285714px * 1.25)", - paragraphGap: 0.375, - rowPadding: 0.25, - timestampFontSize: "calc(18.285714px * .75)", - timestampLineHeight: 18.285714, - }); - await expect - .poll(() => - previewMessage.evaluate((element) => { - const style = window.getComputedStyle(element); - return [style.fontSize, style.lineHeight]; - }), - ) - .toEqual(["16px", "22.8571px"]); - await expect.poll(readSettingsScale).toEqual({ - fontSize: "16px", - lineHeight: "22.8571px", - minHeight: "64px", - paddingBlock: "12px", - }); - await expect - .poll(readPreviewTimestampScale) - .toEqual(["13.7143px", "18.2857px"]); - await expect - .poll(readSettingsChromeScale) - .toEqual(["27.4286px", "16px", "20.5714px"]); - await waitForAnimations(page); - await page.getByTestId("appearance-preferences-card").screenshot({ - path: `${SHOTS}/15-conversation-compact-larger.png`, - }); - - await spacious.click(); - await expect(root).toHaveAttribute("data-conversation-density", "spacious"); - await expect(root).toHaveAttribute("data-font-size", "larger"); - await expect(spacious).toHaveAttribute("aria-pressed", "true"); - await expect.poll(readScale).toEqual({ - authorLineHeight: 18.285714, - bodyGap: 0.25, - fontSize: "calc(18.285714px * .875)", - lineHeight: "calc(18.285714px * 1.25)", - paragraphGap: 0.625, - rowPadding: 0.5, - timestampFontSize: "calc(18.285714px * .75)", - timestampLineHeight: 18.285714, - }); - await expect.poll(readSettingsScale).toEqual({ - fontSize: "16px", - lineHeight: "22.8571px", - minHeight: "64px", - paddingBlock: "12px", - }); - await expect - .poll(readPreviewTimestampScale) - .toEqual(["13.7143px", "18.2857px"]); - await expect - .poll(readSettingsChromeScale) - .toEqual(["27.4286px", "16px", "20.5714px"]); - await smaller.click(); await expect(root).toHaveAttribute("data-conversation-density", "spacious"); await expect(root).toHaveAttribute("data-font-size", "smaller"); await expect(smaller).toHaveAttribute("aria-pressed", "true"); await expect.poll(readScale).toEqual({ - authorLineHeight: 16, + authorLineHeight: 14.857143, bodyGap: 0.25, - fontSize: "calc(16px * .875)", - lineHeight: "calc(16px * 1.25)", + fontSize: "calc(14.857143px * .875)", + lineHeight: "calc(14.857143px * 1.25)", paragraphGap: 0.625, rowPadding: 0.5, - timestampFontSize: "calc(16px * .75)", - timestampLineHeight: 16, + timestampFontSize: "calc(14.857143px * .75)", + timestampLineHeight: 14.857143, }); await expect .poll(() => @@ -977,15 +969,19 @@ test("app font size and conversation density apply independently", async ({ return [style.fontSize, style.lineHeight]; }), ) - .toEqual(["14px", "20px"]); + .toEqual(["13px", "18.5714px"]); await expect.poll(readSettingsScale).toEqual({ - fontSize: "14px", - lineHeight: "20px", + fontSize: "13px", + lineHeight: "18.5714px", minHeight: "64px", paddingBlock: "12px", }); - await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); - await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "18px"]); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["11.1429px", "14.8571px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["22.2857px", "13px", "16.7143px"]); await waitForAnimations(page); await page.getByTestId("appearance-preferences-card").screenshot({ path: `${SHOTS}/16-conversation-spacious-smaller.png`, @@ -1028,18 +1024,18 @@ test("app font size and conversation density apply independently", async ({ ) .toBe("comfortable"); await expect.poll(readScale).toEqual({ - authorLineHeight: 17.142857, + authorLineHeight: 16, bodyGap: 0.25, - fontSize: "calc(17.142857px * .875)", - lineHeight: "calc(17.142857px * 1.25)", + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", paragraphGap: 0.625, rowPadding: 0.5, - timestampFontSize: "calc(17.142857px * .75)", - timestampLineHeight: 17.142857, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, }); await expect.poll(readSettingsScale).toEqual({ - fontSize: "15px", - lineHeight: "21.4286px", + fontSize: "14px", + lineHeight: "20px", minHeight: "64px", paddingBlock: "12px", }); @@ -1093,10 +1089,10 @@ test("app font size and conversation density apply independently", async ({ return [style.fontSize, style.lineHeight]; }), ) - .toEqual(["16px", "22.8571px"]); + .toEqual(["15px", "21.4286px"]); await expect .poll(readSettingsChromeScale) - .toEqual(["27.4286px", "16px", "20.5714px"]); + .toEqual(["25.7143px", "15px", "19.2857px"]); await page.mouse.up(); await expect(larger).toHaveAttribute("aria-pressed", "true"); await expect @@ -1175,8 +1171,8 @@ test("message layout switches the Appearance preview between Open and Bubbles", "background-color", "rgba(0, 0, 0, 0)", ); - await expect(previewSurface).toHaveCSS("border-radius", "17.1429px"); - await expect(previewSurface).toHaveCSS("padding-left", "15px"); + await expect(previewSurface).toHaveCSS("border-radius", "16px"); + await expect(previewSurface).toHaveCSS("padding-left", "14px"); await waitForAnimations(page); const indicator = page.getByTestId("message-style-control-indicator"); diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index a62d064d7..70664bd17 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { selectMessageQuickReaction } from "../helpers/messageActions"; const SELF_PUBKEY = "deadbeef".repeat(8); const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -770,13 +771,7 @@ test.describe("channel activity hover preview", () => { .getByTestId("message-row") .filter({ hasText: "Reacting here means I care" }); await expect(rootRow).toBeVisible(); - await rootRow.hover(); - const actionBar = page.getByTestId(`message-action-bar-${root.id}`); - await expect(actionBar).toBeVisible(); - await actionBar - .getByRole("button", { name: /^React with / }) - .first() - .click(); + await selectMessageQuickReaction(rootRow, /^React with /); await expect( rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }), ).toBeVisible(); diff --git a/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts b/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts index 901d1477a..df5ea1d23 100644 --- a/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts +++ b/desktop/tests/e2e/channel-shared-header-backdrop.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; +import { selectMessageAction } from "../helpers/messageActions"; type MockMessageWindow = Window & { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { @@ -77,9 +78,10 @@ test.describe("channel shared header backdrop", () => { }, ); - const replyButton = page.locator('[data-testid^="reply-message-"]').first(); - await expect(replyButton).toBeVisible(); - await replyButton.click({ force: true }); + const rootMessage = page + .getByTestId("message-row") + .filter({ hasText: "Root message for shared header backdrop coverage." }); + await selectMessageAction(rootMessage, "Reply"); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); const sharedBackdrop = page.getByTestId("channel-shared-header-backdrop"); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index a48799bcb..2c896a9b6 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -3,6 +3,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { installMockBridge } from "../helpers/bridge"; +import { + openMessageReactionPicker, + selectMessageQuickReaction, +} from "../helpers/messageActions"; import { waitForAnimations } from "../helpers/animations"; // Custom-emoji end-to-end guard. @@ -285,28 +289,17 @@ async function quickReactionStorageContains( }, emoji); } -test("message quick reaction tray stays neutral after selecting a tray emoji", async ({ +test("message quick reaction menu closes after selecting an emoji", async ({ page, }) => { await openGeneral(page); const row = reactionTargetRow(page); await expect(row).toBeVisible(); - await row.hover(); - - const quickReactionButton = row.getByRole("button", { - name: "React with :+1:", - }); - await expect(quickReactionButton).toBeVisible(); - await quickReactionButton.click(); + await selectMessageQuickReaction(row, "React with :+1:"); await expect(row.getByLabel("Toggle 👍 reaction")).toBeVisible(); - await row.hover(); - await expect(quickReactionButton).not.toHaveAttribute("aria-pressed", "true"); - await expect(quickReactionButton).not.toHaveClass(SELECTED_ACTION_CLASS); - await expect(messageReactionTrigger(row)).not.toHaveClass( - SELECTED_ACTION_CLASS, - ); + await expect(page.getByRole("menu")).toHaveCount(0); }); test("reacting with a custom emoji renders via the loopback media proxy", async ({ @@ -318,8 +311,7 @@ test("reacting with a custom emoji renders via the loopback media proxy", async // open the reaction picker. const row = reactionTargetRow(page); await expect(row).toBeVisible(); - await row.hover(); - await row.getByLabel("Open reactions").click(); + await openMessageReactionPicker(row); // emoji-mart renders inside a Shadow DOM web component. Search by shortcode // to surface the custom emoji, then click it. @@ -539,8 +531,7 @@ test("a system message accepts a custom-emoji reaction", async ({ page }) => { const row = page.getByTestId("system-message-row").first(); await expect(row).toBeVisible(); - await row.hover(); - await row.getByLabel("Open reactions").click(); + await openMessageReactionPicker(row); const picker = page.locator("em-emoji-picker"); await picker.locator("input[type='search']").fill(REACTION_SHORTCODE); @@ -563,8 +554,7 @@ test("emoji picker search input has spellcheck, autocorrect, and autocapitalize // Open the reaction picker on the seeded reactable message. const row = reactionTargetRow(page); await expect(row).toBeVisible(); - await row.hover(); - await row.getByLabel("Open reactions").click(); + await openMessageReactionPicker(row); // Wait for the picker to be visible, then read the shadow-root input attributes. const picker = page.locator("em-emoji-picker"); @@ -599,8 +589,7 @@ test("emoji picker search input is focused immediately on open (no manual click // focus via our shadow traversal before the user interacts. const row = reactionTargetRow(page); await expect(row).toBeVisible(); - await row.hover(); - await row.getByLabel("Open reactions").click(); + await openMessageReactionPicker(row); const picker = page.locator("em-emoji-picker"); await expect(picker.locator("input[type='search']")).toBeVisible(); diff --git a/desktop/tests/e2e/human-edit-agent-content.spec.ts b/desktop/tests/e2e/human-edit-agent-content.spec.ts index 0994fce23..b15f44dc7 100644 --- a/desktop/tests/e2e/human-edit-agent-content.spec.ts +++ b/desktop/tests/e2e/human-edit-agent-content.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { selectMessageAction } from "../helpers/messageActions"; // Fixed pubkey for the owned managed agent seeded in these tests. // Must not collide with any existing e2eBridge constant. @@ -189,8 +190,7 @@ test("owner can delete their owned agent's message from the thread panel", async await expect(agentRow).toBeVisible({ timeout: 10_000 }); // Open the thread panel by hovering the message and clicking Reply. - await agentRow.hover(); - await agentRow.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(agentRow, "Reply"); // Wait for the thread panel and confirm the thread head contains the agent message. const threadPanel = page.getByTestId("message-thread-panel"); diff --git a/desktop/tests/e2e/inbox-reactions.spec.ts b/desktop/tests/e2e/inbox-reactions.spec.ts index 8b8a48cf5..74138a0cc 100644 --- a/desktop/tests/e2e/inbox-reactions.spec.ts +++ b/desktop/tests/e2e/inbox-reactions.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { selectMessageQuickReaction } from "../helpers/messageActions"; import type { RelayEvent } from "../../src/shared/api/types"; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -172,9 +173,7 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({ ), ).toBeLessThanOrEqual(1); - await selectedMessage - .getByRole("button", { name: "React with :+1:" }) - .click(); + await selectMessageQuickReaction(selectedMessage, "React with :+1:"); // The pill must appear AND persist: the post-toggle refetch replaces the // optimistic state with fetched reaction events. Give the refetch time to diff --git a/desktop/tests/e2e/message-feedback-snapshots.spec.ts b/desktop/tests/e2e/message-feedback-snapshots.spec.ts index a3ac92561..677954c37 100644 --- a/desktop/tests/e2e/message-feedback-snapshots.spec.ts +++ b/desktop/tests/e2e/message-feedback-snapshots.spec.ts @@ -120,6 +120,66 @@ test("profile hover uses the channel hover surface", async ({ page }) => { .screenshot({ path: `${SHOTS}/profile-hover.png` }); }); +test("open messages use the compact ellipsis actions menu", async ({ + page, +}) => { + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz.quick-reaction-emojis.v1:e2e-default-community", + JSON.stringify([{ count: 2, emoji: "🔥", lastUsedAt: Date.now() }]), + ); + }); + await installMockBridge(page); + 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 actionBar = row.locator('[data-testid^="message-action-bar-"]'); + await expect(actionBar).toHaveAttribute("data-presentation", "menu"); + await expect( + actionBar.getByRole("button", { name: "More actions" }), + ).toBeVisible(); + await expect( + actionBar.getByRole("button", { name: "Open reactions" }), + ).toHaveCount(0); + await expect(actionBar.getByRole("button", { name: "Reply" })).toHaveCount(0); + + const [rowBox, actionBarBox] = await Promise.all([ + row.boundingBox(), + actionBar.boundingBox(), + ]); + if (!rowBox || !actionBarBox) { + throw new Error("Expected open message action geometry."); + } + expect(actionBarBox.y).toBeGreaterThanOrEqual(rowBox.y); + expect(actionBarBox.x + actionBarBox.width).toBeLessThanOrEqual( + rowBox.x + rowBox.width + 1, + ); + + await actionBar.getByRole("button", { name: "More actions" }).click(); + await expect(page.getByRole("menu")).toBeVisible(); + const quickReactions = page.locator( + '[data-testid^="message-quick-reactions-"]', + ); + await expect(quickReactions).toBeVisible(); + await expect( + quickReactions.getByRole("button", { name: /^React with / }), + ).toHaveCount(5); + await expect( + page.getByRole("menuitem", { name: "Reply", exact: true }), + ).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/open-message-actions.png` }); +}); + test("left-aligned continuation bubbles join on the left edge", async ({ page, }) => { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 830f1e37e..2b6dbc129 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type Locator } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; +import { selectMessageAction } from "../helpers/messageActions"; import { openSettings } from "../helpers/settings"; async function waitForReadyComposerSnapshots( @@ -2575,7 +2576,7 @@ test("thread panel width uses session storage and reset handle", async ({ ); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); await expect(threadPanel).toBeVisible(); await expect @@ -2636,7 +2637,7 @@ test("thread panel width uses session storage and reset handle", async ({ await expect(threadPanel).toBeHidden(); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); await expect(threadPanel).toBeVisible(); await expect @@ -2663,8 +2664,7 @@ test("narrow thread view collapses channel header actions into a menu", async ({ const rootMessage = page.locator('[data-message-id="mock-general-alice"]'); const threadPanel = page.getByTestId("message-thread-panel"); - await rootMessage.hover(); - await page.getByTestId("reply-message-mock-general-alice").click(); + await selectMessageAction(rootMessage, "Reply"); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0); @@ -2707,8 +2707,7 @@ test("single-panel thread view hides channel actions", async ({ page }) => { const rootMessage = page.locator('[data-message-id="mock-general-alice"]'); const threadPanel = page.getByTestId("message-thread-panel"); - await rootMessage.hover(); - await page.getByTestId("reply-message-mock-general-alice").click(); + await selectMessageAction(rootMessage, "Reply"); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-back")).toBeVisible(); await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0); @@ -2765,7 +2764,7 @@ test("thread composer is focused after clicking the reply icon", async ({ .getByTestId("message-row") .last(); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -2793,7 +2792,7 @@ test("thread refetch preserves a live reply and reaction received in flight", as if (!rootId) throw new Error("Expected a thread root id."); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -2852,7 +2851,7 @@ test("thread reply appears after relay closes and restores its live subscription .getByTestId("message-row") .last(); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); const reply = `Thread reply after CLOSED ${Date.now()}`; @@ -2882,7 +2881,7 @@ test("thread composer keeps focus after sending a thread reply", async ({ .getByTestId("message-row") .last(); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -2992,7 +2991,7 @@ test("ArrowUp edits your last thread reply right after sending it", async ({ .getByTestId("message-row") .last(); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -3036,7 +3035,7 @@ test("action bar stays within the timeline when the thread panel is open", async const rootMessage = timeline.getByTestId("message-row").first(); await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); await expect(page.getByTestId("message-thread-panel")).toBeVisible(); const wideRow = timeline.getByTestId("message-row").last(); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index eb76ef3a4..ad86c6017 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { selectMessageAction } from "../helpers/messageActions"; import { openSettings } from "../helpers/settings"; const ENGINEERING_CHANNEL_ID = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"; @@ -159,8 +160,7 @@ test("back and forward restore open thread panels", async ({ page }) => { .getByTestId("message-timeline") .getByTestId("message-row") .first(); - await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -189,8 +189,7 @@ test("back undoes closing a thread panel", async ({ page }) => { .getByTestId("message-timeline") .getByTestId("message-row") .first(); - await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -212,8 +211,7 @@ test("open thread panels survive reload", async ({ page }) => { .getByTestId("message-timeline") .getByTestId("message-row") .first(); - await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); @@ -274,8 +272,7 @@ test("settings is a route: section survives reload, closing returns to the previ .getByTestId("message-timeline") .getByTestId("message-row") .first(); - await rootMessage.hover(); - await rootMessage.getByRole("button", { name: "Reply" }).click(); + await selectMessageAction(rootMessage, "Reply"); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); const channelUrl = page.url(); diff --git a/desktop/tests/e2e/reaction-order.spec.ts b/desktop/tests/e2e/reaction-order.spec.ts index cf758f2da..f28591839 100644 --- a/desktop/tests/e2e/reaction-order.spec.ts +++ b/desktop/tests/e2e/reaction-order.spec.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { installMockBridge } from "../helpers/bridge"; +import { selectMessageQuickReaction } from "../helpers/messageActions"; import { waitForAnimations } from "../helpers/animations"; // Reaction ordering end-to-end guard. @@ -97,10 +98,7 @@ async function addQuickReaction( emoji: string, label: string, ) { - await row.hover(); - const btn = row.getByRole("button", { name: `React with ${label}` }); - await expect(btn).toBeVisible(); - await btn.click(); + await selectMessageQuickReaction(row, `React with ${label}`); // Wait for the optimistic pill to appear before continuing. await expect( row diff --git a/desktop/tests/helpers/messageActions.ts b/desktop/tests/helpers/messageActions.ts new file mode 100644 index 000000000..71e0f0f97 --- /dev/null +++ b/desktop/tests/helpers/messageActions.ts @@ -0,0 +1,33 @@ +import { expect, type Locator } from "@playwright/test"; + +export async function openMessageActions(row: Locator) { + await row.hover(); + const trigger = row.getByRole("button", { name: "More actions" }); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("data-state", "closed"); + await trigger.click(); + + const menu = row.page().getByRole("menu"); + await expect(menu).toBeVisible(); + return menu; +} + +export async function selectMessageAction(row: Locator, name: string) { + const menu = await openMessageActions(row); + await menu.getByRole("menuitem", { name, exact: true }).click(); +} + +export async function openMessageReactionPicker(row: Locator) { + const menu = await openMessageActions(row); + await menu.getByRole("button", { name: "Open reactions" }).click(); +} + +export async function selectMessageQuickReaction( + row: Locator, + name: string | RegExp, +) { + const menu = await openMessageActions(row); + const reaction = menu.getByRole("button", { name }).first(); + await expect(reaction).toBeVisible(); + await reaction.click(); +}