Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions ui/src/components/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions ui/src/example_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions ui/tests/message-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> 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 <a> 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);
});
});
Loading