diff --git a/ui/src/components/conversation.rs b/ui/src/components/conversation.rs index bdae7bb1..d54ffe34 100644 --- a/ui/src/components/conversation.rs +++ b/ui/src/components/conversation.rs @@ -1808,14 +1808,16 @@ fn MessageGroupComponent( // Message body, wrapped in a padding container so the // "(edited)" indicator can sit inline at the trailing // edge of the body text rather than as a separate - // flex-column row. `break-words` ensures long URLs and - // unbreakable tokens wrap instead of clipping (the - // bubble has `overflow-hidden` to keep the reply strip - // contained). + // flex-column row. `[overflow-wrap:anywhere]` ensures + // long URLs and unbreakable tokens wrap instead of + // forcing the bubble past `max-w-prose`. `anywhere` is + // stricter than `break-word`: it also lowers the + // element's min-content so flex/grid parents can shrink + // the bubble to fit. div { class: "px-3 py-2 min-w-0", div { - class: "prose prose-sm dark:prose-invert max-w-none break-words", + class: "prose prose-sm dark:prose-invert max-w-none [overflow-wrap:anywhere]", dangerous_inner_html: "{msg.content_html}" } if msg.edited { diff --git a/ui/src/example_data.rs b/ui/src/example_data.rs index 41fb5a01..e8552e98 100644 --- a/ui/src/example_data.rs +++ b/ui/src/example_data.rs @@ -355,6 +355,25 @@ fn add_example_messages( messages.messages.push(reply_msg); } + // Add a message containing an unbreakable long URL so the bubble width + // regression for #212 is exercised by Playwright. + { + let (long_url_author_id, long_url_signing_key) = authors[0]; + current_time_ms += 30_000; + let long_url_msg = AuthorizedMessageV1::new( + MessageV1 { + room_owner: *owner_id, + author: long_url_author_id, + time: get_time_from_millis(current_time_ms), + content: RoomMessageBody::public( + "https://example.com/longlongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpath".to_string(), + ), + }, + long_url_signing_key, + ); + messages.messages.push(long_url_msg); + } + // Add reactions to messages from OTHER members (not owner) // Rule: One reaction per user per message // In "Your Private Room" the owner IS self, so this shows self reacting to others diff --git a/ui/tests/message-layout.spec.ts b/ui/tests/message-layout.spec.ts index 716fa99d..8bf73cf9 100644 --- a/ui/tests/message-layout.spec.ts +++ b/ui/tests/message-layout.spec.ts @@ -298,3 +298,67 @@ test.describe("Reply strip keyboard accessibility (#210)", () => { .toBeGreaterThan(0); }); }); + +// #212: a message containing an unbreakable long string (e.g. a long URL) +// must wrap inside the bubble without overflowing. `overflow-wrap: break-word` +// (the `break-words` utility) inserts soft breaks when content would +// otherwise overflow, but does NOT lower min-content sizing — so an ancestor +// `min-w-0` flex parent still gets stretched by the long token. Switching to +// `overflow-wrap: anywhere` also lowers min-content, which is what lets the +// bubble actually shrink to fit. +test.describe("Long unbreakable content (#212)", () => { + test.use({ viewport: { width: 1024, height: 900 } }); + + test("bubble with long URL wraps and does not cause horizontal overflow", async ({ + page, + }) => { + await page.goto("/"); + await waitForApp(page); + await selectRoom(page, "Your Private Room"); + + const longTokenBubble = page + .locator(".max-w-prose") + .filter({ hasText: "longlongurlpath" }) + .first(); + await expect(longTokenBubble).toBeVisible({ timeout: 10_000 }); + await longTokenBubble.scrollIntoViewIfNeeded(); + + // Core assertion: the inner prose body must not overflow its own box — + // i.e. the long URL must actually wrap. If `overflow-wrap` fails to + // apply, the element's min-content exceeds the parent width and + // scrollWidth > clientWidth. + const proseOverflow = await longTokenBubble.evaluate((el) => { + const prose = el.querySelector(".max-w-none") as HTMLElement | null; + if (!prose) return { ok: false, reason: "no prose div" }; + const a = prose.querySelector("a") as HTMLElement | null; + const aRect = a?.getBoundingClientRect(); + const pRect = prose.getBoundingClientRect(); + return { + ok: true, + proseScroll: prose.scrollWidth, + proseClient: prose.clientWidth, + aWidth: aRect?.width ?? 0, + proseWidth: pRect.width, + }; + }); + expect(proseOverflow.ok).toBe(true); + // The prose content must not overflow its own container. + expect(proseOverflow.proseScroll).toBeLessThanOrEqual( + proseOverflow.proseClient + 1 + ); + // And the rendered must fit inside the prose box (i.e. the URL + // wrapped rather than forcing the link to be wider than its parent). + expect(proseOverflow.aWidth).toBeLessThanOrEqual( + proseOverflow.proseWidth + 1 + ); + + // Defense in depth: no horizontal overflow on the document. The + // original regression screenshot showed the long-URL bubble pushing + // the chat column wider than its sibling bubbles. + const docOverflow = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })); + expect(docOverflow.scroll).toBeLessThanOrEqual(docOverflow.client + 1); + }); +});