feat(desktop): simplify the message action rail (#6529)

## Problem

Quick-reaction shortcuts make the message action rail visually noisy.
Reaction access should remain first-class, but through one predictable
Add reaction action rather than several learned emoji shortcuts.

## Change

The rail is now:

**Add reaction · Reply · Copy link · More**

- Remove all quick-reaction shortcut buttons and their divider from the
message action rail.
- Preserve Add reaction as the first action, including its existing
picker, tooltip, accessible name, keyboard behavior, reaction behavior,
and feedback.
- Keep the existing Link2 Copy link action, shared copy handler,
More-menu entry, eligibility guards, and success feedback unchanged.
- Keep reveal, positioning, responsiveness, styling, and remaining menu
paths unchanged.

The now-unused quick-reaction rendering component and imports were
removed from `MessageActionBar`; shared reaction learning remains intact
for other reaction surfaces.

## Tests

Focused smoke coverage verifies:

- zero `React with …` shortcut buttons;
- exact ordered rail: `Open reactions → Reply → Copy link → More
actions`;
- the rail and More-menu paths emit the same canonical thread-aware
`buzz://message` URL;
- existing success feedback;
- pending and huddle rows omit both copy-link surfaces;
- the action bar stays within the open thread panel.

The zero-shortcut contract was mutation-checked by restoring the prior
production action bar: the focused test failed causally with expected 0
versus received 3 quick-reaction buttons.

## Validation

At `4f062e0d60b0f0c16b6862cdea7137c287060947`:

- `pnpm exec biome check src/features/messages/ui/MessageActionBar.tsx
tests/e2e/message-copy-link.spec.ts` — passed.
- `pnpm exec tsc --noEmit` — passed.
- `pnpm test` — 5,432 passed, 0 failed.
- `pnpm build:e2e` — passed; existing dynamic-import and chunk-size
warnings only.
- `pnpm exec playwright test tests/e2e/message-copy-link.spec.ts
--project=smoke` — 2 passed.
- Pre-push `file-size-check`, `desktop-check`, `desktop-typecheck`, and
`desktop-test` — passed.

Vogue’s design review: **SHIP** — reaction remains discoverable and
accessible as the visible first action; the extra click is an
intentional efficiency tradeoff for the simpler hierarchy.

---------

Signed-off-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
Co-authored-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz>
Co-authored-by: Rivet <a08d9a8418c7ff03afe19964724c8fd87bf1776ab9e9b9cafb8cc920edd02a6e@buzz.block.builderlab.xyz>
This commit is contained in:
morgmart
2026-08-24 16:28:51 -07:00
committed by GitHub
parent 30d2fc52f9
commit c5166f2164
7 changed files with 268 additions and 121 deletions

View File

@@ -38,6 +38,7 @@ export default defineConfig({
"**/invites-settings-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/message-feedback-snapshots.spec.ts",
"**/message-copy-link.spec.ts",
"**/custom-emoji.spec.ts",
"**/profile-custom-emoji-status.spec.ts",
"**/custom-emoji-ui.spec.ts",

View File

@@ -18,7 +18,6 @@ import { toast } from "sonner";
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { getThreadReference } from "@/features/messages/lib/threading";
import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog";
import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems";
@@ -26,15 +25,9 @@ import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import {
recordQuickReactionEmoji,
useQuickReactionEmojis,
} from "@/features/messages/ui/useQuickReactionEmojis";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import { cn } from "@/shared/lib/cn";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
import { Button } from "@/shared/ui/button";
import { HashArrowIn } from "@/shared/ui/icons";
@@ -53,6 +46,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
const ACTION_ICON_CLASS = "!h-4 !w-4";
/** Copying a message link is offered from both the hover action bar and the
* More menu; both paths share this exact link-building + toast behavior. */
function copyMessageLink(channelId: string, message: TimelineMessage) {
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
messageId: message.id,
threadRootId: rootId,
});
copyTextToClipboard(link, "Link copied to clipboard");
}
/** Gate shared by every copy-link surface: pending sends have no delivered
* event to link to, huddle system rows aren't linkable, and callers without
* a channelId (e.g. inbox preview rows) can't build the link. */
function canCopyMessageLink(
message: TimelineMessage,
channelId: string | null | undefined,
): channelId is string {
return (
!message.pending &&
message.kind !== KIND_HUDDLE_STARTED &&
Boolean(channelId)
);
}
function MoreActionsMenu({
channelId,
message,
@@ -242,17 +261,11 @@ function MoreActionsMenu({
</DropdownMenuItem>
) : null}
{hasCopyActions && channelId ? (
{canCopyMessageLink(message, channelId) ? (
<DropdownMenuItem
data-testid={`copy-message-link-${message.id}`}
onClick={() => {
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
messageId: message.id,
threadRootId: rootId,
});
copyTextToClipboard(link, "Link copied to clipboard");
copyMessageLink(channelId, message);
}}
>
<Link2 className="h-4 w-4" />
@@ -316,51 +329,6 @@ function MoreActionsMenu({
);
}
function QuickReactionButton({
customEmojiUrl,
emoji,
onSelect,
}: {
customEmojiUrl?: string;
emoji: string;
onSelect: (emoji: string) => void;
}) {
const displayName = emojiDisplayName(emoji);
const mediaUrl = customEmojiUrl ? rewriteRelayUrl(customEmojiUrl) : null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`React with ${displayName}`}
className="flex h-8 w-8 items-center justify-center rounded-full text-base leading-none text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
onClick={() => onSelect(emoji)}
title={displayName}
type="button"
>
{mediaUrl ? (
<img
alt={emoji}
className="h-5 w-5 object-contain"
draggable={false}
src={mediaUrl}
/>
) : (
<span aria-hidden="true" className="translate-y-px">
{emoji}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>{displayName}</TooltipContent>
</Tooltip>
);
}
function isCustomEmojiShortcode(emoji: string) {
return emoji.startsWith(":") && emoji.endsWith(":");
}
export const MessageActionBar = React.memo(function MessageActionBar({
channelId,
message,
@@ -404,20 +372,6 @@ 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 quickReactionItems = React.useMemo(
() =>
quickReactionEmojis
.map((emoji) => ({
customEmojiUrl: reactionEmojiUrl(emoji, customEmoji),
emoji,
}))
.filter(
(item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl,
),
[customEmoji, quickReactionEmojis],
);
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
@@ -482,22 +436,6 @@ export const MessageActionBar = React.memo(function MessageActionBar({
>
<div className="overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85">
<div className="flex items-center gap-0.5 p-1">
{hasReactionAction && quickReactionItems.length > 0 ? (
<>
<div className="hidden items-center gap-0.5 sm:flex">
{quickReactionItems.map(({ customEmojiUrl, emoji }) => (
<QuickReactionButton
customEmojiUrl={customEmojiUrl}
emoji={emoji}
key={emoji}
onSelect={handleReactionSelection}
/>
))}
</div>
<div className="mx-0.5 hidden h-4 w-px bg-border/70 sm:block" />
</>
) : null}
{hasReactionAction ? (
<Popover
onOpenChange={setIsReactionPickerOpen}
@@ -566,6 +504,27 @@ export const MessageActionBar = React.memo(function MessageActionBar({
</Tooltip>
) : null}
{canCopyMessageLink(message, channelId) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy link"
className={ACTION_BUTTON_CLASS}
data-testid={`copy-link-message-${message.id}`}
onClick={() => {
copyMessageLink(channelId, message);
}}
size="sm"
type="button"
variant="ghost"
>
<Link2 className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>Copy link</TooltipContent>
</Tooltip>
) : null}
{hasMoreMenuActions ? (
<MoreActionsMenu
channelId={channelId}

View File

@@ -773,10 +773,11 @@ test.describe("channel activity hover preview", () => {
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 actionBar.getByRole("button", { name: "Open reactions" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().click();
await expect(
rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }),
).toBeVisible();

View File

@@ -285,7 +285,7 @@ async function quickReactionStorageContains(
}, emoji);
}
test("message quick reaction tray stays neutral after selecting a tray emoji", async ({
test("message reaction action stays neutral after selecting from the picker", async ({
page,
}) => {
await openGeneral(page);
@@ -294,19 +294,18 @@ test("message quick reaction tray stays neutral after selecting a tray emoji", a
await expect(row).toBeVisible();
await row.hover();
const quickReactionButton = row.getByRole("button", {
name: "React with :+1:",
});
await expect(quickReactionButton).toBeVisible();
await quickReactionButton.click();
const reactionTrigger = messageReactionTrigger(row);
await expect(reactionTrigger).toBeVisible();
await reactionTrigger.click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().click();
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(reactionTrigger).not.toHaveAttribute("aria-pressed", "true");
await expect(reactionTrigger).not.toHaveClass(SELECTED_ACTION_CLASS);
});
test("emoji picker keeps Frequently used live within the app session", async ({

View File

@@ -114,7 +114,7 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
},
);
// Open the inbox item and react via the hover action bar's quick reaction.
// Open the inbox item and react through Add reaction and the picker.
const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`);
await item.click();
const detail = page.getByTestId("home-inbox-detail");
@@ -152,9 +152,11 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
}
expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y);
await selectedMessage
.getByRole("button", { name: "React with :+1:" })
.click();
await actionBar.getByRole("button", { name: "Open reactions" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill("thumbs up");
await picker.getByRole("button", { name: "👍" }).first().click();
// The pill must appear AND persist: the post-toggle refetch replaces the
// optimistic state with fetched reaction events. Give the refetch time to

View File

@@ -0,0 +1,182 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { KIND_HUDDLE_STARTED } from "../../src/shared/constants/kinds";
import { installMockBridge } from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
async function latestClipboardWrite(page: Page) {
return page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast(
({ command }) => command === "copy_text_to_clipboard",
),
);
}
async function expectCopyLinkUnavailable(row: Locator, messageId: string) {
await row.hover();
await expect(row.getByTestId(`copy-link-message-${messageId}`)).toHaveCount(
0,
);
const moreActions = row.getByTestId(`more-actions-${messageId}`);
if (await moreActions.count()) {
await moreActions.click({ force: true });
await expect(
row.page().getByTestId(`copy-message-link-${messageId}`),
).toHaveCount(0);
await row.page().keyboard.press("Escape");
}
}
test.beforeEach(async ({ page }) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
await installMockBridge(page);
});
test("message action rail copies the same canonical thread link as More", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect
.poll(() =>
page.evaluate(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
),
)
.toBe(true);
const { replyId, rootId } = await page.evaluate(() => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is unavailable.");
const root = emit({
channelName: "general",
content: "Copy-link regression root",
id: "a".repeat(64),
});
const reply = emit({
channelName: "general",
content: "Copy-link regression reply",
id: "b".repeat(64),
parentEventId: root.id,
});
return { replyId: reply.id, rootId: root.id };
});
await page
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`,
)
.click();
const threadPanel = page.getByTestId("message-thread-panel");
const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`);
await expect(replyRow).toContainText("Copy-link regression reply");
await replyRow.hover();
const actionBar = replyRow.getByTestId(`message-action-bar-${replyId}`);
const quickReactions = actionBar.getByRole("button", {
name: /^React with /,
});
await expect(quickReactions).toHaveCount(0);
const orderedActionNames = await actionBar
.getByRole("button")
.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label")),
);
expect(orderedActionNames).toEqual([
"Open reactions",
"Reply",
"Copy link",
"More actions",
]);
const copyLink = actionBar.getByTestId(`copy-link-message-${replyId}`);
await expect(copyLink).toHaveAccessibleName("Copy link");
await copyLink.hover();
await expect(page.getByRole("tooltip", { name: "Copy link" })).toBeVisible();
const expectedLink = `buzz://message?channel=${GENERAL_CHANNEL_ID}&id=${replyId}&thread=${rootId}`;
await copyLink.click();
await expect
.poll(async () => (await latestClipboardWrite(page))?.payload.text)
.toBe(expectedLink);
await expect(
page.locator("[data-sonner-toast]").filter({
hasText: "Link copied to clipboard",
}),
).toBeVisible();
await actionBar.getByTestId(`more-actions-${replyId}`).click();
await page.getByTestId(`copy-message-link-${replyId}`).click();
await expect
.poll(async () => {
const writes = (await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
({ command }) => command === "copy_text_to_clipboard",
),
)) as Array<{ payload: unknown }>;
return writes.map(({ payload }) => payload);
})
.toEqual([{ text: expectedLink }, { text: expectedLink }]);
const [barBox, panelBox] = await Promise.all([
actionBar.boundingBox(),
threadPanel.boundingBox(),
]);
expect(barBox).not.toBeNull();
expect(panelBox).not.toBeNull();
if (!barBox || !panelBox) throw new Error("Message action bounds missing.");
expect(barBox.x).toBeGreaterThanOrEqual(panelBox.x);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(
panelBox.x + panelBox.width,
);
});
test("pending and huddle rows omit both copy-link surfaces", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect
.poll(() =>
page.evaluate(
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
),
)
.toBe(true);
const { huddleId, pendingId } = await page.evaluate((huddleKind) => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is unavailable.");
const pending = emit({
channelName: "general",
content: "Pending copy-link regression",
id: "c".repeat(64),
pending: true,
});
const huddle = emit({
channelName: "general",
content: JSON.stringify({
ephemeral_channel_id: "10000000-0000-4000-8000-000000000001",
}),
id: "d".repeat(64),
kind: huddleKind,
});
return { huddleId: huddle.id, pendingId: pending.id };
}, KIND_HUDDLE_STARTED);
await expectCopyLinkUnavailable(
page.locator(`[data-message-id="${pendingId}"]`),
pendingId,
);
await expectCopyLinkUnavailable(
page.locator(`[data-message-id="${huddleId}"]`),
huddleId,
);
});

View File

@@ -64,16 +64,19 @@ async function getBodyToReactionGap(
return Math.round(reactions.y - (body.y + body.height));
}
/** Click a quick-reaction tray button by emoji (hover → click tray button). */
async function addQuickReaction(
/** Add a reaction through the preserved message action and emoji picker. */
async function addReaction(
page: import("@playwright/test").Page,
row: import("@playwright/test").Locator,
emoji: string,
label: string,
search: string,
) {
await row.hover();
const btn = row.getByRole("button", { name: `React with ${label}` });
await expect(btn).toBeVisible();
await btn.click();
await row.getByRole("button", { name: "Open reactions" }).click();
const picker = page.locator("em-emoji-picker");
await expect(picker).toBeVisible();
await picker.locator("input[type='search']").fill(search);
await picker.getByRole("button", { name: emoji }).first().click();
// Wait for the optimistic pill to appear before continuing.
await expect(
row
@@ -97,11 +100,11 @@ test("reaction pills render left-to-right in the order reactions were added", as
// Add three reactions in order: 👍 → ❤️ → 😂.
// Wait >1 s between each so mock bridge timestamps differ by at least 1 Unix
// second — the formatter sorts by created_at, so distinct seconds matter.
await addQuickReaction(row, "👍", ":+1:");
await addReaction(page, row, "👍", "thumbs up");
await page.waitForTimeout(1100);
await addQuickReaction(row, "❤️", ":heart:");
await addReaction(page, row, "❤️", "red heart");
await page.waitForTimeout(1100);
await addQuickReaction(row, "😂", ":joy:");
await addReaction(page, row, "😂", "face with tears of joy");
const pills = await getPillOrder(row);
expect(pills).toEqual(["👍", "❤️", "😂"]);
@@ -136,9 +139,9 @@ test("a later emoji that accrues more reactors stays to the right of an earlier
// since the key invariant is positional stability: even if ❤️ had higher
// count it must stay right of 👍.
// We use 🎉 (added second) and verify it stays right of 👍 (added first).
await addQuickReaction(row, "👍", ":+1:");
await addReaction(page, row, "👍", "thumbs up");
await page.waitForTimeout(1100);
await addQuickReaction(row, "🎉", ":tada:");
await addReaction(page, row, "🎉", "party popper");
// Both pills present in chronological order: 👍 left, 🎉 right.
const afterAdd = await getPillOrder(row);