mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Group channel membership events (#1713)
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user