mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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",
|
||||
"**/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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
Reference in New Issue
Block a user