diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 69250c4b537..be15c75587d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 4fcf0f067ab..9c490edb076 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -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({ ) : null} - {hasCopyActions && channelId ? ( + {canCopyMessageLink(message, channelId) ? ( { - const { rootId } = getThreadReference(message.tags ?? []); - const link = buildMessageLink({ - channelId, - messageId: message.id, - threadRootId: rootId, - }); - copyTextToClipboard(link, "Link copied to clipboard"); + copyMessageLink(channelId, message); }} > @@ -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 ( - - - - - {displayName} - - ); -} - -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({ >
- {hasReactionAction && quickReactionItems.length > 0 ? ( - <> -
- {quickReactionItems.map(({ customEmojiUrl, emoji }) => ( - - ))} -
-
- - ) : null} - {hasReactionAction ? ( ) : null} + {canCopyMessageLink(message, channelId) ? ( + + + + + Copy link + + ) : null} + {hasMoreMenuActions ? ( { 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(); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 16ca3900ece..9a4bf7f07cc 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -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 ({ diff --git a/desktop/tests/e2e/inbox-reactions.spec.ts b/desktop/tests/e2e/inbox-reactions.spec.ts index 1b7451cbcc2..2385be0f6c3 100644 --- a/desktop/tests/e2e/inbox-reactions.spec.ts +++ b/desktop/tests/e2e/inbox-reactions.spec.ts @@ -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 diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts new file mode 100644 index 00000000000..a7ef2f10c62 --- /dev/null +++ b/desktop/tests/e2e/message-copy-link.spec.ts @@ -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, + ); +}); diff --git a/desktop/tests/e2e/reaction-order.spec.ts b/desktop/tests/e2e/reaction-order.spec.ts index 34cf071e771..b37ce56dd13 100644 --- a/desktop/tests/e2e/reaction-order.spec.ts +++ b/desktop/tests/e2e/reaction-order.spec.ts @@ -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);