Collapse contiguous join messages (#7262)

This commit is contained in:
Luke Tornquist
2026-09-03 19:26:21 -04:00
committed by GitHub
parent 75f101d8b4
commit 2ac0aa1dd1
8 changed files with 1470 additions and 208 deletions

View File

@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildGroupedMembershipPayload } from "./membershipGroupPayload.ts";
import { buildTimelineItems } from "./timelineItems.ts";
import { KIND_SYSTEM_MESSAGE } from "../../../shared/constants/kinds.ts";
const DAY_START = Math.floor(Date.UTC(2026, 5, 14, 12, 0, 0) / 1000);
function systemEntry(id, createdAt, body) {
return {
message: {
author: "System",
body: JSON.stringify(body),
createdAt,
depth: 0,
id,
kind: KIND_SYSTEM_MESSAGE,
pubkey: "aa".repeat(32),
reactions: [],
time: "12:00 PM",
},
summary: null,
};
}
// One symbol per membership mechanism the relay can emit, so the matrix covers
// self-arrival, addition (same and different actor), and departure — for two
// distinct targets, which is what makes same-target vs cross-target grouping
// observable.
const SYMBOLS = {
"self-join(a)": { type: "member_joined", actor: "aaa", target: "aaa" },
"self-join(b)": { type: "member_joined", actor: "bbb", target: "bbb" },
"add(a by x)": { type: "member_joined", actor: "xxx", target: "aaa" },
"add(b by x)": { type: "member_joined", actor: "xxx", target: "bbb" },
"add(b by y)": { type: "member_joined", actor: "yyy", target: "bbb" },
"leave(a)": { type: "member_left", actor: "aaa" },
"leave(b)": { type: "member_left", actor: "bbb" },
};
const SYMBOL_NAMES = Object.keys(SYMBOLS);
function sequencesOfLength(length) {
if (length === 0) return [[]];
return sequencesOfLength(length - 1).flatMap((prefix) =>
SYMBOL_NAMES.map((name) => [...prefix, name]),
);
}
function entriesFor(sequence) {
return sequence.map((name, index) =>
systemEntry(`e${index}`, DAY_START + index * 30, SYMBOLS[name]),
);
}
/**
* The seam this guards: `buildTimelineItems` owns *which* contiguous membership
* events collapse into one `system-group`, and `buildGroupedMembershipPayload`
* owns *how* that group is described. They are two statements of one rule.
*
* When the builder emits a group the payload builder returns null for,
* `SystemMessageRow` falls back to the group's oldest message and every other
* event in the group vanishes from the timeline — a member who left still
* renders as present. That failure is silent, so it needs a standing guard
* rather than one case-by-case regression per shape.
*/
test("every membership group buildTimelineItems emits is describable", () => {
const undescribable = [];
let groupsChecked = 0;
for (const length of [2, 3, 4]) {
for (const sequence of sequencesOfLength(length)) {
const { items } = buildTimelineItems(entriesFor(sequence), null);
for (const item of items) {
if (item.kind !== "system-group") continue;
groupsChecked += 1;
const payload = buildGroupedMembershipPayload(
item.entries.map((entry) => entry.message),
);
if (!payload) {
undescribable.push(
`${sequence.join(" → ")} [group: ${item.entries
.map((entry) => entry.message.id)
.join(",")}]`,
);
}
}
}
}
assert.ok(groupsChecked > 0, "matrix produced no groups to check");
assert.deepEqual(
undescribable.slice(0, 10),
[],
`${undescribable.length} grouped shape(s) render as the oldest event only`,
);
});
test("a lifecycle group needs every arrival to be a self-join by the departing member", () => {
const elrond = "11".repeat(32);
const viewer = "10".repeat(32);
const message = (id, body) => ({
author: "System",
body: JSON.stringify(body),
createdAt: 1,
depth: 0,
id,
kind: KIND_SYSTEM_MESSAGE,
reactions: [],
time: "12:00 PM",
});
const selfJoin = (id) =>
message(id, { type: "member_joined", actor: elrond, target: elrond });
const leave = (id) => message(id, { type: "member_left", actor: elrond });
// Accepted: one or more equivalent self-arrivals, then that member departing.
for (const arrivals of [1, 2, 3]) {
const messages = [
...Array.from({ length: arrivals }, (_, index) => selfJoin(`j${index}`)),
leave("l"),
];
assert.equal(
buildGroupedMembershipPayload(messages)?.type,
"member_joined_then_left",
`${arrivals} self-arrival(s) + departure should be one lifecycle summary`,
);
}
// Rejected: anything else. Each of these would misattribute a self-join the
// member never made, or fold in an unrelated member's activity.
const rejected = {
"addition, not a self-join": [
message("a", { type: "member_joined", actor: viewer, target: elrond }),
leave("l"),
],
"one arrival is an addition": [
selfJoin("j0"),
message("a", { type: "member_joined", actor: viewer, target: elrond }),
leave("l"),
],
"a different member departs": [
selfJoin("j0"),
message("l", { type: "member_left", actor: viewer }),
],
"departure is not last": [leave("l"), selfJoin("j0")],
"no departure at all": [selfJoin("j0"), selfJoin("j1")],
};
for (const [name, messages] of Object.entries(rejected)) {
assert.notEqual(
buildGroupedMembershipPayload(messages)?.type,
"member_joined_then_left",
`${name} must not render as a lifecycle summary`,
);
}
});

View File

@@ -0,0 +1,126 @@
/**
* Grouped-membership payload construction for system rows.
*
* `buildTimelineItems` decides *which* contiguous membership events collapse
* into one `system-group`; this module decides *how* that group is described.
* The two rules are one invariant, so they live where both the renderer and a
* lib-level test can import them — a group the builder emits but this module
* cannot describe silently degrades to "render only the oldest event", which
* drops the rest of the group from the timeline.
*
* Kept pure (no React, no DOM) so `membershipGroupPayload.test.mjs` and
* `timelineItems.test.mjs` can cross-check the two rules directly.
*/
import type { TimelineMessage } from "@/features/messages/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
/** Parsed body of a kind:40099 system message, plus the synthetic group kinds. */
export type SystemMessagePayload = {
type: string;
actor?: string;
arrivals?: Array<{ actor: string; target: string }>;
target?: string;
targets?: string[];
topic?: string;
purpose?: string;
// Moderation tombstone fields (kind:40099 "message_deleted"). All optional and
// moderator-authored — present when a moderator removed the message, absent for
// a plain member self-delete. Reporter identity/evidence never appears here.
public_reason?: string;
reason_code?: string;
action_id?: string;
};
/** Parses one system message body; returns null when the body is not JSON. */
export function parseSystemMessagePayload(
message: TimelineMessage,
): SystemMessagePayload | null {
try {
return JSON.parse(message.body) as SystemMessagePayload;
} catch {
return null;
}
}
/**
* Derives the synthetic payload describing a grouped membership row, or null
* when the group is not a shape this module can describe.
*
* Must stay total over everything `buildTimelineItems` groups —
* `membershipGroupPayload.test.mjs` asserts that with a matrix invariant.
*/
export function buildGroupedMembershipPayload(
messages: readonly TimelineMessage[],
): SystemMessagePayload | null {
if (messages.length < 2) return null;
const payloads = messages.map(parseSystemMessagePayload);
const joinedThenLeft = buildJoinedThenLeftPayload(payloads);
if (joinedThenLeft) return joinedThenLeft;
const arrivals = payloads.map((payload) => {
const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null;
const payloadTarget = payload?.target
? normalizePubkey(payload.target)
: null;
if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) {
return null;
}
return { actor: payloadActor, target: payloadTarget };
});
if (arrivals.some((arrival) => !arrival)) return null;
const membershipArrivals = arrivals as Array<{
actor: string;
target: string;
}>;
const targets = [...new Set(membershipArrivals.map(({ target }) => target))];
return {
arrivals: membershipArrivals,
type: "members_arrived",
target: targets[0],
targets,
};
}
/**
* One lifecycle summary for N>=1 equivalent self-arrivals of a member followed
* by that same member departing.
*
* Mirrors `membershipChangesCanGroup`: a departure only groups with self-arrivals
* of the departing member, and the builder absorbs *every* contiguous one, not
* just the nearest. Duplicate `member_joined` events are a real relay artifact —
* `handle_put_user` re-emits one on each PUT_USER — so pinning this to a 2-event
* pair dropped the departure from the timeline entirely.
*/
function buildJoinedThenLeftPayload(
payloads: readonly (SystemMessagePayload | null)[],
): SystemMessagePayload | null {
if (payloads.length < 2) return null;
const departure = payloads[payloads.length - 1];
const departureActor = departure?.actor
? normalizePubkey(departure.actor)
: null;
if (departure?.type !== "member_left" || !departureActor) return null;
const arrivals = payloads.slice(0, -1);
const arrivalTarget = arrivals[0]?.target;
if (!arrivalTarget) return null;
const everyArrivalIsSelfJoinByDeparture = arrivals.every((arrival) => {
const actor = arrival?.actor ? normalizePubkey(arrival.actor) : null;
const target = arrival?.target ? normalizePubkey(arrival.target) : null;
return (
arrival?.type === "member_joined" &&
actor !== null &&
target !== null &&
actor === target &&
target === departureActor
);
});
if (!everyArrivalIsSelfJoinByDeparture) return null;
return { type: "member_joined_then_left", target: arrivalTarget };
}

View File

@@ -23,6 +23,14 @@ export function addedByActionPrefix(isCurrentUser: boolean): string {
return isCurrentUser ? "were added by" : "added by";
}
/**
* Generic add caption when the timeline should state that someone was added
* without attributing the action to a single actor.
*/
export function addedActionPrefix(isCurrentUser: boolean): string {
return isCurrentUser ? "were added" : "was added";
}
/**
* Caption for a channel topic or purpose change.
*

View File

@@ -155,6 +155,71 @@ test("buildTimelineItems: contiguous self-joins group across different members",
);
});
test("buildTimelineItems: mixed arrival actors collapse into one cohort", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({
actor: "viewer",
createdAt: start,
id: "elrond",
target: "elrond",
}),
memberAddedEntry({
actor: "elrond",
createdAt: start + 60,
id: "legolas",
target: "legolas",
}),
memberAddedEntry({
actor: "elrond",
createdAt: start + 120,
id: "gimli",
target: "gimli",
}),
memberAddedEntry({
actor: "viewer",
createdAt: start + 180,
id: "gandalf",
target: "gandalf",
}),
];
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),
["elrond", "legolas", "gimli", "gandalf"],
);
assert.equal(group?.key, "gandalf");
});
test("buildTimelineItems: self-joins and additions share one arrival cohort", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({
actor: "target-a",
id: "b",
target: "target-b",
createdAt: start + 60,
}),
memberJoinedEntry({
id: "c",
target: "target-c",
createdAt: start + 120,
}),
];
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: prepending membership history preserves the loaded suffix", () => {
const start = dayAt(2026, 6, 14);
const loaded = [
@@ -194,28 +259,44 @@ test("buildTimelineItems: contiguous member additions extend a group outside one
);
});
test("buildTimelineItems: incompatible arrivals remain separate", () => {
test("buildTimelineItems: arrival cohorts stop at unread dividers", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({
id: "b",
actor: "actor-b",
id: "b",
target: "target-b",
createdAt: start + 30,
}),
entry({ id: "message", createdAt: start + 60 }),
memberAddedEntry({
memberJoinedEntry({
id: "c",
actor: "actor-b",
target: "target-c",
createdAt: start + 90,
createdAt: start + 60,
}),
];
const { items } = buildTimelineItems(entries, "b");
assert.deepEqual(kinds(items), [
"day-divider",
"system",
"unread-divider",
"system-group",
]);
});
test("buildTimelineItems: arrival cohorts stop at day boundaries", () => {
const entries = [
memberAddedEntry({
id: "self-join",
actor: "target-d",
target: "target-d",
createdAt: start + 120,
actor: "viewer",
id: "a",
target: "target-a",
createdAt: dayAt(2026, 6, 14, 23, 59),
}),
memberJoinedEntry({
id: "b",
target: "target-b",
createdAt: dayAt(2026, 6, 15, 0, 0),
}),
];
@@ -223,13 +304,49 @@ test("buildTimelineItems: incompatible arrivals remain separate", () => {
assert.deepEqual(kinds(items), [
"day-divider",
"system",
"system",
"message",
"system",
"day-divider",
"system",
]);
});
test("buildTimelineItems: arrival cohorts stop at non-membership rows", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
entry({ id: "message", createdAt: start + 60 }),
memberAddedEntry({
actor: "actor-b",
id: "b",
target: "target-c",
createdAt: start + 90,
}),
memberJoinedEntry({ id: "c", target: "target-d", createdAt: start + 120 }),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), [
"day-divider",
"system",
"message",
"system-group",
]);
});
test("buildTimelineItems: arrival cohorts stop at >1 hour adjacent gaps", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberJoinedEntry({
id: "b",
target: "target-b",
createdAt: start + 3_601,
}),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system", "system"]);
});
test("buildTimelineItems: a member joining then leaving is one lifecycle group", () => {
const start = dayAt(2026, 6, 14);
const entries = [
@@ -246,6 +363,30 @@ test("buildTimelineItems: a member joining then leaving is one lifecycle group",
);
});
test("buildTimelineItems: duplicate self-joins then leaving stay one group keyed by the departure", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberJoinedEntry({ id: "joined-1", target: "member-a", createdAt: start }),
memberJoinedEntry({
id: "joined-2",
target: "member-a",
createdAt: start + 30,
}),
memberLeftEntry({ id: "left", target: "member-a", createdAt: start + 90 }),
];
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),
["joined-1", "joined-2", "left"],
);
// Anchored on the newest entry so prepending older history cannot repartition
// the loaded rows; splitting this into separate rows would change both.
assert.equal(group?.key, "left");
});
test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => {
const entries = [
entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }),

View File

@@ -106,17 +106,10 @@ function membershipChangesCanGroup(
first: MembershipChangePayload,
second: MembershipChangePayload,
): boolean {
if (first.mode === "self-arrival") {
return (
second.mode === "self-arrival" ||
(second.mode === "departure" && first.target === second.target)
);
if (second.mode === "departure") {
return first.mode === "self-arrival" && first.target === second.target;
}
return (
first.mode === "addition" &&
second.mode === "addition" &&
first.actor === second.actor
);
return first.mode !== "departure";
}
/**
@@ -126,10 +119,14 @@ 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.
* Arrival cohorts are actor-neutral even when self-joins and additions mix, but
* one or more equivalent self-joins followed by that member leaving remain a
* single lifecycle summary — every contiguous self-arrival of the departing
* member is absorbed, since the relay re-emits `member_joined` on each
* PUT_USER. `buildGroupedMembershipPayload` must describe every group this
* emits; `membershipGroupPayload.test.mjs` pins that with a matrix invariant.
* 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[],

View File

@@ -0,0 +1,567 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
Element: dom.window.Element,
getComputedStyle: dom.window.getComputedStyle,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
MutationObserver: dom.window.MutationObserver,
Node: dom.window.Node,
SVGElement: dom.window.SVGElement,
window: dom.window,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
});
after(() => dom.window.close());
function systemMessage({ actor, createdAt = 1, id, reactions = [], target }) {
return {
author: "System",
body: JSON.stringify({ type: "member_joined", actor, target }),
createdAt,
depth: 0,
id,
kind: 40099,
reactions,
time: "12:00 PM",
};
}
function memberLeftMessage({ actor, createdAt = 1, id, reactions = [] }) {
return {
author: "System",
body: JSON.stringify({ type: "member_left", actor }),
createdAt,
depth: 0,
id,
kind: 40099,
reactions,
time: "12:00 PM",
};
}
function reaction(emoji, { reactedByCurrentUser = false } = {}) {
return {
emoji,
count: 1,
reactedByCurrentUser,
users: [{ avatarUrl: null, displayName: "Zed", pubkey: "99".repeat(32) }],
};
}
function profileFor(pubkey, displayName) {
return {
[pubkey]: {
avatarUrl: null,
displayName,
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
};
}
function normalizeText(text) {
return text.replace(/\s+/g, " ").trim();
}
async function renderSystemMessageRow({
currentPubkey = "viewer",
groupedMessages,
message = groupedMessages[0],
onToggleReaction,
profiles,
}) {
const { createElement } = await import("react");
const { render } = await import("@testing-library/react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { TooltipProvider } = await import("@/shared/ui/tooltip");
const { SystemMessageRow } = await import("./SystemMessageRow.tsx");
const queryClient = new QueryClient();
render(
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(
TooltipProvider,
null,
createElement(SystemMessageRow, {
currentPubkey,
groupedMessages,
message,
onToggleReaction,
profiles,
}),
),
),
);
}
test("grouped duplicate arrival targets render one unique added member", async () => {
const { screen } = await import("@testing-library/react");
const target = "11".repeat(32);
const firstActor = "12".repeat(32);
const secondActor = "13".repeat(32);
const groupedMessages = [
systemMessage({ actor: firstActor, createdAt: 1, id: "a", target }),
systemMessage({ actor: secondActor, createdAt: 2, id: "b", target }),
];
await renderSystemMessageRow({
groupedMessages,
profiles: {
[target]: {
avatarUrl: null,
displayName: "Elrond",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(normalizeText(row.textContent ?? ""), "Elrond was added");
assert.equal(
screen
.getByTestId("system-message-avatar-stack")
.getAttribute("aria-label"),
"1 channel member",
);
});
test("grouped duplicate arrival targets keep viewer grammar truthful", async () => {
const { screen } = await import("@testing-library/react");
const viewer = "10".repeat(32);
const firstActor = "11".repeat(32);
const secondActor = "12".repeat(32);
const groupedMessages = [
systemMessage({
actor: firstActor,
createdAt: 1,
id: "a",
target: viewer,
}),
systemMessage({
actor: secondActor,
createdAt: 2,
id: "b",
target: viewer,
}),
];
await renderSystemMessageRow({
currentPubkey: viewer,
groupedMessages,
profiles: {
[viewer]: {
avatarUrl: null,
displayName: "Viewer",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(normalizeText(row.textContent ?? ""), "You were added");
});
test("grouped mixed additions render mechanism-truthful copy", async () => {
const { screen } = await import("@testing-library/react");
const viewer = "10".repeat(32);
const elrond = "11".repeat(32);
const legolas = "12".repeat(32);
const gimli = "13".repeat(32);
const gandalf = "14".repeat(32);
const groupedMessages = [
systemMessage({ actor: viewer, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: elrond, createdAt: 2, id: "b", target: legolas }),
systemMessage({ actor: elrond, createdAt: 3, id: "c", target: gimli }),
systemMessage({ actor: viewer, createdAt: 4, id: "d", target: gandalf }),
];
await renderSystemMessageRow({
currentPubkey: viewer,
groupedMessages,
profiles: {
[elrond]: {
avatarUrl: null,
displayName: "Elrond",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
[legolas]: {
avatarUrl: null,
displayName: "Legolas",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
[gimli]: {
avatarUrl: null,
displayName: "Gimli",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
[gandalf]: {
avatarUrl: null,
displayName: "Gandalf",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(
normalizeText(row.textContent ?? ""),
"Elrond was added along with Legolas, Gimli, and Gandalf",
);
});
test("grouped self-joins render joined copy", async () => {
const { screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const legolas = "12".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: legolas, createdAt: 2, id: "b", target: legolas }),
];
await renderSystemMessageRow({
groupedMessages,
profiles: {
[elrond]: {
avatarUrl: null,
displayName: "Elrond",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
[legolas]: {
avatarUrl: null,
displayName: "Legolas",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(
normalizeText(row.textContent ?? ""),
"Elrond joined along with Legolas",
);
});
test("grouped duplicate self-joins render singular joined copy", async () => {
const { screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }),
];
await renderSystemMessageRow({
groupedMessages,
profiles: {
[elrond]: {
avatarUrl: null,
displayName: "Elrond",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(normalizeText(row.textContent ?? ""), "Elrond joined");
});
test("grouped self-joins plus additions render neutral arrival copy", async () => {
const { screen } = await import("@testing-library/react");
const viewer = "10".repeat(32);
const elrond = "11".repeat(32);
const legolas = "12".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: viewer, createdAt: 2, id: "b", target: legolas }),
];
await renderSystemMessageRow({
currentPubkey: viewer,
groupedMessages,
profiles: {
[elrond]: {
avatarUrl: null,
displayName: "Elrond",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
[legolas]: {
avatarUrl: null,
displayName: "Legolas",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(
normalizeText(row.textContent ?? ""),
"Elrond arrived along with Legolas",
);
});
test("grouped duplicate mixed-mechanism arrivals render singular neutral copy", async () => {
const { screen } = await import("@testing-library/react");
const viewer = "10".repeat(32);
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: viewer, createdAt: 2, id: "b", target: elrond }),
];
await renderSystemMessageRow({
currentPubkey: viewer,
groupedMessages,
profiles: {
[elrond]: {
avatarUrl: null,
displayName: "Elrond",
isAgent: false,
name: null,
nip05Handle: null,
ownerPubkey: null,
},
},
});
const row = screen.getByTestId("system-message-row");
assert.equal(normalizeText(row.textContent ?? ""), "Elrond arrived");
});
// --- joined-then-left lifecycle groups ---------------------------------------
//
// `buildTimelineItems` groups every contiguous equivalent self-arrival with the
// matching departure, so the renderer must describe N>=1 arrivals + 1 departure.
// When it cannot, `SystemMessageRow` falls back to the group's *oldest* message
// and the departure never renders — the timeline claims a departed member is
// still present.
test("a duplicate self-join followed by leaving renders one lifecycle summary", async () => {
const { screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }),
memberLeftMessage({ actor: elrond, createdAt: 3, id: "c" }),
];
await renderSystemMessageRow({
groupedMessages,
profiles: profileFor(elrond, "Elrond"),
});
const row = screen.getByTestId("system-message-row");
assert.equal(
normalizeText(row.textContent ?? ""),
"Elrond joined, then left the channel",
);
});
test("more than two duplicate self-joins before leaving still render one lifecycle summary", async () => {
const { screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }),
systemMessage({ actor: elrond, createdAt: 3, id: "c", target: elrond }),
memberLeftMessage({ actor: elrond, createdAt: 4, id: "d" }),
];
await renderSystemMessageRow({
groupedMessages,
profiles: profileFor(elrond, "Elrond"),
});
const row = screen.getByTestId("system-message-row");
assert.equal(
normalizeText(row.textContent ?? ""),
"Elrond joined, then left the channel",
);
});
test("a single self-join followed by leaving keeps the two-event lifecycle copy", async () => {
const { screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({ actor: elrond, createdAt: 1, id: "a", target: elrond }),
memberLeftMessage({ actor: elrond, createdAt: 2, id: "b" }),
];
await renderSystemMessageRow({
groupedMessages,
profiles: profileFor(elrond, "Elrond"),
});
const row = screen.getByTestId("system-message-row");
assert.equal(
normalizeText(row.textContent ?? ""),
"Elrond joined, then left the channel",
);
});
test("an addition before the departing member's self-join is not a lifecycle summary", async () => {
const { screen } = await import("@testing-library/react");
const viewer = "10".repeat(32);
const elrond = "11".repeat(32);
// `buildTimelineItems` never groups this shape — an addition cannot precede a
// departure in one group. Hand-built here so the arrivals guard stays
// falsifiable: dropping it would let "was added" collapse into "joined, then
// left", attributing a self-join the member never made.
const groupedMessages = [
systemMessage({ actor: viewer, createdAt: 1, id: "a", target: elrond }),
systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }),
memberLeftMessage({ actor: elrond, createdAt: 3, id: "c" }),
];
await renderSystemMessageRow({
currentPubkey: viewer,
groupedMessages,
profiles: profileFor(elrond, "Elrond"),
});
const row = screen.getByTestId("system-message-row");
assert.notEqual(
normalizeText(row.textContent ?? ""),
"Elrond joined, then left the channel",
);
});
test("a duplicate self-join lifecycle group surfaces reactions from every source event", async () => {
const { screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({
actor: elrond,
createdAt: 1,
id: "a",
reactions: [reaction("👍")],
target: elrond,
}),
systemMessage({
actor: elrond,
createdAt: 2,
id: "b",
reactions: [reaction("🚀")],
target: elrond,
}),
memberLeftMessage({
actor: elrond,
createdAt: 3,
id: "c",
reactions: [reaction("🎉")],
}),
];
await renderSystemMessageRow({
groupedMessages,
onToggleReaction: async () => {},
profiles: profileFor(elrond, "Elrond"),
});
for (const emoji of ["👍", "🚀", "🎉"]) {
assert.ok(
screen.queryByText(emoji),
`expected the grouped row to surface ${emoji}`,
);
}
});
test("removing a reaction from a duplicate self-join lifecycle group targets every reacted source", async () => {
const { act, fireEvent, screen } = await import("@testing-library/react");
const elrond = "11".repeat(32);
const groupedMessages = [
systemMessage({
actor: elrond,
createdAt: 1,
id: "a",
reactions: [reaction("👍", { reactedByCurrentUser: true })],
target: elrond,
}),
systemMessage({ actor: elrond, createdAt: 2, id: "b", target: elrond }),
memberLeftMessage({
actor: elrond,
createdAt: 3,
id: "c",
reactions: [reaction("👍", { reactedByCurrentUser: true })],
}),
];
const removals = [];
await renderSystemMessageRow({
groupedMessages,
onToggleReaction: async (source, emoji, remove) => {
if (remove) removals.push([source.id, emoji]);
},
profiles: profileFor(elrond, "Elrond"),
});
// `act` (not a bare microtask drain) so the removal's optimistic state update
// and its awaited fan-out across every reacted source both settle before the
// assertion — a bare `fireEvent` leaves that update outside act.
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Toggle 👍 reaction" }));
});
assert.deepEqual(removals.sort(), [
["a", "👍"],
["c", "👍"],
]);
});

View File

@@ -26,6 +26,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
buildGroupedMembershipPayload,
parseSystemMessagePayload,
type SystemMessagePayload,
} from "../lib/membershipGroupPayload";
import {
addedActionPrefix,
addedByActionPrefix,
describeChannelTextFieldChange,
toInlineName,
@@ -45,21 +51,6 @@ import {
const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0";
const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4";
type SystemMessagePayload = {
type: string;
actor?: string;
target?: string;
targets?: string[];
topic?: string;
purpose?: string;
// Moderation tombstone fields (kind:40099 "message_deleted"). All optional and
// moderator-authored — present when a moderator removed the message, absent for
// a plain member self-delete. Reporter identity/evidence never appears here.
public_reason?: string;
reason_code?: string;
action_id?: string;
};
type SystemMessageDescription = {
action: React.ReactNode;
title: React.ReactNode;
@@ -67,97 +58,6 @@ type SystemMessageDescription = {
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 joinedThenLeft = buildJoinedThenLeftPayload(payloads);
if (joinedThenLeft) return joinedThenLeft;
const arrivals = payloads.map((payload) => {
const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null;
const payloadTarget = payload?.target
? normalizePubkey(payload.target)
: null;
if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) {
return null;
}
return { actor: payloadActor, target: payloadTarget };
});
if (arrivals.some((arrival) => !arrival)) return null;
const membershipArrivals = arrivals as {
actor: string;
target: string;
}[];
const targets = membershipArrivals.map(({ target }) => target);
const isSelfJoinGroup = membershipArrivals.every(
({ actor, target }) => actor === target,
);
if (isSelfJoinGroup) {
return {
type: "members_joined",
target: targets[0],
targets,
};
}
const actor = membershipArrivals[0].actor;
const isSameAdderGroup = membershipArrivals.every(
({ actor: candidateActor, target }) =>
candidateActor === actor && candidateActor !== target,
);
if (!isSameAdderGroup) {
return null;
}
return {
type: "members_added",
actor,
target: targets[0],
targets,
};
}
function buildJoinedThenLeftPayload(
payloads: readonly (SystemMessagePayload | null)[],
): SystemMessagePayload | null {
if (payloads.length !== 2) return null;
const [arrival, departure] = payloads;
const arrivalTarget = arrival?.target
? normalizePubkey(arrival.target)
: null;
const departureActor = departure?.actor
? normalizePubkey(departure.actor)
: null;
if (
arrival?.type !== "member_joined" ||
departure?.type !== "member_left" ||
!arrival.actor ||
!arrivalTarget ||
normalizePubkey(arrival.actor) !== arrivalTarget ||
arrivalTarget !== departureActor
) {
return null;
}
return { type: "member_joined_then_left", target: arrival.target };
}
function aggregateGroupedReactions(
messages: readonly TimelineMessage[],
): TimelineReaction[] {
@@ -319,7 +219,7 @@ function ProfileName({
function membershipActivityPubkeys(payload: SystemMessagePayload): string[] {
const pubkeys =
payload.type === "members_added" || payload.type === "members_joined"
payload.type === "members_arrived"
? (payload.targets ?? [])
: payload.type === "member_removed"
? [payload.target ?? payload.actor]
@@ -458,6 +358,148 @@ function MemberNamesInlineList({
);
}
function describeGroupedArrivals({
agentPubkeys,
currentPubkey,
isTargetCurrentUser,
membershipTitle,
payload,
personaLookup,
profiles,
}: {
agentPubkeys?: ReadonlySet<string>;
currentPubkey: string | undefined;
isTargetCurrentUser: boolean;
membershipTitle: React.ReactNode;
payload: SystemMessagePayload;
personaLookup?: Map<string, string>;
profiles: UserProfileLookup | undefined;
}): SystemMessageDescription | null {
const arrivals = payload.arrivals;
if (!arrivals?.length || !payload.targets?.length) return null;
const sharedActor = arrivals[0].actor;
const hasSelfJoins = arrivals.some(({ actor, target }) => actor === target);
const hasAdditions = arrivals.some(({ actor, target }) => actor !== target);
const isSameAdderGroup =
hasAdditions &&
arrivals.every(
({ actor, target }) => actor === sharedActor && actor !== target,
);
const isAllAdditionGroup = hasAdditions && !hasSelfJoins;
const isAllSelfJoinGroup = hasSelfJoins && !hasAdditions;
const additionalTargets = payload.targets.slice(1);
if (payload.targets.length === 1) {
if (isSameAdderGroup) {
return {
title: membershipTitle,
action: (
<>
{addedByActionPrefix(isTargetCurrentUser)}{" "}
<ProfileName pubkey={sharedActor} underlineOnHover>
{resolveInlineDisplayLabel(sharedActor, currentPubkey, profiles)}
</ProfileName>
</>
),
};
}
if (isAllAdditionGroup) {
return {
title: membershipTitle,
action: addedActionPrefix(isTargetCurrentUser),
};
}
if (isAllSelfJoinGroup) {
return {
title: membershipTitle,
action: "joined",
};
}
return {
title: membershipTitle,
action: "arrived",
};
}
if (isSameAdderGroup) {
return {
title: membershipTitle,
action: (
<>
{addedByActionPrefix(isTargetCurrentUser)}{" "}
<ProfileName pubkey={sharedActor} underlineOnHover>
{resolveInlineDisplayLabel(sharedActor, currentPubkey, profiles)}
</ProfileName>
, along with{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={additionalTargets}
/>
</>
),
};
}
if (isAllAdditionGroup) {
return {
title: membershipTitle,
action: (
<>
{addedActionPrefix(isTargetCurrentUser)} along with{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={additionalTargets}
/>
</>
),
};
}
if (isAllSelfJoinGroup) {
return {
title: membershipTitle,
action: (
<>
joined along with{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={additionalTargets}
/>
</>
),
};
}
return {
title: membershipTitle,
action: (
<>
arrived along with{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={additionalTargets}
/>
</>
),
};
}
function describeSystemEvent(
payload: SystemMessagePayload,
currentPubkey: string | undefined,
@@ -509,48 +551,16 @@ function describeSystemEvent(
);
switch (payload.type) {
case "members_added":
if (!payload.actor || !payload.targets?.length) return null;
return {
title: membershipTitle,
action: (
<>
{addedByActionPrefix(isTargetCurrentUser)}{" "}
<ProfileName pubkey={payload.actor} underlineOnHover>
{resolveInlineDisplayLabel(
payload.actor,
currentPubkey,
profiles,
)}
</ProfileName>
, along with{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={payload.targets.slice(1)}
/>
</>
),
};
case "members_joined":
if (!payload.targets?.length) return null;
return {
title: membershipTitle,
action: (
<>
joined the channel along with{" "}
<MemberNamesInlineList
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targets={payload.targets.slice(1)}
/>
</>
),
};
case "members_arrived":
return describeGroupedArrivals({
agentPubkeys,
currentPubkey,
isTargetCurrentUser,
membershipTitle,
payload,
personaLookup,
profiles,
});
case "member_joined_then_left":
if (!payload.target) return null;
return {
@@ -730,9 +740,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
return null;
}
const isMembershipArrival =
payload.type === "member_joined" ||
payload.type === "members_added" ||
payload.type === "members_joined";
payload.type === "member_joined" || payload.type === "members_arrived";
const isMembershipActivity =
isMembershipArrival ||
payload.type === "member_joined_then_left" ||

View File

@@ -34,11 +34,44 @@ const PROFILE_ONLY_AGENT_PUBKEY =
const OWNED_AGENT_PROFILE_PUBKEY =
"1212121212121212121212121212121212121212121212121212121212121212";
const HUDDLE_EPHEMERAL_CHANNEL_ID = "3f9f2c4e-8b7a-4b1c-9d2e-5a6f7c8d9e0f";
const ELROND_PUBKEY = "31".repeat(32);
const LEGOLAS_PUBKEY = "32".repeat(32);
const GIMLI_PUBKEY = "33".repeat(32);
const GANDALF_PUBKEY = "34".repeat(32);
const STANDARD_GROUPED_ARRIVAL_ACTOR = {
pubkey: "10".repeat(32),
displayName: "Alice Chen",
};
const STANDARD_GROUPED_ARRIVAL_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" },
];
const JOIN_COLLAPSE_PROFILES = [
{ pubkey: ELROND_PUBKEY, displayName: "Elrond" },
{ pubkey: LEGOLAS_PUBKEY, displayName: "Legolas" },
{ pubkey: GIMLI_PUBKEY, displayName: "Gimli" },
{ pubkey: GANDALF_PUBKEY, displayName: "Gandalf" },
];
const JOIN_COLLAPSE_CHANNEL_NAME = "random";
const SYSTEM_MESSAGE_KIND = 40099;
const DM_THREAD_AGENT_MENTION_ERROR_TEXT =
"Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent.";
const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT =
"Checking conversation members. Try again in a moment.";
const JOIN_COLLAPSE_SPLIT_TEXTS = [
"Elrond added by you",
"Legolas added by Elrond, along with Gimli",
"Gandalf added by you",
];
const JOIN_COLLAPSE_GROUPED_TEXT =
"Elrond was added along with Legolas, Gimli, and Gandalf";
const JOIN_COLLAPSE_CAPTURE_WIDTH = 560;
const JOIN_COLLAPSE_CAPTURE_HEIGHT = 260;
const JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING = 24;
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
function autocomplete(page: import("@playwright/test").Page) {
@@ -236,6 +269,91 @@ async function waitForTimelineSettled(page: import("@playwright/test").Page) {
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
}
function normalizeVisibleText(text: string) {
return text.replace(/\s+,/g, ",").replace(/\s+/g, " ").trim();
}
function findDuplicateFixturePubkeys(
profiles: Array<{ pubkey: string; displayName: string }>,
) {
const namesByPubkey = new Map<string, string[]>();
for (const profile of profiles) {
const names = namesByPubkey.get(profile.pubkey) ?? [];
names.push(profile.displayName);
namesByPubkey.set(profile.pubkey, names);
}
return [...namesByPubkey.entries()]
.filter(([, names]) => names.length > 1)
.map(([pubkey, displayNames]) => ({ pubkey, displayNames }));
}
async function collectJoinCollapseRows(page: import("@playwright/test").Page) {
const rows = page.getByTestId("system-message-row").filter({
hasText: /Elrond|Legolas|Gimli|Gandalf/,
});
const texts: string[] = [];
const count = await rows.count();
for (let index = 0; index < count; index += 1) {
texts.push(normalizeVisibleText(await rows.nth(index).innerText()));
}
return { rows, texts };
}
async function maybeCaptureJoinCollapseTimeline(
page: import("@playwright/test").Page,
) {
const capturePath = process.env.JOIN_COLLAPSE_CAPTURE_PATH;
if (!capturePath) return;
await waitForAnimations(page);
const timeline = page.getByTestId("message-timeline");
const timelineBox = await timeline.boundingBox();
const { rows } = await collectJoinCollapseRows(page);
const rowBoxes = await Promise.all(
Array.from({ length: await rows.count() }, (_, index) =>
rows.nth(index).boundingBox(),
),
);
const visibleRowBoxes = rowBoxes.filter(
(box): box is NonNullable<typeof box> => box !== null,
);
if (!timelineBox || visibleRowBoxes.length === 0) {
throw new Error("Join-collapse screenshot target is not visible");
}
const minRowY = Math.min(...visibleRowBoxes.map((box) => box.y));
const maxRowY = Math.max(...visibleRowBoxes.map((box) => box.y + box.height));
const minRowX = Math.min(...visibleRowBoxes.map((box) => box.x));
const maxRowX = Math.max(...visibleRowBoxes.map((box) => box.x + box.width));
const desiredTop = minRowY - JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING;
const desiredBottom = maxRowY + JOIN_COLLAPSE_CAPTURE_VERTICAL_PADDING;
const desiredCenter = (desiredTop + desiredBottom) / 2;
const desiredHorizontalCenter = (minRowX + maxRowX) / 2;
const viewportHeight = page.viewportSize()?.height ?? 720;
const clip = {
x: Math.max(
Math.round(timelineBox.x),
Math.min(
Math.round(desiredHorizontalCenter - JOIN_COLLAPSE_CAPTURE_WIDTH / 2),
Math.round(
timelineBox.x + timelineBox.width - JOIN_COLLAPSE_CAPTURE_WIDTH,
),
),
),
y: Math.max(
0,
Math.min(
Math.round(desiredCenter - JOIN_COLLAPSE_CAPTURE_HEIGHT / 2),
viewportHeight - JOIN_COLLAPSE_CAPTURE_HEIGHT,
),
),
width: JOIN_COLLAPSE_CAPTURE_WIDTH,
height: JOIN_COLLAPSE_CAPTURE_HEIGHT,
};
await page.screenshot({ path: capturePath, clip });
console.log(`JOIN_COLLAPSE_CAPTURE_PATH=${capturePath}`);
}
async function expectOwnedAgentProfileActions(
profilePopover: import("@playwright/test").Locator,
pubkey: string,
@@ -3611,14 +3729,14 @@ test("system add rows use plain names while remove rows retain agent mention sty
],
});
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(
({ actorPubkey, kind, targetPubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
channelName: "random",
content: JSON.stringify({
type: "member_joined",
actor: actorPubkey,
@@ -3627,7 +3745,7 @@ test("system add rows use plain names while remove rows retain agent mention sty
kind,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
channelName: "random",
content: JSON.stringify({
type: "member_removed",
actor: actorPubkey,
@@ -3664,32 +3782,22 @@ test("system add rows use plain names while remove rows retain agent mention sty
test("groups contiguous arrival activity 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" },
];
const actor = STANDARD_GROUPED_ARRIVAL_ACTOR;
const targets = STANDARD_GROUPED_ARRIVAL_TARGETS;
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.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random", 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",
channelName: "random",
content: JSON.stringify({
type: "member_joined",
actor: actorPubkey,
@@ -3753,16 +3861,119 @@ test("groups contiguous arrival activity with hidden names in the standard toolt
await expect(avatarStack.locator("..")).toHaveCSS("align-items", "center");
});
test("keeps deterministic grouped-arrival fixture pubkeys unique", () => {
expect(
findDuplicateFixturePubkeys([
STANDARD_GROUPED_ARRIVAL_ACTOR,
...STANDARD_GROUPED_ARRIVAL_TARGETS,
...JOIN_COLLAPSE_PROFILES,
]),
).toEqual([]);
});
test("collapses contiguous mixed join arrivals into one actor-neutral cohort", async ({
page,
}) => {
await installMockBridge(page, {
searchProfiles: JOIN_COLLAPSE_PROFILES,
});
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText(
JOIN_COLLAPSE_CHANNEL_NAME,
);
await waitForMockLiveSubscription(
page,
JOIN_COLLAPSE_CHANNEL_NAME,
SYSTEM_MESSAGE_KIND,
);
await page.evaluate(
({ channelName, currentUser, elrond, legolas, gimli, gandalf, kind }) => {
const createdAt = Math.floor(Date.now() / 1_000);
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: JSON.stringify({
type: "member_joined",
actor: currentUser,
target: elrond,
}),
createdAt,
kind,
pubkey: currentUser,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: JSON.stringify({
type: "member_joined",
actor: elrond,
target: legolas,
}),
createdAt: createdAt + 1,
kind,
pubkey: elrond,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: JSON.stringify({
type: "member_joined",
actor: elrond,
target: gimli,
}),
createdAt: createdAt + 2,
kind,
pubkey: elrond,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
content: JSON.stringify({
type: "member_joined",
actor: currentUser,
target: gandalf,
}),
createdAt: createdAt + 3,
kind,
pubkey: currentUser,
});
},
{
channelName: JOIN_COLLAPSE_CHANNEL_NAME,
currentUser: MOCK_VIEWER_PUBKEY,
elrond: ELROND_PUBKEY,
legolas: LEGOLAS_PUBKEY,
gimli: GIMLI_PUBKEY,
gandalf: GANDALF_PUBKEY,
kind: SYSTEM_MESSAGE_KIND,
},
);
await waitForTimelineSettled(page);
await page.getByTestId("message-timeline").evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await waitForAnimations(page);
const { rows, texts } = await collectJoinCollapseRows(page);
console.log(`JOIN_COLLAPSE_VISIBLE_TEXTS=${JSON.stringify(texts)}`);
if (process.env.JOIN_COLLAPSE_EXPECT_SPLIT === "1") {
expect(texts).toEqual(JOIN_COLLAPSE_SPLIT_TEXTS);
}
await maybeCaptureJoinCollapseTimeline(page);
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText(JOIN_COLLAPSE_GROUPED_TEXT);
});
test("system agent profile exposes owned agent actions", 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(
({ actorPubkey, kind, targetPubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
channelName: "random",
content: JSON.stringify({
type: "member_joined",
actor: actorPubkey,
@@ -3825,8 +4036,7 @@ test("system agent activity avatar stack is decorative", async ({ page }) => {
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "mira" })
.filter({ hasText: "joined the channel" });
.filter({ has: page.getByText("mira", { exact: true }) });
const avatarStack = joinedRow.getByTestId("system-message-avatar-stack");
await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(1);
await expect(avatarStack.locator("button")).toHaveCount(0);
@@ -3873,6 +4083,57 @@ test("membership activity folds a member joining then leaving", async ({
);
});
test("membership activity folds duplicate self-joins then leaving into one row", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
// The relay re-emits `member_joined` on each PUT_USER, so a member can arrive
// twice before leaving. Both arrivals group with the departure; describing
// only a 2-event pair dropped the departure and left the member rendered as
// still present.
await page.evaluate(
({ alicePubkey, kind }) => {
const createdAt = Math.floor(Date.now() / 1_000);
const join = {
type: "member_joined",
actor: alicePubkey,
target: alicePubkey,
};
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: JSON.stringify(join),
createdAt,
kind,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: JSON.stringify(join),
createdAt: createdAt + 1,
kind,
});
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: JSON.stringify({ type: "member_left", actor: alicePubkey }),
createdAt: createdAt + 2,
kind,
});
},
{ alicePubkey: TEST_IDENTITIES.alice.pubkey, kind: SYSTEM_MESSAGE_KIND },
);
await waitForTimelineSettled(page);
const aliceRows = page
.getByTestId("system-message-row")
.filter({ hasText: "alice" });
await expect(aliceRows).toHaveCount(1);
await expect(aliceRows).toHaveText(/joined, then left the channel/);
await expect(aliceRows.getByTestId("system-message-avatar")).toHaveCount(1);
});
test("profile-only agent author hides actions without agent access", async ({
page,
}) => {
@@ -3937,8 +4198,7 @@ test("system member-joined rows render the joined person as a plain profile name
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "bob" })
.filter({ hasText: "joined the channel" });
.filter({ has: page.getByText("bob", { exact: true }) });
const joinedPersonName = joinedRow.getByText("bob", { exact: true });
await expect(joinedPersonName).toBeVisible();