mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Fix mention chip alignment (#1094)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
resolveEffectiveTimestamp,
|
||||
} from "./readStateManager.ts";
|
||||
|
||||
const threadKey = "thread:" + "a".repeat(64);
|
||||
const threadKey = `thread:${"a".repeat(64)}`;
|
||||
const channelKey = "channel-1";
|
||||
const channelResolver = (ctx) =>
|
||||
ctx.startsWith("thread:") ? channelKey : null;
|
||||
|
||||
@@ -16,6 +16,8 @@ import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
|
||||
import {
|
||||
MENTION_CHIP_BASE_CLASSES,
|
||||
MENTION_CHIP_HOVER_CLASSES,
|
||||
MENTION_CHIP_PREFIX_CLASS,
|
||||
MESSAGE_MARKDOWN_CLASS,
|
||||
} from "@/shared/ui/mentionChip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
@@ -91,7 +93,9 @@ function ProfileName({
|
||||
: "rounded-xs transition-colors hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{highlight && !isAgentMention ? "@" : null}
|
||||
{highlight && !isAgentMention ? (
|
||||
<span className={MENTION_CHIP_PREFIX_CLASS}>@</span>
|
||||
) : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -343,7 +347,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
|
||||
profiles={profiles}
|
||||
targetPubkey={payload.target}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn(MESSAGE_MARKDOWN_CLASS, "min-w-0 flex-1")}>
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<div className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
|
||||
{description.title}
|
||||
|
||||
@@ -395,7 +395,6 @@ export function useTimelineScrollManager({
|
||||
[onTargetReached, unpinFromBottom],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
|
||||
@@ -896,6 +896,12 @@
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.message-markdown .mention-chip-prefix {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.12em);
|
||||
}
|
||||
|
||||
.message-markdown .inline-code-chip,
|
||||
.message-markdown :not(pre) > code {
|
||||
font-size: var(--inline-code-font-size);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
INLINE_CODE_CHIP_CLASS,
|
||||
MENTION_CHIP_BASE_CLASSES,
|
||||
MENTION_CHIP_HOVER_CLASSES,
|
||||
MENTION_CHIP_PREFIX_CLASS,
|
||||
MESSAGE_MARKDOWN_CLASS,
|
||||
} from "@/shared/ui/mentionChip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
@@ -1941,9 +1942,15 @@ function createMarkdownComponents(
|
||||
const isAgentMention =
|
||||
pubkey !== undefined &&
|
||||
agentMentionPubkeysByName?.[mentionName] === pubkey;
|
||||
const renderedMentionText = isAgentMention
|
||||
? mentionText.replace(/^@/, "")
|
||||
: children;
|
||||
const mentionLabel = mentionText.replace(/^@/, "");
|
||||
const renderedMentionText = isAgentMention ? (
|
||||
mentionLabel
|
||||
) : (
|
||||
<>
|
||||
<span className={MENTION_CHIP_PREFIX_CLASS}>@</span>
|
||||
{mentionLabel}
|
||||
</>
|
||||
);
|
||||
const mentionNode = (
|
||||
<span
|
||||
data-mention=""
|
||||
|
||||
@@ -2,6 +2,8 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip";
|
||||
|
||||
export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover";
|
||||
|
||||
export const MENTION_CHIP_PREFIX_CLASS = "mention-chip-prefix";
|
||||
|
||||
/** Wrapper on rendered message Markdown — scopes inline chip CSS. */
|
||||
export const MESSAGE_MARKDOWN_CLASS = "message-markdown";
|
||||
|
||||
|
||||
@@ -699,6 +699,49 @@ test("system add and remove rows use agent mention styling for managed agents",
|
||||
).toHaveText("portal");
|
||||
});
|
||||
|
||||
test("system member-joined rows render the joined person as a mention chip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
|
||||
|
||||
await page.evaluate(
|
||||
({ kind, pubkey }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: JSON.stringify({
|
||||
type: "member_joined",
|
||||
actor: pubkey,
|
||||
target: pubkey,
|
||||
}),
|
||||
kind,
|
||||
});
|
||||
},
|
||||
{ kind: SYSTEM_MESSAGE_KIND, pubkey: TEST_IDENTITIES.bob.pubkey },
|
||||
);
|
||||
await waitForTimelineSettled(page);
|
||||
|
||||
const joinedRow = page
|
||||
.getByTestId("system-message-row")
|
||||
.filter({ hasText: "bob" })
|
||||
.filter({ hasText: "joined the channel" });
|
||||
const joinedPersonChip = joinedRow.locator("[data-mention].mention-chip", {
|
||||
hasText: "bob",
|
||||
});
|
||||
|
||||
await expect(joinedPersonChip).toBeVisible();
|
||||
await expect(joinedPersonChip).toHaveCSS("display", /^(inline-)?flex$/);
|
||||
await expect(joinedPersonChip).not.toHaveCSS(
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(joinedPersonChip.locator(".mention-chip-prefix")).toHaveText(
|
||||
"@",
|
||||
);
|
||||
});
|
||||
|
||||
test("selecting a non-member agent from a DM inserts @Name into input", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -988,7 +1031,7 @@ test("Escape dismisses autocomplete dropdown", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("mention text is highlighted in sent messages", async ({ page }) => {
|
||||
const suffix = `check this out ${Date.now()}`;
|
||||
const suffix = ` check this out ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
@@ -1006,8 +1049,9 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
|
||||
const mentionChip = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator("[data-mention]", { hasText: "@bob" });
|
||||
.locator("[data-mention].mention-chip", { hasText: "bob" });
|
||||
await expect(mentionChip).toBeVisible();
|
||||
await expect(mentionChip.locator(".mention-chip-prefix")).toHaveText("@");
|
||||
await expect(mentionChip.locator("svg")).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/thread-unread";
|
||||
|
||||
type MockMessageEvent = {
|
||||
id: string;
|
||||
created_at: number;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
async function waitForMockLiveSubscription(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
@@ -26,7 +32,7 @@ async function waitForMockLiveSubscription(
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
function emitMockMessage(
|
||||
async function emitMockMessage(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
content: string,
|
||||
@@ -36,8 +42,8 @@ function emitMockMessage(
|
||||
createdAt?: number;
|
||||
mentionPubkeys?: string[];
|
||||
},
|
||||
) {
|
||||
return page.evaluate(
|
||||
): Promise<MockMessageEvent> {
|
||||
const event = await page.evaluate(
|
||||
({ ch, msg, parentEventId, pubkey, ts, mentionPubkeys }) => {
|
||||
return (
|
||||
window as Window & {
|
||||
@@ -68,6 +74,10 @@ function emitMockMessage(
|
||||
mentionPubkeys: options?.mentionPubkeys,
|
||||
},
|
||||
);
|
||||
if (!event) {
|
||||
throw new Error("Mock message emitter is not installed");
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
// Unread thread replies must be dated strictly after the read frontier captured
|
||||
@@ -231,7 +241,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
const base = unreadTimestamp();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await emitMockMessage(page, "general", `Bob chimes in ${i + 1}`, {
|
||||
parentEventId: rootEvent!.id,
|
||||
parentEventId: rootEvent.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: base + i,
|
||||
});
|
||||
@@ -278,19 +288,19 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
"general",
|
||||
"Replying one level down",
|
||||
{
|
||||
parentEventId: r1!.id,
|
||||
parentEventId: r1.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: past + 1,
|
||||
},
|
||||
);
|
||||
// A sibling at r1's level so the tree reads as a branching discussion.
|
||||
await emitMockMessage(page, "general", "Separate angle on the same point", {
|
||||
parentEventId: r1!.id,
|
||||
parentEventId: r1.id,
|
||||
pubkey: TEST_IDENTITIES.charlie.pubkey,
|
||||
createdAt: past + 2,
|
||||
});
|
||||
const r3 = await emitMockMessage(page, "general", "Going deeper still", {
|
||||
parentEventId: r2!.id,
|
||||
parentEventId: r2.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: past + 3,
|
||||
});
|
||||
@@ -302,8 +312,8 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
await expect(summary).toBeVisible();
|
||||
await summary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expandReply(page, r1!.id);
|
||||
await expandReply(page, r2!.id);
|
||||
await expandReply(page, r1.id);
|
||||
await expandReply(page, r2.id);
|
||||
await page.getByTestId("message-thread-close").click();
|
||||
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
|
||||
|
||||
@@ -314,12 +324,12 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
|
||||
const base = unreadTimestamp();
|
||||
const r4 = await emitMockMessage(page, "general", "New nested follow-up", {
|
||||
parentEventId: r3!.id,
|
||||
parentEventId: r3.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: base,
|
||||
});
|
||||
await emitMockMessage(page, "general", "Deepest unread reply", {
|
||||
parentEventId: r4!.id,
|
||||
parentEventId: r4.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: base + 1,
|
||||
});
|
||||
@@ -331,10 +341,10 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId("message-thread-summary").first().click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expandReply(page, r1!.id);
|
||||
await expandReply(page, r2!.id);
|
||||
await expandReply(page, r3!.id);
|
||||
await expandReply(page, r4!.id);
|
||||
await expandReply(page, r1.id);
|
||||
await expandReply(page, r2.id);
|
||||
await expandReply(page, r3.id);
|
||||
await expandReply(page, r4.id);
|
||||
|
||||
// Fully expanded: r1, r2, sibling, r3, r4, r5 — six rendered replies.
|
||||
const replies = page
|
||||
@@ -371,7 +381,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
createdAt: past,
|
||||
});
|
||||
const c = await emitMockMessage(page, "general", "Child of branch parent", {
|
||||
parentEventId: p!.id,
|
||||
parentEventId: p.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: past + 1,
|
||||
});
|
||||
@@ -402,13 +412,13 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
"general",
|
||||
"Unread under the branch",
|
||||
{
|
||||
parentEventId: c!.id,
|
||||
parentEventId: c.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: base,
|
||||
},
|
||||
);
|
||||
await emitMockMessage(page, "general", "Another unread under the branch", {
|
||||
parentEventId: c2!.id,
|
||||
parentEventId: c2.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: base + 1,
|
||||
});
|
||||
@@ -444,7 +454,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
|
||||
// Expanding p marks its whole subtree read; the descendant-inclusive gate
|
||||
// (Phase 2.5) drops the badge from p and every revealed row beneath it.
|
||||
await expandReply(page, p!.id);
|
||||
await expandReply(page, p.id);
|
||||
await expect(inPanelBadge).toHaveCount(0);
|
||||
|
||||
await page.screenshot({
|
||||
@@ -469,7 +479,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
createdAt: past,
|
||||
});
|
||||
const c = await emitMockMessage(page, "general", "Child of branch parent", {
|
||||
parentEventId: p!.id,
|
||||
parentEventId: p.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: past + 1,
|
||||
});
|
||||
@@ -486,7 +496,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
|
||||
const base = unreadTimestamp();
|
||||
await emitMockMessage(page, "general", "First unread under branch", {
|
||||
parentEventId: c!.id,
|
||||
parentEventId: c.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: base,
|
||||
});
|
||||
@@ -508,7 +518,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
// the badge must bump to 2 on the same tick — readStateVersion-driven
|
||||
// recompute is what makes this fire live rather than on a later re-render.
|
||||
await emitMockMessage(page, "general", "Second unread under branch", {
|
||||
parentEventId: c!.id,
|
||||
parentEventId: c.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: base + 1,
|
||||
});
|
||||
@@ -538,7 +548,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
createdAt: past,
|
||||
});
|
||||
const oldChild = await emitMockMessage(page, "general", "Old child", {
|
||||
parentEventId: branchOld!.id,
|
||||
parentEventId: branchOld.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: past + 1,
|
||||
});
|
||||
@@ -548,7 +558,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
createdAt: past + 2,
|
||||
});
|
||||
const newChild = await emitMockMessage(page, "general", "New child", {
|
||||
parentEventId: branchNew!.id,
|
||||
parentEventId: branchNew.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: past + 3,
|
||||
});
|
||||
@@ -570,12 +580,12 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
// sibling's badge survives until that branch is expanded too.
|
||||
const base = unreadTimestamp();
|
||||
await emitMockMessage(page, "general", "Unread in older branch", {
|
||||
parentEventId: oldChild!.id,
|
||||
parentEventId: oldChild.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: base,
|
||||
});
|
||||
await emitMockMessage(page, "general", "Unread in newer branch", {
|
||||
parentEventId: newChild!.id,
|
||||
parentEventId: newChild.id,
|
||||
pubkey: TEST_IDENTITIES.bob.pubkey,
|
||||
createdAt: base + 30,
|
||||
});
|
||||
@@ -599,7 +609,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
// `expandedSubtreeReplyIds` gate against the frozen open-time frontier.
|
||||
// The older sibling's badge SURVIVES — the design does not sweep across
|
||||
// branches off a live marker.
|
||||
await expandReply(page, branchNew!.id);
|
||||
await expandReply(page, branchNew.id);
|
||||
await expect(inPanelBadges).toHaveCount(1);
|
||||
|
||||
await page.screenshot({
|
||||
@@ -607,7 +617,7 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
});
|
||||
|
||||
// Expanding the older branch clears the last remaining badge.
|
||||
await expandReply(page, branchOld!.id);
|
||||
await expandReply(page, branchOld.id);
|
||||
await expect(inPanelBadges).toHaveCount(0);
|
||||
|
||||
await page.screenshot({
|
||||
|
||||
Reference in New Issue
Block a user