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();
+}