mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Collapse contiguous join messages (#7262)
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
126
desktop/src/features/messages/lib/membershipGroupPayload.ts
Normal file
126
desktop/src/features/messages/lib/membershipGroupPayload.ts
Normal 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 };
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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[],
|
||||
|
||||
567
desktop/src/features/messages/ui/SystemMessageRow.test.mjs
Normal file
567
desktop/src/features/messages/ui/SystemMessageRow.test.mjs
Normal 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", "👍"],
|
||||
]);
|
||||
});
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user