diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 889847193e..4f46b5914e 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -55,7 +55,7 @@ export function FileCard({ ); }} data-testid="file-card" - className="my-1 inline-flex max-w-sm items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70" + className="my-1 inline-flex max-w-[min(24rem,100%)] items-center gap-3 rounded-2xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70" style={{ borderRadius: "1rem" }} > diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 2d8e5f8769..9072513749 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -725,3 +725,139 @@ test("a non-media attachment's remove button is named and keyboard-operable", as await page.keyboard.press("Enter"); await expect(composer).not.toContainText("quarterly-report.pdf"); }); + +// Shared by the two FileCard width-cap tests below. FileCard is an inline-flex +// (shrink-to-fit) box whose min-content width is set by its `truncate` +// (nowrap) filename, so a long name is what drives the card to its cap. +const LONG_ATTACHMENT_FILENAME = + "Running onboarding-flow first run with a temporary identity and wiped communities.txt"; +// 24rem at the default 16px root — the 24rem half of FileCard's +// `max-w-[min(24rem,100%)]` cap. +const FILE_CARD_CAP_PX = 384; + +async function seedLongNamedAttachment(page: Page) { + const sha256 = "b".repeat(64); + const url = `https://mock.relay/media/${sha256}.txt`; + + await page.goto("/"); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function", + null, + { timeout: 5_000 }, + ); + await page.evaluate( + async ({ content, mediaTags }) => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__: ( + command: string, + payload: Record, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + await invoke("send_channel_message", { + channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + content, + mediaTags, + }); + }, + { + content: `[${LONG_ATTACHMENT_FILENAME}](${url})`, + mediaTags: [ + [ + "imeta", + `url ${url}`, + "m text/plain", + `x ${sha256}`, + "size 13312", + `filename ${LONG_ATTACHMENT_FILENAME}`, + ], + ], + }, + ); + + await page.getByTestId("channel-general").click(); + const timelineRow = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ has: page.getByTestId("file-card") }) + .last(); + await expect(timelineRow).toBeVisible(); + return timelineRow; +} + +async function expectFilenameEllipsised(card: Locator) { + await expect + .poll(() => + card + .getByText(LONG_ATTACHMENT_FILENAME) + .evaluate((el) => el.scrollWidth > el.clientWidth), + ) + .toBe(true); +} + +test("a long filename stays inside the narrow thread pane row", async ({ + page, +}) => { + // Regression: with only the 24rem cap the card grew past a ~300px + // thread-pane row and clipped the download icon. The cap is + // min(24rem, 100%) so the card shrinks and the filename ellipsises instead. + const timelineRow = await seedLongNamedAttachment(page); + await timelineRow.hover(); + await timelineRow.getByRole("button", { name: "Reply" }).click(); + + // The thread head renders the same message in the default 380px pane. + const threadRow = page + .getByTestId("message-thread-panel") + .getByTestId("message-thread-head") + .getByTestId("message-row"); + const card = threadRow.getByTestId("file-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("13 KB"); + + const [rowBox, cardBox, iconBox] = await Promise.all([ + threadRow.boundingBox(), + card.boundingBox(), + card.locator("svg").last().boundingBox(), + ]); + if (!rowBox || !cardBox || !iconBox) { + throw new Error("Expected the thread row, card, and icon to lay out."); + } + // The pane is narrower than the card's 24rem cap, so the filename must be + // ellipsised — otherwise this test would not be exercising the overflow. + expect(rowBox.width).toBeLessThan(FILE_CARD_CAP_PX); + await expectFilenameEllipsised(card); + expect(cardBox.x + cardBox.width).toBeLessThanOrEqual( + rowBox.x + rowBox.width + 0.5, + ); + expect(iconBox.x + iconBox.width).toBeLessThanOrEqual( + rowBox.x + rowBox.width + 0.5, + ); +}); + +test("a long filename keeps the 24rem cap in a wide timeline row", async ({ + page, +}) => { + // Regression: `max-w-full` alone (without the 24rem cap) let a long + // filename stretch the card across the whole main-timeline row. The cap + // must still bind where the row is wider than 24rem. + const timelineRow = await seedLongNamedAttachment(page); + const card = timelineRow.getByTestId("file-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("13 KB"); + + const [rowBox, cardBox] = await Promise.all([ + timelineRow.boundingBox(), + card.boundingBox(), + ]); + if (!rowBox || !cardBox) { + throw new Error("Expected the timeline row and card to lay out."); + } + // The row must be comfortably wider than the cap, otherwise `100%` would + // bind first and this test would not be exercising the 24rem limit. + expect(rowBox.width).toBeGreaterThan(FILE_CARD_CAP_PX + 100); + expect(cardBox.width).toBeLessThanOrEqual(FILE_CARD_CAP_PX + 0.5); + await expectFilenameEllipsised(card); +});