Skip to content
Open
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
62 changes: 62 additions & 0 deletions desktop/src/features/messages/lib/timelineItems.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
20 changes: 15 additions & 5 deletions desktop/src/features/messages/lib/timelineItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"
) {
Expand All @@ -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 };
Expand All @@ -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" &&
Expand All @@ -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[],
Expand Down
61 changes: 56 additions & 5 deletions desktop/src/features/messages/ui/SystemMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))),
Expand Down Expand Up @@ -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{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={payload.targets}
/>{" "}
from the channel
</>
),
};
case "topic_changed":
return {
title: actorName,
Expand Down Expand Up @@ -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)
: [];
Expand Down
68 changes: 68 additions & 0 deletions desktop/tests/e2e/mentions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down