From c185793ae54bf5fc37303d6ca0f4f15051e19ba9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 10 Jul 2026 16:04:18 +0100 Subject: [PATCH 1/2] group channel membership events --- .../messages/lib/rowHeightEstimate.ts | 5 +- .../messages/lib/timelineItems.test.mjs | 104 +++++ .../features/messages/lib/timelineItems.ts | 88 ++++- .../features/messages/ui/SystemMessageRow.tsx | 368 ++++++++++++++++-- .../messages/ui/TimelineMessageList.tsx | 27 +- desktop/tests/e2e/mentions.spec.ts | 164 ++++++-- 6 files changed, 701 insertions(+), 55 deletions(-) diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index c979a8ad10..cfce8d94a1 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -157,6 +157,7 @@ export function estimateRowHeight( // Dividers are short, fixed-height rows; reserving their true height keeps the // estimate honest without a content scan. const DIVIDER_HEIGHT = 32; +const SYSTEM_GROUP_HEIGHT = 80; /** * `contain-intrinsic-size` for a `timeline-row-cv` wrapper. A credible per-row @@ -174,6 +175,8 @@ export function timelineRowReserveStyle( }) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING) : item.kind === "system" ? estimateRowHeight(item.entry.message) - : DIVIDER_HEIGHT; + : item.kind === "system-group" + ? SYSTEM_GROUP_HEIGHT + : DIVIDER_HEIGHT; return { containIntrinsicSize: `auto ${height}px` }; } diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index bd844e7032..9d6fe4c2a6 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -35,6 +35,19 @@ function entry(overrides) { return { message: message(overrides), summary: null }; } +function memberAddedEntry({ actor = "actor-a", createdAt, id, target }) { + return entry({ + id, + createdAt, + kind: KIND_SYSTEM_MESSAGE, + body: JSON.stringify({ type: "member_joined", actor, target }), + }); +} + +function memberJoinedEntry({ createdAt, id, target }) { + return memberAddedEntry({ actor: target, createdAt, id, target }); +} + function kinds(items) { return items.map((item) => item.kind); } @@ -90,6 +103,97 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => { assert.deepEqual(kinds(items), ["day-divider", "message", "system"]); }); +test("buildTimelineItems: member additions by one actor group within five minutes", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 60 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 300 }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["a", "b", "c"], + ); + assert.equal(group?.key, "a"); +}); + +test("buildTimelineItems: self-joins group across different members within five minutes", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }), + memberJoinedEntry({ + id: "b", + target: "target-b", + createdAt: start + 60, + }), + memberJoinedEntry({ + id: "c", + target: "target-c", + createdAt: start + 300, + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + const group = items.find((item) => item.kind === "system-group"); + assert.deepEqual( + group?.entries.map((groupEntry) => groupEntry.message.id), + ["a", "b", "c"], + ); +}); + +test("buildTimelineItems: member-add window is fixed from the first addition", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 301 }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system-group", "system"]); +}); + +test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), + memberAddedEntry({ + id: "b", + actor: "actor-b", + target: "target-b", + createdAt: start + 30, + }), + entry({ id: "message", createdAt: start + 60 }), + memberAddedEntry({ + id: "c", + actor: "actor-b", + target: "target-c", + createdAt: start + 90, + }), + memberAddedEntry({ + id: "self-join", + actor: "target-d", + target: "target-d", + createdAt: start + 120, + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), [ + "day-divider", + "system", + "system", + "message", + "system", + "system", + ]); +}); + test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => { const entries = [ entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }), diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 87b8a41d6b..e2f196b25c 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -20,7 +20,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; /** * One renderable row in the flattened timeline. Dividers carry no message and - * never appear in the index map; the three message-bearing kinds do. + * never appear in the index map; the message-bearing kinds do. */ export type TimelineItem = // `headingTimestamp` (not a prebaked label) so the render still resolves @@ -28,6 +28,11 @@ export type TimelineItem = | { kind: "day-divider"; key: string; headingTimestamp: number } | { kind: "unread-divider"; key: string } | { kind: "system"; key: string; entry: MainTimelineEntry } + | { + kind: "system-group"; + key: string; + entries: MainTimelineEntry[]; + } | { kind: "message"; key: string; @@ -57,6 +62,45 @@ function entryRenderKey(entry: MainTimelineEntry): string { return entry.message.renderKey ?? entry.message.id; } +const MEMBERSHIP_GROUP_WINDOW_SECONDS = 5 * 60; + +type MembershipChangePayload = { + actor: string | null; + mode: "added" | "joined"; + target: string; +}; + +function parseMembershipChangePayload( + entry: MainTimelineEntry, +): MembershipChangePayload | null { + if (entry.message.kind !== KIND_SYSTEM_MESSAGE) return null; + + try { + const payload = JSON.parse(entry.message.body) as { + type?: unknown; + actor?: unknown; + target?: unknown; + }; + if ( + payload.type !== "member_joined" || + typeof payload.actor !== "string" || + typeof payload.target !== "string" + ) { + return null; + } + + const actor = payload.actor.trim().toLowerCase(); + const target = payload.target.trim().toLowerCase(); + if (!actor || !target) return null; + + return actor === target + ? { actor: null, mode: "joined", target } + : { actor, mode: "added", target }; + } catch { + return null; + } +} + /** * Walks the (already top-level-filtered) entries once, emitting a day-divider * at each calendar-day boundary and an unread-divider above the first unread @@ -69,6 +113,7 @@ export function buildTimelineItems( const items: TimelineItem[] = []; let previousGroupEntry: MainTimelineEntry | null = null; let previousMessageItemIndex: number | null = null; + let previousMembershipItemIndex: number | null = null; // Index boundaries by their start position so the walk below can look up the // prepend-stable section key (start-of-local-day). Keying the divider by @@ -89,6 +134,7 @@ export function buildTimelineItems( if (dayBoundary) { previousGroupEntry = null; previousMessageItemIndex = null; + previousMembershipItemIndex = null; items.push({ kind: "day-divider", key: dayBoundary.key, @@ -99,6 +145,7 @@ export function buildTimelineItems( if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) { previousGroupEntry = null; previousMessageItemIndex = null; + previousMembershipItemIndex = null; items.push({ kind: "unread-divider", key: `unread-${renderKey}` }); } @@ -106,10 +153,49 @@ export function buildTimelineItems( if (kind === "system") { previousGroupEntry = null; previousMessageItemIndex = null; + + const membershipChange = parseMembershipChangePayload(entry); + const previousItem = + previousMembershipItemIndex === null + ? null + : items[previousMembershipItemIndex]; + const previousEntries = + previousItem?.kind === "system-group" + ? previousItem.entries + : previousItem?.kind === "system" + ? [previousItem.entry] + : []; + const firstPreviousEntry = previousEntries[0]; + const firstPreviousPayload = firstPreviousEntry + ? parseMembershipChangePayload(firstPreviousEntry) + : null; + + if ( + membershipChange && + firstPreviousEntry && + firstPreviousPayload?.mode === membershipChange.mode && + (membershipChange.mode === "joined" || + firstPreviousPayload.actor === membershipChange.actor) && + message.createdAt >= firstPreviousEntry.message.createdAt && + message.createdAt - firstPreviousEntry.message.createdAt <= + MEMBERSHIP_GROUP_WINDOW_SECONDS + ) { + const groupIndex = previousMembershipItemIndex as number; + items[groupIndex] = { + kind: "system-group", + key: entryRenderKey(firstPreviousEntry), + entries: [...previousEntries, entry], + }; + continue; + } + items.push({ kind, key: renderKey, entry }); + previousMembershipItemIndex = membershipChange ? items.length - 1 : null; continue; } + previousMembershipItemIndex = null; + const isContinuation = previousGroupEntry !== null && hasSameMessageAuthor(previousGroupEntry.message, message) && diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index ffdff4436e..1ad48ff805 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -2,7 +2,10 @@ import { SmilePlus } from "lucide-react"; import * as React from "react"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; -import type { TimelineMessage } from "@/features/messages/types"; +import type { + TimelineMessage, + TimelineReaction, +} from "@/features/messages/types"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis"; @@ -22,6 +25,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0"; @@ -31,6 +35,7 @@ type SystemMessagePayload = { type: string; actor?: string; target?: string; + targets?: string[]; topic?: string; purpose?: string; // Moderation tombstone fields (kind:40099 "message_deleted"). All optional and @@ -46,6 +51,104 @@ type SystemMessageDescription = { title: React.ReactNode; }; +const MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES = 3; + +function parseSystemMessagePayload( + message: TimelineMessage, +): SystemMessagePayload | null { + try { + return JSON.parse(message.body) as SystemMessagePayload; + } catch { + return null; + } +} + +function buildGroupedMembershipPayload( + messages: readonly TimelineMessage[], +): SystemMessagePayload | null { + if (messages.length < 2) return null; + + const payloads = messages.map(parseSystemMessagePayload); + const firstPayload = payloads[0]; + const actor = firstPayload?.actor + ? normalizePubkey(firstPayload.actor) + : null; + const firstTarget = firstPayload?.target + ? normalizePubkey(firstPayload.target) + : null; + if (!actor || !firstTarget) return null; + const isSelfJoinGroup = actor === firstTarget; + + const targets: string[] = []; + for (const payload of payloads) { + const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null; + const payloadTarget = payload?.target + ? normalizePubkey(payload.target) + : null; + if ( + payload?.type !== "member_joined" || + !payloadActor || + !payloadTarget || + (isSelfJoinGroup + ? payloadActor !== payloadTarget + : payloadActor !== actor || payloadActor === payloadTarget) + ) { + return null; + } + targets.push(payloadTarget); + } + + if (isSelfJoinGroup) { + return { + type: "members_joined", + target: targets[0], + targets, + }; + } + + return { + type: "members_added", + actor, + target: targets[0], + targets, + }; +} + +function aggregateGroupedReactions( + messages: readonly TimelineMessage[], +): TimelineReaction[] { + const reactionsByEmoji = new Map< + string, + TimelineReaction & { + usersByKey: Map; + } + >(); + + for (const message of messages) { + for (const reaction of message.reactions ?? []) { + const existing = reactionsByEmoji.get(reaction.emoji) ?? { + emoji: reaction.emoji, + emojiUrl: reaction.emojiUrl, + count: 0, + reactedByCurrentUser: false, + users: [], + usersByKey: new Map(), + }; + existing.reactedByCurrentUser ||= reaction.reactedByCurrentUser === true; + for (const user of reaction.users) { + const userKey = normalizePubkey(user.pubkey) || user.displayName; + existing.usersByKey.set(userKey, user); + } + reactionsByEmoji.set(reaction.emoji, existing); + } + } + + return [...reactionsByEmoji.values()].map(({ usersByKey, ...reaction }) => { + const users = [...usersByKey.values()]; + return { ...reaction, count: users.length, users }; + }); +} + function resolveLabel( pubkey: string | undefined, currentPubkey: string | undefined, @@ -96,11 +199,13 @@ function ProfileName({ highlight = false, isAgent = false, pubkey, + underlineOnHover = false, }: { children: React.ReactNode; highlight?: boolean; isAgent?: boolean; pubkey: string | undefined; + underlineOnHover?: boolean; }) { const isAgentMention = highlight && isAgent; const node = ( @@ -115,6 +220,7 @@ function ProfileName({ isAgentMention && "agent-mention-highlight", ) : "rounded-xs transition-colors hover:text-foreground", + underlineOnHover && "hover:underline", )} > {highlight && !isAgentMention ? ( @@ -252,6 +358,118 @@ function SystemMessageAvatar({ ); } +function MembershipPersonName({ + agentPubkeys, + currentPubkey, + personaLookup, + profiles, + pubkey, +}: { + agentPubkeys?: ReadonlySet; + currentPubkey: string | undefined; + personaLookup?: Map; + profiles: UserProfileLookup | undefined; + pubkey: string; +}) { + return ( + + {resolveDisplayLabel(pubkey, currentPubkey, profiles)} + + ); +} + +function MemberNamesInlineList({ + agentPubkeys, + currentPubkey, + personaLookup, + profiles, + targets, +}: { + agentPubkeys?: ReadonlySet; + currentPubkey: string | undefined; + personaLookup?: Map; + profiles: UserProfileLookup | undefined; + targets: string[]; +}) { + const visibleTargets = targets.slice(0, MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES); + const hiddenTargets = targets.slice(MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES); + const renderName = (pubkey: string) => ( + + ); + + return ( + <> + {visibleTargets.map((pubkey, index) => { + const isLast = index === visibleTargets.length - 1; + const separator = + index === 0 + ? null + : isLast && hiddenTargets.length === 0 + ? visibleTargets.length === 2 + ? " and " + : ", and " + : ", "; + return ( + + {separator} + {renderName(pubkey)} + + ); + })} + {hiddenTargets.length > 0 ? ( + <> + , and{" "} + + + + + +
+ {hiddenTargets.map((pubkey) => ( +
+ + + {resolveDisplayLabel(pubkey, currentPubkey, profiles)} + +
+ ))} +
+
+
+ + ) : null} + + ); +} + function describeSystemEvent( payload: SystemMessagePayload, currentPubkey: string | undefined, @@ -283,18 +501,73 @@ function describeSystemEvent( {targetLabel} ); + const membershipTitle = ( + + {targetLabel} + + ); switch (payload.type) { + case "members_added": + if (!payload.actor || !payload.targets?.length) return null; + return { + title: membershipTitle, + action: ( + <> + was added by{" "} + + {resolveDisplayLabel(payload.actor, currentPubkey, profiles)} + + , along with{" "} + + + ), + }; + case "members_joined": + if (!payload.targets?.length) return null; + return { + title: membershipTitle, + action: ( + <> + joined the channel along with{" "} + + + ), + }; case "member_joined": { - if (payload.actor === payload.target) { + if (!payload.actor || !payload.target) return null; + if (normalizePubkey(payload.actor) === normalizePubkey(payload.target)) { return { - title: targetName, + title: membershipTitle, action: "joined the channel", }; } return { - title: actorName, - action: <>added {targetName} to the channel, + title: membershipTitle, + action: ( + <> + was added by{" "} + + {resolveDisplayLabel(payload.actor, currentPubkey, profiles)} + + + ), }; } case "member_left": @@ -354,6 +627,7 @@ function describeSystemEvent( export const SystemMessageRow = React.memo(function SystemMessageRow({ message, + groupedMessages, currentPubkey, agentPubkeys, profiles, @@ -361,6 +635,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ onToggleReaction, }: { message: TimelineMessage; + groupedMessages?: TimelineMessage[]; currentPubkey?: string; agentPubkeys?: ReadonlySet; profiles?: UserProfileLookup; @@ -372,6 +647,45 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ remove: boolean, ) => Promise; }) { + const sourceMessages = React.useMemo( + () => groupedMessages ?? [message], + [groupedMessages, message], + ); + const groupedPayload = React.useMemo( + () => buildGroupedMembershipPayload(sourceMessages), + [sourceMessages], + ); + const reactionMessage = React.useMemo( + () => + groupedPayload + ? { + ...message, + pending: sourceMessages.some((source) => source.pending), + reactions: aggregateGroupedReactions(sourceMessages), + } + : message, + [groupedPayload, message, sourceMessages], + ); + const handleGroupedReaction = React.useCallback( + async (_groupMessage: TimelineMessage, emoji: string, remove: boolean) => { + if (!onToggleReaction) return; + if (!remove) { + await onToggleReaction(message, emoji, false); + return; + } + + const reactedMessages = sourceMessages.filter((source) => + source.reactions?.some( + (reaction) => + reaction.emoji === emoji && reaction.reactedByCurrentUser, + ), + ); + await Promise.all( + reactedMessages.map((source) => onToggleReaction(source, emoji, true)), + ); + }, + [message, onToggleReaction, sourceMessages], + ); const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); @@ -382,14 +696,15 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ pending: reactionPending, errorMessage: reactionErrorMessage, select: handleReactionSelect, - } = useReactionHandler(message, onToggleReaction); + } = useReactionHandler( + reactionMessage, + groupedPayload && onToggleReaction + ? handleGroupedReaction + : onToggleReaction, + ); - let payload: SystemMessagePayload; - try { - payload = JSON.parse(message.body); - } catch { - return null; - } + const payload = groupedPayload ?? parseSystemMessagePayload(message); + if (!payload) return null; const description = describeSystemEvent( payload, @@ -401,6 +716,10 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ if (!description) { return null; } + const isMembershipArrival = + payload.type === "member_joined" || + payload.type === "members_added" || + payload.type === "members_joined"; const wouldAddReaction = (emoji: string) => !reactions.some( @@ -409,34 +728,39 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ return (
-
-
-
+
+ + {description.title} -
+ -
-

+ +

{description.action}

); + case "system-group": + return ( + messageFooters?.[entry.message.id] ?? null, + )} + onToggleReaction={onToggleReaction} + profiles={profiles} + /> + ); case "message": return ( entries?.map((systemEntry) => systemEntry.message), + [entries], + ); + if (!firstEntry) return null; + return (
{ await installMockBridge(page, { @@ -928,16 +928,15 @@ test("system add and remove rows use agent mention styling for managed agents", const addedRow = page .getByTestId("system-message-row") - .filter({ hasText: "added portal to the channel" }); + .filter({ hasText: "portal" }) + .filter({ hasText: "was added by" }); const removedRow = page .getByTestId("system-message-row") .filter({ hasText: "removed portal from the channel" }); - await expect( - addedRow.locator("[data-mention].agent-mention-highlight", { - hasText: "portal", - }), - ).toHaveText("portal"); + const addedName = addedRow.getByText("portal", { exact: true }); + await expect(addedName).toBeVisible(); + await expect(addedName).not.toHaveAttribute("data-mention"); await expect( removedRow.locator("[data-mention].agent-mention-highlight", { hasText: "portal", @@ -945,6 +944,125 @@ test("system add and remove rows use agent mention styling for managed agents", ).toHaveText("portal"); }); +test("groups member additions and joins with hidden names in the standard tooltip", async ({ + page, +}) => { + const actor = { + pubkey: "10".repeat(32), + displayName: "Alice Chen", + }; + const targets = [ + { pubkey: "11".repeat(32), displayName: "Erica Chapman" }, + { pubkey: "12".repeat(32), displayName: "Peter Griffin" }, + { pubkey: "13".repeat(32), displayName: "Marcia Thomas" }, + { pubkey: "14".repeat(32), displayName: "Jordan Lee" }, + { pubkey: "15".repeat(32), displayName: "Olivia Park" }, + { pubkey: "16".repeat(32), displayName: "Sam Rivera" }, + ]; + await installMockBridge(page, { + searchProfiles: [actor, ...targets], + }); + 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( + ({ actorPubkey, addedTargets, kind }) => { + const createdAt = Math.floor(Date.now() / 1_000); + for (const [index, target] of addedTargets.entries()) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: JSON.stringify({ + type: "member_joined", + actor: actorPubkey, + target: target.pubkey, + }), + createdAt: createdAt + index, + kind, + }); + } + }, + { + actorPubkey: actor.pubkey, + addedTargets: targets, + kind: SYSTEM_MESSAGE_KIND, + }, + ); + await waitForTimelineSettled(page); + + const groupedRow = page + .getByTestId("system-message-row") + .filter({ hasText: "was added by Alice Chen" }); + for (const visibleName of [ + "Erica Chapman", + "Peter Griffin", + "Marcia Thomas", + "Jordan Lee", + ]) { + await expect(groupedRow).toContainText(visibleName); + } + await expect( + groupedRow.locator("p").filter({ hasText: "was added by" }), + ).toContainText( + "was added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", + ); + await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); + + const visibleName = groupedRow.getByText("Peter Griffin", { exact: true }); + await expect(visibleName).toHaveCSS("text-decoration-line", "none"); + await visibleName.hover(); + await expect(visibleName).toHaveCSS("text-decoration-line", "underline"); + + const othersTrigger = groupedRow.getByRole("button", { name: "2 others" }); + await expect(othersTrigger).toHaveCSS("text-decoration-line", "none"); + await othersTrigger.hover(); + await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline"); + + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toContainText("Olivia Park"); + await expect(tooltip).toContainText("Sam Rivera"); + + await page.evaluate( + ({ addedTargets, kind }) => { + const createdAt = Math.floor(Date.now() / 1_000) + 60; + for (const [index, target] of addedTargets.entries()) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: JSON.stringify({ + type: "member_joined", + actor: target.pubkey, + target: target.pubkey, + }), + createdAt: createdAt + index, + kind, + }); + } + }, + { addedTargets: targets, kind: SYSTEM_MESSAGE_KIND }, + ); + await waitForTimelineSettled(page); + + const joinedRow = page + .getByTestId("system-message-row") + .filter({ hasText: "joined the channel" }) + .filter({ hasText: "Erica Chapman" }); + await expect( + joinedRow.locator("p").filter({ hasText: "joined the channel" }), + ).toContainText( + "joined the channel along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", + ); + await expect(joinedRow.locator("[data-mention]")).toHaveCount(0); + + const joinedOthersTrigger = joinedRow.getByRole("button", { + name: "2 others", + }); + await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none"); + await joinedOthersTrigger.hover(); + await expect(page.getByRole("tooltip")).toContainText("Olivia Park"); + await expect(page.getByRole("tooltip")).toContainText("Sam Rivera"); +}); + test("system agent profile only exposes message action", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -973,15 +1091,12 @@ test("system agent profile only exposes message action", async ({ page }) => { const joinedRow = page .getByTestId("system-message-row") - .filter({ hasText: "added mira to the channel" }); - const agentChip = joinedRow.locator( - "[data-mention].agent-mention-highlight", - { - hasText: "mira", - }, - ); - await expect(agentChip).toHaveText("mira"); - await agentChip.hover(); + .filter({ hasText: "mira" }) + .filter({ hasText: "was added by" }); + const agentName = joinedRow.getByText("mira", { exact: true }); + await expect(agentName).toHaveText("mira"); + await expect(agentName).not.toHaveAttribute("data-mention"); + await agentName.hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', @@ -1072,7 +1187,7 @@ test("profile-only agent author popover only exposes message action", async ({ ); }); -test("system member-joined rows render the joined person as a mention chip", async ({ +test("system member-joined rows render the joined person as a plain profile name", async ({ page, }) => { await page.goto("/"); @@ -1100,19 +1215,10 @@ test("system member-joined rows render the joined person as a mention chip", asy .getByTestId("system-message-row") .filter({ hasText: "bob" }) .filter({ hasText: "joined the channel" }); - const joinedPersonChip = joinedRow.locator("[data-mention].mention-chip", { - hasText: "bob", - }); + const joinedPersonName = joinedRow.getByText("bob", { exact: true }); - 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( - "@", - ); + await expect(joinedPersonName).toBeVisible(); + await expect(joinedPersonName).not.toHaveAttribute("data-mention"); }); test("selecting a non-member agent from a DM inserts @Name into input", async ({ From 19fee87fc1b614368ba1dad08dc0036c39095fbb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 10 Jul 2026 16:24:33 +0100 Subject: [PATCH 2/2] isolate membership avatar test --- desktop/tests/e2e/mentions.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 76aa57cda0..084dd4d3b6 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1110,14 +1110,14 @@ test("system agent profile only exposes message action", async ({ page }) => { test("system agent avatar only exposes message action", 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.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND); await page.evaluate( ({ kind, targetPubkey }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "general", + channelName: "random", content: JSON.stringify({ type: "member_joined", actor: targetPubkey,