Group channel membership events (#1713)

Co-authored-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
klopez4212
2026-07-12 16:02:15 +00:00
committed by GitHub
co-authored by Wes
parent 89e2e2e4ad
commit 5dc70bd0b8
6 changed files with 705 additions and 59 deletions
@@ -157,6 +157,7 @@ export function estimateRowHeight(
// Dividers are short, fixed-height rows; reserving their true height keeps the
// estimate honest without a content scan.
const DIVIDER_HEIGHT = 32;
const SYSTEM_GROUP_HEIGHT = 80;
/**
* `contain-intrinsic-size` for a `timeline-row-cv` wrapper. A credible per-row
@@ -174,6 +175,8 @@ export function timelineRowReserveStyle(
}) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING)
: item.kind === "system"
? estimateRowHeight(item.entry.message)
: DIVIDER_HEIGHT;
: item.kind === "system-group"
? SYSTEM_GROUP_HEIGHT
: DIVIDER_HEIGHT;
return { containIntrinsicSize: `auto ${height}px` };
}
@@ -35,6 +35,19 @@ function entry(overrides) {
return { message: message(overrides), summary: null };
}
function memberAddedEntry({ actor = "actor-a", createdAt, id, target }) {
return entry({
id,
createdAt,
kind: KIND_SYSTEM_MESSAGE,
body: JSON.stringify({ type: "member_joined", actor, target }),
});
}
function memberJoinedEntry({ createdAt, id, target }) {
return memberAddedEntry({ actor: target, createdAt, id, target });
}
function kinds(items) {
return items.map((item) => item.kind);
}
@@ -90,6 +103,97 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => {
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
});
test("buildTimelineItems: member additions by one actor group within five minutes", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 60 }),
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 300 }),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group"]);
const group = items.find((item) => item.kind === "system-group");
assert.deepEqual(
group?.entries.map((groupEntry) => groupEntry.message.id),
["a", "b", "c"],
);
assert.equal(group?.key, "a");
});
test("buildTimelineItems: self-joins group across different members within five minutes", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }),
memberJoinedEntry({
id: "b",
target: "target-b",
createdAt: start + 60,
}),
memberJoinedEntry({
id: "c",
target: "target-c",
createdAt: start + 300,
}),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group"]);
const group = items.find((item) => item.kind === "system-group");
assert.deepEqual(
group?.entries.map((groupEntry) => groupEntry.message.id),
["a", "b", "c"],
);
});
test("buildTimelineItems: member-add window is fixed from the first addition", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }),
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 301 }),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), ["day-divider", "system-group", "system"]);
});
test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => {
const start = dayAt(2026, 6, 14);
const entries = [
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
memberAddedEntry({
id: "b",
actor: "actor-b",
target: "target-b",
createdAt: start + 30,
}),
entry({ id: "message", createdAt: start + 60 }),
memberAddedEntry({
id: "c",
actor: "actor-b",
target: "target-c",
createdAt: start + 90,
}),
memberAddedEntry({
id: "self-join",
actor: "target-d",
target: "target-d",
createdAt: start + 120,
}),
];
const { items } = buildTimelineItems(entries, null);
assert.deepEqual(kinds(items), [
"day-divider",
"system",
"system",
"message",
"system",
"system",
]);
});
test("buildTimelineItems: consecutive same-author messages within the window are grouped", () => {
const entries = [
entry({ id: "a", pubkey: "author-a", createdAt: dayAt(2026, 6, 14) }),
@@ -20,7 +20,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
/**
* One renderable row in the flattened timeline. Dividers carry no message and
* never appear in the index map; the three message-bearing kinds do.
* never appear in the index map; the message-bearing kinds do.
*/
export type TimelineItem =
// `headingTimestamp` (not a prebaked label) so the render still resolves
@@ -28,6 +28,11 @@ export type TimelineItem =
| { kind: "day-divider"; key: string; headingTimestamp: number }
| { kind: "unread-divider"; key: string }
| { kind: "system"; key: string; entry: MainTimelineEntry }
| {
kind: "system-group";
key: string;
entries: MainTimelineEntry[];
}
| {
kind: "message";
key: string;
@@ -57,6 +62,45 @@ function entryRenderKey(entry: MainTimelineEntry): string {
return entry.message.renderKey ?? entry.message.id;
}
const MEMBERSHIP_GROUP_WINDOW_SECONDS = 5 * 60;
type MembershipChangePayload = {
actor: string | null;
mode: "added" | "joined";
target: string;
};
function parseMembershipChangePayload(
entry: MainTimelineEntry,
): MembershipChangePayload | null {
if (entry.message.kind !== KIND_SYSTEM_MESSAGE) return null;
try {
const payload = JSON.parse(entry.message.body) as {
type?: unknown;
actor?: unknown;
target?: unknown;
};
if (
payload.type !== "member_joined" ||
typeof payload.actor !== "string" ||
typeof payload.target !== "string"
) {
return null;
}
const actor = payload.actor.trim().toLowerCase();
const target = payload.target.trim().toLowerCase();
if (!actor || !target) return null;
return actor === target
? { actor: null, mode: "joined", target }
: { actor, mode: "added", target };
} catch {
return null;
}
}
/**
* Walks the (already top-level-filtered) entries once, emitting a day-divider
* at each calendar-day boundary and an unread-divider above the first unread
@@ -69,6 +113,7 @@ export function buildTimelineItems(
const items: TimelineItem[] = [];
let previousGroupEntry: MainTimelineEntry | null = null;
let previousMessageItemIndex: number | null = null;
let previousMembershipItemIndex: number | null = null;
// Index boundaries by their start position so the walk below can look up the
// prepend-stable section key (start-of-local-day). Keying the divider by
@@ -89,6 +134,7 @@ export function buildTimelineItems(
if (dayBoundary) {
previousGroupEntry = null;
previousMessageItemIndex = null;
previousMembershipItemIndex = null;
items.push({
kind: "day-divider",
key: dayBoundary.key,
@@ -99,6 +145,7 @@ export function buildTimelineItems(
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
previousGroupEntry = null;
previousMessageItemIndex = null;
previousMembershipItemIndex = null;
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
}
@@ -106,10 +153,49 @@ export function buildTimelineItems(
if (kind === "system") {
previousGroupEntry = null;
previousMessageItemIndex = null;
const membershipChange = parseMembershipChangePayload(entry);
const previousItem =
previousMembershipItemIndex === null
? null
: items[previousMembershipItemIndex];
const previousEntries =
previousItem?.kind === "system-group"
? previousItem.entries
: previousItem?.kind === "system"
? [previousItem.entry]
: [];
const firstPreviousEntry = previousEntries[0];
const firstPreviousPayload = firstPreviousEntry
? parseMembershipChangePayload(firstPreviousEntry)
: null;
if (
membershipChange &&
firstPreviousEntry &&
firstPreviousPayload?.mode === membershipChange.mode &&
(membershipChange.mode === "joined" ||
firstPreviousPayload.actor === membershipChange.actor) &&
message.createdAt >= firstPreviousEntry.message.createdAt &&
message.createdAt - firstPreviousEntry.message.createdAt <=
MEMBERSHIP_GROUP_WINDOW_SECONDS
) {
const groupIndex = previousMembershipItemIndex as number;
items[groupIndex] = {
kind: "system-group",
key: entryRenderKey(firstPreviousEntry),
entries: [...previousEntries, entry],
};
continue;
}
items.push({ kind, key: renderKey, entry });
previousMembershipItemIndex = membershipChange ? items.length - 1 : null;
continue;
}
previousMembershipItemIndex = null;
const isContinuation =
previousGroupEntry !== null &&
hasSameMessageAuthor(previousGroupEntry.message, message) &&
@@ -2,7 +2,10 @@ import { SmilePlus } from "lucide-react";
import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import type { TimelineMessage } from "@/features/messages/types";
import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
@@ -22,6 +25,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0";
@@ -31,6 +35,7 @@ type SystemMessagePayload = {
type: string;
actor?: string;
target?: string;
targets?: string[];
topic?: string;
purpose?: string;
// Moderation tombstone fields (kind:40099 "message_deleted"). All optional and
@@ -46,6 +51,104 @@ type SystemMessageDescription = {
title: React.ReactNode;
};
const MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES = 3;
function parseSystemMessagePayload(
message: TimelineMessage,
): SystemMessagePayload | null {
try {
return JSON.parse(message.body) as SystemMessagePayload;
} catch {
return null;
}
}
function buildGroupedMembershipPayload(
messages: readonly TimelineMessage[],
): SystemMessagePayload | null {
if (messages.length < 2) return null;
const payloads = messages.map(parseSystemMessagePayload);
const firstPayload = payloads[0];
const actor = firstPayload?.actor
? normalizePubkey(firstPayload.actor)
: null;
const firstTarget = firstPayload?.target
? normalizePubkey(firstPayload.target)
: null;
if (!actor || !firstTarget) return null;
const isSelfJoinGroup = actor === firstTarget;
const targets: string[] = [];
for (const payload of payloads) {
const payloadActor = payload?.actor ? normalizePubkey(payload.actor) : null;
const payloadTarget = payload?.target
? normalizePubkey(payload.target)
: null;
if (
payload?.type !== "member_joined" ||
!payloadActor ||
!payloadTarget ||
(isSelfJoinGroup
? payloadActor !== payloadTarget
: payloadActor !== actor || payloadActor === payloadTarget)
) {
return null;
}
targets.push(payloadTarget);
}
if (isSelfJoinGroup) {
return {
type: "members_joined",
target: targets[0],
targets,
};
}
return {
type: "members_added",
actor,
target: targets[0],
targets,
};
}
function aggregateGroupedReactions(
messages: readonly TimelineMessage[],
): TimelineReaction[] {
const reactionsByEmoji = new Map<
string,
TimelineReaction & {
usersByKey: Map<string, TimelineReaction["users"][number]>;
}
>();
for (const message of messages) {
for (const reaction of message.reactions ?? []) {
const existing = reactionsByEmoji.get(reaction.emoji) ?? {
emoji: reaction.emoji,
emojiUrl: reaction.emojiUrl,
count: 0,
reactedByCurrentUser: false,
users: [],
usersByKey: new Map(),
};
existing.reactedByCurrentUser ||= reaction.reactedByCurrentUser === true;
for (const user of reaction.users) {
const userKey = normalizePubkey(user.pubkey) || user.displayName;
existing.usersByKey.set(userKey, user);
}
reactionsByEmoji.set(reaction.emoji, existing);
}
}
return [...reactionsByEmoji.values()].map(({ usersByKey, ...reaction }) => {
const users = [...usersByKey.values()];
return { ...reaction, count: users.length, users };
});
}
function resolveLabel(
pubkey: string | undefined,
currentPubkey: string | undefined,
@@ -96,11 +199,13 @@ function ProfileName({
highlight = false,
isAgent = false,
pubkey,
underlineOnHover = false,
}: {
children: React.ReactNode;
highlight?: boolean;
isAgent?: boolean;
pubkey: string | undefined;
underlineOnHover?: boolean;
}) {
const isAgentMention = highlight && isAgent;
const node = (
@@ -115,6 +220,7 @@ function ProfileName({
isAgentMention && "agent-mention-highlight",
)
: "rounded-xs transition-colors hover:text-foreground",
underlineOnHover && "hover:underline",
)}
>
{highlight && !isAgentMention ? (
@@ -252,6 +358,118 @@ function SystemMessageAvatar({
);
}
function MembershipPersonName({
agentPubkeys,
currentPubkey,
personaLookup,
profiles,
pubkey,
}: {
agentPubkeys?: ReadonlySet<string>;
currentPubkey: string | undefined;
personaLookup?: Map<string, string>;
profiles: UserProfileLookup | undefined;
pubkey: string;
}) {
return (
<ProfileName
isAgent={isKnownAgentPubkey(
pubkey,
profiles,
personaLookup,
agentPubkeys,
)}
pubkey={pubkey}
underlineOnHover
>
{resolveDisplayLabel(pubkey, currentPubkey, profiles)}
</ProfileName>
);
}
function MemberNamesInlineList({
agentPubkeys,
currentPubkey,
personaLookup,
profiles,
targets,
}: {
agentPubkeys?: ReadonlySet<string>;
currentPubkey: string | undefined;
personaLookup?: Map<string, string>;
profiles: UserProfileLookup | undefined;
targets: string[];
}) {
const visibleTargets = targets.slice(0, MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES);
const hiddenTargets = targets.slice(MAX_VISIBLE_ADDITIONAL_MEMBER_NAMES);
const renderName = (pubkey: string) => (
<MembershipPersonName
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
pubkey={pubkey}
/>
);
return (
<>
{visibleTargets.map((pubkey, index) => {
const isLast = index === visibleTargets.length - 1;
const separator =
index === 0
? null
: isLast && hiddenTargets.length === 0
? visibleTargets.length === 2
? " and "
: ", and "
: ", ";
return (
<React.Fragment key={pubkey}>
{separator}
{renderName(pubkey)}
</React.Fragment>
);
})}
{hiddenTargets.length > 0 ? (
<>
, and{" "}
<Tooltip>
<TooltipTrigger asChild>
<button
className="cursor-help rounded-xs hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
type="button"
>
{hiddenTargets.length} others
</button>
</TooltipTrigger>
<TooltipContent className="max-w-72 p-2 text-left" side="top">
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{hiddenTargets.map((pubkey) => (
<div className="flex items-center gap-2" key={pubkey}>
<UserAvatar
avatarUrl={resolveAvatarUrl(pubkey, profiles)}
className="!h-5 !w-5 shrink-0 text-3xs"
displayName={resolveDisplayLabel(
pubkey,
currentPubkey,
profiles,
)}
/>
<span className="min-w-0 truncate">
{resolveDisplayLabel(pubkey, currentPubkey, profiles)}
</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</>
) : null}
</>
);
}
function describeSystemEvent(
payload: SystemMessagePayload,
currentPubkey: string | undefined,
@@ -283,18 +501,73 @@ function describeSystemEvent(
{targetLabel}
</ProfileName>
);
const membershipTitle = (
<ProfileName
isAgent={isTargetAgent}
pubkey={payload.target}
underlineOnHover
>
{targetLabel}
</ProfileName>
);
switch (payload.type) {
case "members_added":
if (!payload.actor || !payload.targets?.length) return null;
return {
title: membershipTitle,
action: (
<>
was added by{" "}
<ProfileName pubkey={payload.actor} underlineOnHover>
{resolveDisplayLabel(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 "member_joined": {
if (payload.actor === payload.target) {
if (!payload.actor || !payload.target) return null;
if (normalizePubkey(payload.actor) === normalizePubkey(payload.target)) {
return {
title: targetName,
title: membershipTitle,
action: "joined the channel",
};
}
return {
title: actorName,
action: <>added {targetName} to the channel</>,
title: membershipTitle,
action: (
<>
was added by{" "}
<ProfileName pubkey={payload.actor} underlineOnHover>
{resolveDisplayLabel(payload.actor, currentPubkey, profiles)}
</ProfileName>
</>
),
};
}
case "member_left":
@@ -354,6 +627,7 @@ function describeSystemEvent(
export const SystemMessageRow = React.memo(function SystemMessageRow({
message,
groupedMessages,
currentPubkey,
agentPubkeys,
profiles,
@@ -361,6 +635,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
onToggleReaction,
}: {
message: TimelineMessage;
groupedMessages?: TimelineMessage[];
currentPubkey?: string;
agentPubkeys?: ReadonlySet<string>;
profiles?: UserProfileLookup;
@@ -372,6 +647,45 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
remove: boolean,
) => Promise<void>;
}) {
const sourceMessages = React.useMemo(
() => groupedMessages ?? [message],
[groupedMessages, message],
);
const groupedPayload = React.useMemo(
() => buildGroupedMembershipPayload(sourceMessages),
[sourceMessages],
);
const reactionMessage = React.useMemo(
() =>
groupedPayload
? {
...message,
pending: sourceMessages.some((source) => source.pending),
reactions: aggregateGroupedReactions(sourceMessages),
}
: message,
[groupedPayload, message, sourceMessages],
);
const handleGroupedReaction = React.useCallback(
async (_groupMessage: TimelineMessage, emoji: string, remove: boolean) => {
if (!onToggleReaction) return;
if (!remove) {
await onToggleReaction(message, emoji, false);
return;
}
const reactedMessages = sourceMessages.filter((source) =>
source.reactions?.some(
(reaction) =>
reaction.emoji === emoji && reaction.reactedByCurrentUser,
),
);
await Promise.all(
reactedMessages.map((source) => onToggleReaction(source, emoji, true)),
);
},
[message, onToggleReaction, sourceMessages],
);
const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
null,
);
@@ -382,14 +696,15 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
pending: reactionPending,
errorMessage: reactionErrorMessage,
select: handleReactionSelect,
} = useReactionHandler(message, onToggleReaction);
} = useReactionHandler(
reactionMessage,
groupedPayload && onToggleReaction
? handleGroupedReaction
: onToggleReaction,
);
let payload: SystemMessagePayload;
try {
payload = JSON.parse(message.body);
} catch {
return null;
}
const payload = groupedPayload ?? parseSystemMessagePayload(message);
if (!payload) return null;
const description = describeSystemEvent(
payload,
@@ -401,6 +716,10 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
if (!description) {
return null;
}
const isMembershipArrival =
payload.type === "member_joined" ||
payload.type === "members_added" ||
payload.type === "members_joined";
const wouldAddReaction = (emoji: string) =>
!reactions.some(
@@ -409,34 +728,39 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
return (
<div
className="group/message relative mx-1 rounded-2xl px-2 py-2 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
className="group/message relative mx-1 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
data-testid="system-message-row"
>
<div className="flex items-start gap-2.5">
<SystemMessageAvatar
actorPubkey={payload.actor}
actorPubkey={isMembershipArrival ? payload.target : payload.actor}
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targetPubkey={payload.target}
targetPubkey={isMembershipArrival ? undefined : payload.target}
/>
<div className={cn(MESSAGE_MARKDOWN_CLASS, "min-w-0 flex-1")}>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<div className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
<div
className={cn(
MESSAGE_MARKDOWN_CLASS,
"flex min-w-0 flex-1 flex-col gap-0.5",
)}
>
<MessageHeaderRow>
<MessageAuthorText as="div" className="text-foreground">
{description.title}
</div>
</MessageAuthorText>
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
/>
</div>
<p className="mt-1 text-sm leading-snug text-foreground">
</MessageHeaderRow>
<p className="-mt-0.5 text-sm leading-snug text-foreground">
{description.action}
</p>
<div>
<MessageReactions
messageId={message.id}
messageId={reactionMessage.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
@@ -193,6 +193,18 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
profiles={profiles}
/>
);
case "system-group":
return (
<SystemRow
currentPubkey={currentPubkey}
entries={item.entries}
footer={item.entries.map(
(entry) => messageFooters?.[entry.message.id] ?? null,
)}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
);
case "message":
return (
<MessageRowItem
@@ -292,21 +304,32 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
function SystemRow({
currentPubkey,
entries,
entry,
footer,
onToggleReaction,
profiles,
}: {
currentPubkey?: string;
entry: MainTimelineEntry;
entries?: MainTimelineEntry[];
entry?: MainTimelineEntry;
footer: React.ReactNode;
onToggleReaction?: TimelineMessageListProps["onToggleReaction"];
profiles?: UserProfileLookup;
}) {
const systemEntries = entries ?? (entry ? [entry] : []);
const firstEntry = systemEntries[0];
const groupedMessages = React.useMemo(
() => entries?.map((systemEntry) => systemEntry.message),
[entries],
);
if (!firstEntry) return null;
return (
<div className="flex flex-col gap-1 pb-2.5">
<SystemMessageRow
message={entry.message}
groupedMessages={groupedMessages}
message={firstEntry.message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
+139 -33
View File
@@ -880,7 +880,7 @@ test("mentioning a non-member provider managed agent deploys it before sending",
await expect(mentionChip).toBeVisible();
});
test("system add and remove rows use agent mention styling for managed agents", async ({
test("system add rows use plain names while remove rows retain agent mention styling", async ({
page,
}) => {
await installMockBridge(page, {
@@ -932,16 +932,15 @@ test("system add and remove rows use agent mention styling for managed agents",
const addedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "added portal to the channel" });
.filter({ hasText: "portal" })
.filter({ hasText: "was added by" });
const removedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "removed portal from the channel" });
await expect(
addedRow.locator("[data-mention].agent-mention-highlight", {
hasText: "portal",
}),
).toHaveText("portal");
const addedName = addedRow.getByText("portal", { exact: true });
await expect(addedName).toBeVisible();
await expect(addedName).not.toHaveAttribute("data-mention");
await expect(
removedRow.locator("[data-mention].agent-mention-highlight", {
hasText: "portal",
@@ -949,6 +948,125 @@ test("system add and remove rows use agent mention styling for managed agents",
).toHaveText("portal");
});
test("groups member additions and joins with hidden names in the standard tooltip", async ({
page,
}) => {
const actor = {
pubkey: "10".repeat(32),
displayName: "Alice Chen",
};
const targets = [
{ pubkey: "11".repeat(32), displayName: "Erica Chapman" },
{ pubkey: "12".repeat(32), displayName: "Peter Griffin" },
{ pubkey: "13".repeat(32), displayName: "Marcia Thomas" },
{ pubkey: "14".repeat(32), displayName: "Jordan Lee" },
{ pubkey: "15".repeat(32), displayName: "Olivia Park" },
{ pubkey: "16".repeat(32), displayName: "Sam Rivera" },
];
await installMockBridge(page, {
searchProfiles: [actor, ...targets],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
await page.evaluate(
({ actorPubkey, addedTargets, kind }) => {
const createdAt = Math.floor(Date.now() / 1_000);
for (const [index, target] of addedTargets.entries()) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: JSON.stringify({
type: "member_joined",
actor: actorPubkey,
target: target.pubkey,
}),
createdAt: createdAt + index,
kind,
});
}
},
{
actorPubkey: actor.pubkey,
addedTargets: targets,
kind: SYSTEM_MESSAGE_KIND,
},
);
await waitForTimelineSettled(page);
const groupedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "was added by Alice Chen" });
for (const visibleName of [
"Erica Chapman",
"Peter Griffin",
"Marcia Thomas",
"Jordan Lee",
]) {
await expect(groupedRow).toContainText(visibleName);
}
await expect(
groupedRow.locator("p").filter({ hasText: "was added by" }),
).toContainText(
"was added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others",
);
await expect(groupedRow.locator("[data-mention]")).toHaveCount(0);
const visibleName = groupedRow.getByText("Peter Griffin", { exact: true });
await expect(visibleName).toHaveCSS("text-decoration-line", "none");
await visibleName.hover();
await expect(visibleName).toHaveCSS("text-decoration-line", "underline");
const othersTrigger = groupedRow.getByRole("button", { name: "2 others" });
await expect(othersTrigger).toHaveCSS("text-decoration-line", "none");
await othersTrigger.hover();
await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline");
const tooltip = page.getByRole("tooltip");
await expect(tooltip).toContainText("Olivia Park");
await expect(tooltip).toContainText("Sam Rivera");
await page.evaluate(
({ addedTargets, kind }) => {
const createdAt = Math.floor(Date.now() / 1_000) + 60;
for (const [index, target] of addedTargets.entries()) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: JSON.stringify({
type: "member_joined",
actor: target.pubkey,
target: target.pubkey,
}),
createdAt: createdAt + index,
kind,
});
}
},
{ addedTargets: targets, kind: SYSTEM_MESSAGE_KIND },
);
await waitForTimelineSettled(page);
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "joined the channel" })
.filter({ hasText: "Erica Chapman" });
await expect(
joinedRow.locator("p").filter({ hasText: "joined the channel" }),
).toContainText(
"joined the channel along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others",
);
await expect(joinedRow.locator("[data-mention]")).toHaveCount(0);
const joinedOthersTrigger = joinedRow.getByRole("button", {
name: "2 others",
});
await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none");
await joinedOthersTrigger.hover();
await expect(page.getByRole("tooltip")).toContainText("Olivia Park");
await expect(page.getByRole("tooltip")).toContainText("Sam Rivera");
});
test("system agent profile only exposes message action", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -977,15 +1095,12 @@ test("system agent profile only exposes message action", async ({ page }) => {
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "added mira to the channel" });
const agentChip = joinedRow.locator(
"[data-mention].agent-mention-highlight",
{
hasText: "mira",
},
);
await expect(agentChip).toHaveText("mira");
await agentChip.hover();
.filter({ hasText: "mira" })
.filter({ hasText: "was added by" });
const agentName = joinedRow.getByText("mira", { exact: true });
await expect(agentName).toHaveText("mira");
await expect(agentName).not.toHaveAttribute("data-mention");
await agentName.hover();
const profilePopover = page.locator(
'[data-testid="user-profile-popover"][data-state="open"]',
@@ -999,14 +1114,14 @@ test("system agent profile only exposes message action", async ({ page }) => {
test("system agent avatar only exposes message action", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await waitForMockLiveSubscription(page, "random", SYSTEM_MESSAGE_KIND);
await page.evaluate(
({ kind, targetPubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
channelName: "random",
content: JSON.stringify({
type: "member_joined",
actor: targetPubkey,
@@ -1076,7 +1191,7 @@ test("profile-only agent author popover only exposes message action", async ({
);
});
test("system member-joined rows render the joined person as a mention chip", async ({
test("system member-joined rows render the joined person as a plain profile name", async ({
page,
}) => {
await page.goto("/");
@@ -1104,19 +1219,10 @@ test("system member-joined rows render the joined person as a mention chip", asy
.getByTestId("system-message-row")
.filter({ hasText: "bob" })
.filter({ hasText: "joined the channel" });
const joinedPersonChip = joinedRow.locator("[data-mention].mention-chip", {
hasText: "bob",
});
const joinedPersonName = joinedRow.getByText("bob", { exact: true });
await expect(joinedPersonChip).toBeVisible();
await expect(joinedPersonChip).toHaveCSS("display", /^(inline-)?flex$/);
await expect(joinedPersonChip).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(joinedPersonChip.locator(".mention-chip-prefix")).toHaveText(
"@",
);
await expect(joinedPersonName).toBeVisible();
await expect(joinedPersonName).not.toHaveAttribute("data-mention");
});
test("selecting a non-member agent from a DM inserts @Name into input", async ({