fix(reactions): wrap long popover names (#3834)

**Category:** fix
**User Impact:** Long custom emoji names now stay contained inside
reaction popovers and remain fully readable.

**Problem:** An unbroken custom emoji name could force a reaction
popover beyond its intended maximum width and overflow the message view.

**Solution:** Give the reaction popover a definite 288px width and allow
the complete emoji name to wrap within it without truncation or
ellipsis. Short names retain the same content and interaction behavior.

<details>
<summary>File changes</summary>

**desktop/src/features/messages/ui/MessageReactions.tsx**
Bounds the reaction popover width and allows long names to break across
lines while preserving the full shortcode.

**desktop/tests/e2e/reaction-names.spec.ts**
Covers fixed width, full text preservation, and wrapping for the maximum
supported colon-wrapped reaction name, with deterministic seeded Picsum
visual fixtures and explicit image-load waits.

</details>

## Reproduction Steps

1. Open a message with a custom emoji reaction whose name is 64
characters.
2. Hover or focus the reaction pill to open its details popover.
3. Confirm the popover remains 288px wide and the complete name wraps
within it without ellipsis.
4. Open a short-name reaction and confirm its popover remains readable
and unchanged in behavior.

## Screenshots

| Before | After |
| --- | --- |
| ![Maximum-length name
before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-before-picsum.png)
| ![Maximum-length name
after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-after-picsum.png)
|

**Short-name regression check**

![Short reaction
name](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/short-name-after-picsum.png)

## Verification

- `pnpm test` in `desktop`: 3,858 passed
- Focused reaction-name E2E with seeded Picsum captures: 2 passed
- Desktop checks and commit hooks passed

Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-03 14:09:46 -07:00
committed by GitHub
parent d4a4570b97
commit 7981597848
2 changed files with 128 additions and 8 deletions

View File

@@ -126,7 +126,10 @@ function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
<div className="max-w-[14rem] text-balance text-sm font-semibold leading-snug text-popover-foreground">
{userText} <span className="text-muted-foreground">reacted with</span>
</div>
<div className="mt-0.5 text-sm font-semibold leading-snug text-muted-foreground">
<div
className="mt-0.5 break-all text-sm font-semibold leading-snug text-muted-foreground"
data-testid="reaction-popover-name"
>
{displayName}
</div>
</div>
@@ -504,7 +507,7 @@ function ReactionPill({
align="start"
side="top"
sideOffset={6}
className="w-auto min-w-56 max-w-72 rounded-xl p-3"
className="w-72 rounded-xl p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}

View File

@@ -1,11 +1,23 @@
import { expect, test } from "@playwright/test";
import * as fs from "node:fs";
import * as path from "node:path";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
const REACTION_TARGET_EVENT_ID = "d".repeat(64);
const BOB_PUBKEY =
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260";
const MAX_REACTION_NAME =
"abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl";
const MAX_REACTION_AVATAR_URL =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%23e5484d"/%3E%3C/svg%3E';
const SHORT_REACTION_AVATAR_URL =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E';
const SCREENSHOT_DIR =
process.env.REACTION_POPOVER_SCREENSHOT_DIR ??
"test-results/reaction-popover-screenshots";
function reactionTargetRow(page: import("@playwright/test").Page) {
return page
@@ -14,8 +26,45 @@ function reactionTargetRow(page: import("@playwright/test").Page) {
.last();
}
async function waitForImage(
image: import("@playwright/test").Locator,
): Promise<void> {
await expect(image).toBeVisible();
await expect
.poll(() =>
image.evaluate(
(element) =>
element instanceof HTMLImageElement &&
element.complete &&
element.naturalWidth > 0,
),
)
.toBe(true);
}
async function capturePopover(
page: import("@playwright/test").Page,
popover: import("@playwright/test").Locator,
filename: string,
): Promise<void> {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await waitForAnimations(page);
await popover.screenshot({
animations: "disabled",
path: path.join(SCREENSHOT_DIR, filename),
});
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
await installMockBridge(page, {
searchProfiles: [
{
pubkey: BOB_PUBKEY,
displayName: "bob",
avatarUrl: SHORT_REACTION_AVATAR_URL,
},
],
});
});
test("reaction popover resolves a reactor with no authored message in the window", async ({
@@ -33,21 +82,89 @@ test("reaction popover resolves a reactor with no authored message in the window
);
await page.evaluate(
({ pubkey, targetId }) => {
({ pubkey, targetId, avatarUrl }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: "🎉",
extraTags: [["e", targetId]],
content: ":react:",
extraTags: [
["e", targetId],
["emoji", "react", avatarUrl],
],
kind: 7,
pubkey,
});
},
{ pubkey: BOB_PUBKEY, targetId: REACTION_TARGET_EVENT_ID },
{
avatarUrl: SHORT_REACTION_AVATAR_URL,
pubkey: BOB_PUBKEY,
targetId: REACTION_TARGET_EVENT_ID,
},
);
const row = reactionTargetRow(page);
const pill = row.getByRole("button", { name: "Toggle 🎉 reaction" });
const pill = row.getByRole("button", { name: "Toggle :react: reaction" });
await expect(pill).toBeVisible();
await pill.hover();
await expect(page.getByText("bob reacted with")).toBeVisible();
const popover = page
.locator("[data-radix-popper-content-wrapper]")
.filter({ hasText: "bob reacted with" });
const avatar = popover.locator("img");
await expect(avatar).toHaveAttribute("src", SHORT_REACTION_AVATAR_URL);
await waitForImage(avatar);
await capturePopover(page, popover, "short-name-after.png");
});
test("maximum-length reaction name wraps inside a fixed-width popover", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.waitForFunction(
() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
kind: 7,
}) === true,
);
const reaction = `:${MAX_REACTION_NAME}:`;
await page.evaluate(
({ content, targetId, avatarUrl }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content,
extraTags: [
["e", targetId],
["emoji", content.slice(1, -1), avatarUrl],
],
kind: 7,
});
},
{
avatarUrl: MAX_REACTION_AVATAR_URL,
content: reaction,
targetId: REACTION_TARGET_EVENT_ID,
},
);
const pill = reactionTargetRow(page).getByRole("button", {
name: `Toggle ${reaction} reaction`,
});
await expect(pill).toBeVisible();
await pill.focus();
const popover = page
.locator("[data-radix-popper-content-wrapper]")
.filter({ hasText: reaction });
await expect(popover).toBeVisible();
await expect(popover).toHaveCSS("width", "288px");
const reactionName = popover.getByTestId("reaction-popover-name");
await expect(reactionName).toHaveCSS("word-break", "break-all");
await expect(reactionName).toHaveText(reaction);
const avatar = popover.locator("img");
await expect(avatar).toHaveAttribute("src", MAX_REACTION_AVATAR_URL);
await waitForImage(avatar);
await capturePopover(page, popover, "max-length-after.png");
});