diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 2b87b5ebc3..3207b2d8ef 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -48,6 +48,15 @@ function memberJoinedEntry({ createdAt, id, target }) { return memberAddedEntry({ actor: target, createdAt, id, target }); } +function memberRemovedEntry({ actor = "actor-a", createdAt, id, target }) { + return entry({ + id, + createdAt, + kind: KIND_SYSTEM_MESSAGE, + body: JSON.stringify({ type: "member_removed", actor, target }), + }); +} + function memberLeftEntry({ createdAt, id, target }) { return entry({ id, @@ -130,6 +139,59 @@ test("buildTimelineItems: contiguous member additions by one actor group", () => assert.equal(group?.key, "c"); }); +test("buildTimelineItems: contiguous member removals by one actor group", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberRemovedEntry({ id: "a", target: "target-a", createdAt: start }), + memberRemovedEntry({ id: "b", target: "target-b", createdAt: start + 60 }), + memberRemovedEntry({ + id: "c", + target: "target-c", + createdAt: start + 3_600, + }), + ]; + + 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, "c"); +}); + +test("buildTimelineItems: removals by different actors do not group together", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberRemovedEntry({ id: "a", target: "target-a", createdAt: start }), + memberRemovedEntry({ + id: "b", + actor: "actor-b", + target: "target-b", + createdAt: start + 60, + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system", "system"]); +}); + +test("buildTimelineItems: removals do not group with additions", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberAddedEntry({ id: "added", target: "target-a", createdAt: start }), + memberRemovedEntry({ + id: "removed", + target: "target-b", + createdAt: start + 60, + }), + ]; + + const { items } = buildTimelineItems(entries, null); + assert.deepEqual(kinds(items), ["day-divider", "system", "system"]); +}); + test("buildTimelineItems: contiguous self-joins group across different members", () => { const start = dayAt(2026, 6, 14); const entries = [ diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index c283871025..4c4cddb0bf 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -65,6 +65,7 @@ function entryRenderKey(entry: MainTimelineEntry): string { type MembershipChangePayload = | { mode: "self-arrival"; target: string } | { actor: string; mode: "addition"; target: string } + | { actor: string; mode: "removal"; target: string } | { mode: "departure"; target: string }; function parseMembershipChangePayload( @@ -83,7 +84,7 @@ function parseMembershipChangePayload( return target ? { mode: "departure", target } : null; } if ( - payload.type !== "member_joined" || + (payload.type !== "member_joined" && payload.type !== "member_removed") || typeof payload.actor !== "string" || typeof payload.target !== "string" ) { @@ -93,6 +94,11 @@ function parseMembershipChangePayload( const actor = payload.actor.trim().toLowerCase(); const target = payload.target.trim().toLowerCase(); if (!actor || !target) return null; + if (payload.type === "member_removed") { + // A removal is only ever attributed to the administrator who performed + // it, so self-removal has no distinct rendering to preserve. + return { actor, mode: "removal", target }; + } return actor === target ? { mode: "self-arrival", target } : { actor, mode: "addition", target }; @@ -111,6 +117,9 @@ function membershipChangesCanGroup( (second.mode === "departure" && first.target === second.target) ); } + if (first.mode === "removal") { + return second.mode === "removal" && first.actor === second.actor; + } return ( first.mode === "addition" && second.mode === "addition" && @@ -125,10 +134,11 @@ function membershipChangesCanGroup( * its contents, but not its identity or the virtual list's existing key suffix. * * Compatible membership activities stay together while they are contiguous. - * Self-joins and additions from one administrator each form their own summary; - * a self-join immediately followed by that member leaving becomes a single - * lifecycle summary. Each adjacent event must fall within the one-hour activity - * window, so uninterrupted activity can extend beyond an hour overall. + * Self-joins, additions from one administrator, and removals by one + * administrator each form their own summary; a self-join immediately followed + * by that member leaving becomes a single lifecycle summary. Each adjacent + * event must fall within the one-hour activity window, so uninterrupted + * activity can extend beyond an hour overall. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 1da16e437f..0664045025 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -85,6 +85,9 @@ function buildGroupedMembershipPayload( const joinedThenLeft = buildJoinedThenLeftPayload(payloads); if (joinedThenLeft) return joinedThenLeft; + const removals = buildGroupedRemovalPayload(payloads); + if (removals) return removals; + const arrivals = payloads.map((payload) => { const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null; const payloadTarget = payload?.target @@ -131,6 +134,35 @@ function buildGroupedMembershipPayload( }; } +/** + * Collapses a run of `member_removed` events performed by one administrator + * into a single summary, mirroring `members_added`. Bulk removals are ordinary + * — cleaning up after an import, or offboarding a team — and without this each + * one takes its own timeline row. + */ +function buildGroupedRemovalPayload( + payloads: readonly (SystemMessagePayload | null)[], +): SystemMessagePayload | null { + const first = payloads[0]; + if (first?.type !== "member_removed" || !first.actor) return null; + + const actor = normalizePubkey(first.actor); + const targets: string[] = []; + for (const payload of payloads) { + if ( + payload?.type !== "member_removed" || + !payload.actor || + !payload.target || + normalizePubkey(payload.actor) !== actor + ) { + return null; + } + targets.push(payload.target); + } + + return { type: "members_removed", actor: first.actor, targets }; +} + function buildJoinedThenLeftPayload( payloads: readonly (SystemMessagePayload | null)[], ): SystemMessagePayload | null { @@ -314,11 +346,11 @@ function ProfileName({ function membershipActivityPubkeys(payload: SystemMessagePayload): string[] { const pubkeys = - payload.type === "members_added" || payload.type === "members_joined" + payload.type === "members_added" || + payload.type === "members_joined" || + payload.type === "members_removed" ? (payload.targets ?? []) - : payload.type === "member_removed" - ? [payload.target ?? payload.actor] - : [payload.target ?? payload.actor]; + : [payload.target ?? payload.actor]; return [ ...new Set(pubkeys.filter((pubkey): pubkey is string => Boolean(pubkey))), @@ -570,6 +602,24 @@ function describeSystemEvent( title: actorName, action: <>removed {targetName} from the channel, }; + case "members_removed": + if (!payload.actor || !payload.targets?.length) return null; + return { + title: actorName, + action: ( + <> + removed{" "} + {" "} + from the channel + + ), + }; case "topic_changed": return { title: actorName, @@ -716,7 +766,8 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ isMembershipArrival || payload.type === "member_joined_then_left" || payload.type === "member_left" || - payload.type === "member_removed"; + payload.type === "member_removed" || + payload.type === "members_removed"; const membershipPubkeys = isMembershipActivity ? membershipActivityPubkeys(payload) : []; diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ed00e8c355..7fee0da659 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1462,6 +1462,74 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await expect(avatarStack.locator("..")).toHaveCSS("align-items", "center"); }); +test("groups contiguous removal activity with hidden names in the standard tooltip", async ({ + page, +}) => { + const actor = { + pubkey: "20".repeat(32), + displayName: "Alice Chen", + }; + const targets = [ + { pubkey: "21".repeat(32), displayName: "Erica Chapman" }, + { pubkey: "22".repeat(32), displayName: "Peter Griffin" }, + { pubkey: "23".repeat(32), displayName: "Marcia Thomas" }, + { pubkey: "24".repeat(32), displayName: "Jordan Lee" }, + { pubkey: "25".repeat(32), displayName: "Olivia Park" }, + { pubkey: "26".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, kind, removedTargets }) => { + const createdAt = Math.floor(Date.now() / 1_000); + for (const [index, target] of removedTargets.entries()) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: JSON.stringify({ + type: "member_removed", + actor: actorPubkey, + target: target.pubkey, + }), + createdAt: createdAt + index, + kind, + }); + } + }, + { + actorPubkey: actor.pubkey, + kind: SYSTEM_MESSAGE_KIND, + removedTargets: targets, + }, + ); + await waitForTimelineSettled(page); + + const groupedRow = page + .getByTestId("system-message-row") + .filter({ hasText: "Alice Chen removed" }); + await expect(groupedRow).toHaveCount(1); + await expect( + groupedRow.locator("p").filter({ hasText: "Alice Chen removed" }), + ).toContainText( + "Alice Chen removed Erica Chapman, Peter Griffin, Marcia Thomas, and 3 others from the channel", + ); + + const avatarStack = groupedRow.getByTestId("system-message-avatar-stack"); + await expect(avatarStack).toHaveCount(1); + + const othersTrigger = groupedRow.getByRole("button", { name: "3 others" }); + await othersTrigger.hover(); + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toContainText("Jordan Lee"); + await expect(tooltip).toContainText("Olivia Park"); + await expect(tooltip).toContainText("Sam Rivera"); +}); + test("system agent profile exposes owned agent actions", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click();