mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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:
@@ -38,6 +38,7 @@ export default defineConfig({
|
|||||||
"**/invites-settings-screenshots.spec.ts",
|
"**/invites-settings-screenshots.spec.ts",
|
||||||
"**/messaging.spec.ts",
|
"**/messaging.spec.ts",
|
||||||
"**/message-feedback-snapshots.spec.ts",
|
"**/message-feedback-snapshots.spec.ts",
|
||||||
|
"**/message-copy-link.spec.ts",
|
||||||
"**/custom-emoji.spec.ts",
|
"**/custom-emoji.spec.ts",
|
||||||
"**/profile-custom-emoji-status.spec.ts",
|
"**/profile-custom-emoji-status.spec.ts",
|
||||||
"**/custom-emoji-ui.spec.ts",
|
"**/custom-emoji-ui.spec.ts",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { buildMessageLink } from "@/features/messages/lib/messageLink";
|
import { buildMessageLink } from "@/features/messages/lib/messageLink";
|
||||||
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
|
||||||
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
|
|
||||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||||
import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog";
|
import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog";
|
||||||
import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems";
|
import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems";
|
||||||
@@ -26,15 +25,9 @@ import type {
|
|||||||
TimelineMessage,
|
TimelineMessage,
|
||||||
TimelineReaction,
|
TimelineReaction,
|
||||||
} from "@/features/messages/types";
|
} from "@/features/messages/types";
|
||||||
import {
|
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
|
||||||
recordQuickReactionEmoji,
|
|
||||||
useQuickReactionEmojis,
|
|
||||||
} from "@/features/messages/ui/useQuickReactionEmojis";
|
|
||||||
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
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 { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { HashArrowIn } from "@/shared/ui/icons";
|
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_BUTTON_CLASS = "h-8 w-8 rounded-full p-0";
|
||||||
const ACTION_ICON_CLASS = "!h-4 !w-4";
|
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({
|
function MoreActionsMenu({
|
||||||
channelId,
|
channelId,
|
||||||
message,
|
message,
|
||||||
@@ -242,17 +261,11 @@ function MoreActionsMenu({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasCopyActions && channelId ? (
|
{canCopyMessageLink(message, channelId) ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid={`copy-message-link-${message.id}`}
|
data-testid={`copy-message-link-${message.id}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const { rootId } = getThreadReference(message.tags ?? []);
|
copyMessageLink(channelId, message);
|
||||||
const link = buildMessageLink({
|
|
||||||
channelId,
|
|
||||||
messageId: message.id,
|
|
||||||
threadRootId: rootId,
|
|
||||||
});
|
|
||||||
copyTextToClipboard(link, "Link copied to clipboard");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link2 className="h-4 w-4" />
|
<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({
|
export const MessageActionBar = React.memo(function MessageActionBar({
|
||||||
channelId,
|
channelId,
|
||||||
message,
|
message,
|
||||||
@@ -404,20 +372,6 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
|||||||
}) {
|
}) {
|
||||||
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = 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 hasReplyAction = Boolean(onReply);
|
||||||
const hasReactionAction = Boolean(onReactionSelect);
|
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="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">
|
<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 ? (
|
{hasReactionAction ? (
|
||||||
<Popover
|
<Popover
|
||||||
onOpenChange={setIsReactionPickerOpen}
|
onOpenChange={setIsReactionPickerOpen}
|
||||||
@@ -566,6 +504,27 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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 ? (
|
{hasMoreMenuActions ? (
|
||||||
<MoreActionsMenu
|
<MoreActionsMenu
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
|
|||||||
@@ -773,10 +773,11 @@ test.describe("channel activity hover preview", () => {
|
|||||||
await rootRow.hover();
|
await rootRow.hover();
|
||||||
const actionBar = page.getByTestId(`message-action-bar-${root.id}`);
|
const actionBar = page.getByTestId(`message-action-bar-${root.id}`);
|
||||||
await expect(actionBar).toBeVisible();
|
await expect(actionBar).toBeVisible();
|
||||||
await actionBar
|
await actionBar.getByRole("button", { name: "Open reactions" }).click();
|
||||||
.getByRole("button", { name: /^React with / })
|
const picker = page.locator("em-emoji-picker");
|
||||||
.first()
|
await expect(picker).toBeVisible();
|
||||||
.click();
|
await picker.locator("input[type='search']").fill("thumbs up");
|
||||||
|
await picker.getByRole("button", { name: "👍" }).first().click();
|
||||||
await expect(
|
await expect(
|
||||||
rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }),
|
rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ async function quickReactionStorageContains(
|
|||||||
}, emoji);
|
}, 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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await openGeneral(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 expect(row).toBeVisible();
|
||||||
await row.hover();
|
await row.hover();
|
||||||
|
|
||||||
const quickReactionButton = row.getByRole("button", {
|
const reactionTrigger = messageReactionTrigger(row);
|
||||||
name: "React with :+1:",
|
await expect(reactionTrigger).toBeVisible();
|
||||||
});
|
await reactionTrigger.click();
|
||||||
await expect(quickReactionButton).toBeVisible();
|
const picker = page.locator("em-emoji-picker");
|
||||||
await quickReactionButton.click();
|
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 expect(row.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
||||||
await row.hover();
|
await row.hover();
|
||||||
await expect(quickReactionButton).not.toHaveAttribute("aria-pressed", "true");
|
await expect(reactionTrigger).not.toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(quickReactionButton).not.toHaveClass(SELECTED_ACTION_CLASS);
|
await expect(reactionTrigger).not.toHaveClass(SELECTED_ACTION_CLASS);
|
||||||
await expect(messageReactionTrigger(row)).not.toHaveClass(
|
|
||||||
SELECTED_ACTION_CLASS,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emoji picker keeps Frequently used live within the app session", async ({
|
test("emoji picker keeps Frequently used live within the app session", async ({
|
||||||
|
|||||||
@@ -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}`);
|
const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`);
|
||||||
await item.click();
|
await item.click();
|
||||||
const detail = page.getByTestId("home-inbox-detail");
|
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);
|
expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y);
|
||||||
|
|
||||||
await selectedMessage
|
await actionBar.getByRole("button", { name: "Open reactions" }).click();
|
||||||
.getByRole("button", { name: "React with :+1:" })
|
const picker = page.locator("em-emoji-picker");
|
||||||
.click();
|
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
|
// The pill must appear AND persist: the post-toggle refetch replaces the
|
||||||
// optimistic state with fetched reaction events. Give the refetch time to
|
// optimistic state with fetched reaction events. Give the refetch time to
|
||||||
|
|||||||
182
desktop/tests/e2e/message-copy-link.spec.ts
Normal file
182
desktop/tests/e2e/message-copy-link.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -64,16 +64,19 @@ async function getBodyToReactionGap(
|
|||||||
return Math.round(reactions.y - (body.y + body.height));
|
return Math.round(reactions.y - (body.y + body.height));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click a quick-reaction tray button by emoji (hover → click tray button). */
|
/** Add a reaction through the preserved message action and emoji picker. */
|
||||||
async function addQuickReaction(
|
async function addReaction(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
row: import("@playwright/test").Locator,
|
row: import("@playwright/test").Locator,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
label: string,
|
search: string,
|
||||||
) {
|
) {
|
||||||
await row.hover();
|
await row.hover();
|
||||||
const btn = row.getByRole("button", { name: `React with ${label}` });
|
await row.getByRole("button", { name: "Open reactions" }).click();
|
||||||
await expect(btn).toBeVisible();
|
const picker = page.locator("em-emoji-picker");
|
||||||
await btn.click();
|
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.
|
// Wait for the optimistic pill to appear before continuing.
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row
|
||||||
@@ -97,11 +100,11 @@ test("reaction pills render left-to-right in the order reactions were added", as
|
|||||||
// Add three reactions in order: 👍 → ❤️ → 😂.
|
// Add three reactions in order: 👍 → ❤️ → 😂.
|
||||||
// Wait >1 s between each so mock bridge timestamps differ by at least 1 Unix
|
// 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.
|
// 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 page.waitForTimeout(1100);
|
||||||
await addQuickReaction(row, "❤️", ":heart:");
|
await addReaction(page, row, "❤️", "red heart");
|
||||||
await page.waitForTimeout(1100);
|
await page.waitForTimeout(1100);
|
||||||
await addQuickReaction(row, "😂", ":joy:");
|
await addReaction(page, row, "😂", "face with tears of joy");
|
||||||
|
|
||||||
const pills = await getPillOrder(row);
|
const pills = await getPillOrder(row);
|
||||||
expect(pills).toEqual(["👍", "❤️", "😂"]);
|
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
|
// since the key invariant is positional stability: even if ❤️ had higher
|
||||||
// count it must stay right of 👍.
|
// count it must stay right of 👍.
|
||||||
// We use 🎉 (added second) and verify it stays right of 👍 (added first).
|
// 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 page.waitForTimeout(1100);
|
||||||
await addQuickReaction(row, "🎉", ":tada:");
|
await addReaction(page, row, "🎉", "party popper");
|
||||||
|
|
||||||
// Both pills present in chronological order: 👍 left, 🎉 right.
|
// Both pills present in chronological order: 👍 left, 🎉 right.
|
||||||
const afterAdd = await getPillOrder(row);
|
const afterAdd = await getPillOrder(row);
|
||||||
|
|||||||
Reference in New Issue
Block a user