From ebe34b1d6817aa45eb78823d74cab50ed5ad023b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 2 Aug 2026 08:37:37 +0100 Subject: [PATCH 1/8] feat(desktop): refine timeline activity presentation Signed-off-by: kenny lopez --- .../features/channels/ui/ChannelScreen.tsx | 4 + .../MembershipActivityAvatarDebugToggle.tsx | 75 +++ .../messages/lib/rowHeightEstimate.test.mjs | 4 +- .../messages/lib/rowHeightEstimate.ts | 2 +- .../messages/lib/systemEventCopy.test.mjs | 6 + .../features/messages/lib/systemEventCopy.ts | 9 + .../messages/lib/timelineItems.test.mjs | 62 +- .../features/messages/lib/timelineItems.ts | 32 +- .../lib/virtualizedTimelineItems.test.mjs | 2 +- .../messages/lib/virtualizedTimelineItems.ts | 2 +- .../src/features/messages/ui/DayDivider.tsx | 24 +- .../features/messages/ui/MessageTimeline.tsx | 8 +- .../messages/ui/SystemMessageAvatars.tsx | 196 +++++++ .../features/messages/ui/SystemMessageRow.tsx | 531 +++++++++--------- .../messages/ui/TimelineMessageList.tsx | 206 +++++-- .../features/messages/ui/TimelineRowShell.tsx | 27 + .../profile/ui/UserProfilePopover.tsx | 5 + desktop/src/shared/layout/chromeLayout.ts | 3 + desktop/tests/e2e/channels.spec.ts | 142 +++-- desktop/tests/e2e/mentions.spec.ts | 124 ++-- desktop/tests/e2e/unread-pill.spec.ts | 20 + preview-features.json | 14 + 22 files changed, 1058 insertions(+), 440 deletions(-) create mode 100644 desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx create mode 100644 desktop/src/features/messages/ui/SystemMessageAvatars.tsx create mode 100644 desktop/src/features/messages/ui/TimelineRowShell.tsx diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index b235468cd8..476436d5e0 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -84,6 +84,7 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; +import { MembershipActivityAvatarDebugToggle } from "./MembershipActivityAvatarDebugToggle"; const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ @@ -992,6 +993,9 @@ export function ChannelScreen({ onViewActivity={handleOpenAgentSession} relayUrl={activeCommunity?.relayUrl} /> + ); diff --git a/desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx b/desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx new file mode 100644 index 0000000000..96a0764ff9 --- /dev/null +++ b/desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx @@ -0,0 +1,75 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import { useFeatureToggle } from "@/shared/features"; +import { Switch } from "@/shared/ui/switch"; + +export function MembershipActivityAvatarDebugToggle({ + channelType, +}: { + channelType: string | null | undefined; +}) { + const [host, setHost] = React.useState(null); + const [showAvatarStacks, setShowAvatarStacks] = useFeatureToggle( + "membershipActivityAvatarStacks", + ); + const [useInlineAvatarLayout, setUseInlineAvatarLayout] = useFeatureToggle( + "membershipActivityAvatarInlineLayout", + ); + + React.useLayoutEffect(() => { + setHost( + document.querySelector("[data-testid='app-top-chrome']"), + ); + }, []); + + if (channelType === undefined || channelType === "forum" || !host) + return null; + + return createPortal( +
+ Activity avatars + + +
+ + +
+
, + host, + ); +} diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index f17a53c661..1fe6f66cd5 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -106,11 +106,11 @@ test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => assert.match(String(style.containIntrinsicSize), /^auto \d+px$/); }); -test("timelineRowReserveStyle: divider is short fixed height", () => { +test("timelineRowReserveStyle: divider reserves its visual breathing room", () => { const style = timelineRowReserveStyle({ kind: "day-divider", key: "k", headingTimestamp: 0, }); - assert.equal(style.containIntrinsicSize, "auto 32px"); + assert.equal(style.containIntrinsicSize, "auto 56px"); }); diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index acefae95d4..196aabbec4 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -156,7 +156,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 DIVIDER_HEIGHT = 56; const SYSTEM_GROUP_HEIGHT = 80; /** diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs index eeed9d543c..417685d47e 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.test.mjs +++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs @@ -2,10 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + addedByActionPrefix, describeChannelTextFieldChange, toInlineName, } from "./systemEventCopy.ts"; +test("an add to the reader uses passive wording", () => { + assert.equal(addedByActionPrefix(true), "were added by"); + assert.equal(addedByActionPrefix(false), "added by"); +}); + test("a set topic is quoted verbatim", () => { assert.equal( describeChannelTextFieldChange("topic", "Release planning"), diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts index bae6abb09b..99f7316165 100644 --- a/desktop/src/features/messages/lib/systemEventCopy.ts +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -14,6 +14,15 @@ const CLOSE_QUOTE = "”"; export type ChannelTextField = "topic" | "purpose"; +/** + * The reader is the recipient of an add, while every other member is the + * subject of one. Keep that distinction in the caption: "You were added by" + * rather than the ungrammatical "You added by". + */ +export function addedByActionPrefix(isCurrentUser: boolean): string { + return isCurrentUser ? "were added by" : "added by"; +} + /** * Caption for a channel topic or purpose change. * diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 4677fe7b49..83af860407 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -48,6 +48,15 @@ function memberJoinedEntry({ createdAt, id, target }) { return memberAddedEntry({ actor: target, createdAt, id, target }); } +function memberLeftEntry({ createdAt, id, target }) { + return entry({ + id, + createdAt, + kind: KIND_SYSTEM_MESSAGE, + body: JSON.stringify({ type: "member_left", actor: target }), + }); +} + function kinds(items) { return items.map((item) => item.kind); } @@ -103,12 +112,12 @@ 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", () => { +test("buildTimelineItems: contiguous member additions by one actor group", () => { 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 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_600 }), ]; const { items } = buildTimelineItems(entries, null); @@ -121,7 +130,7 @@ test("buildTimelineItems: member additions by one actor group within five minute assert.equal(group?.key, "c"); }); -test("buildTimelineItems: self-joins group across different members within five minutes", () => { +test("buildTimelineItems: contiguous self-joins group across different members", () => { const start = dayAt(2026, 6, 14); const entries = [ memberJoinedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -133,7 +142,7 @@ test("buildTimelineItems: self-joins group across different members within five memberJoinedEntry({ id: "c", target: "target-c", - createdAt: start + 300, + createdAt: start + 3_600, }), ]; @@ -149,9 +158,9 @@ test("buildTimelineItems: self-joins group across different members within five test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => { const start = dayAt(2026, 6, 14); const loaded = [ - memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }), - memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 360 }), - entry({ id: "message", createdAt: start + 600 }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 3_500 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_601 }), + entry({ id: "message", createdAt: start + 3_700 }), ]; const prepended = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -164,23 +173,28 @@ test("buildTimelineItems: prepending membership history preserves the loaded suf const prependedKeys = prependedItems.slice(1).map((item) => item.key); assert.deepEqual(loadedKeys, ["c", "message"]); - assert.deepEqual(prependedKeys, ["a", "c", "message"]); + assert.deepEqual(prependedKeys, ["c", "message"]); assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys); }); -test("buildTimelineItems: member-add window is fixed from the newest addition", () => { +test("buildTimelineItems: contiguous member additions extend a group outside one hour", () => { 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 }), + memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 3_599 }), + memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 3_601 }), ]; const { items } = buildTimelineItems(entries, null); - assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]); + 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: actor changes and intervening rows break member-add groups", () => { +test("buildTimelineItems: arrivals share a group but intervening rows break it", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -208,14 +222,28 @@ test("buildTimelineItems: actor changes and intervening rows break member-add gr const { items } = buildTimelineItems(entries, null); assert.deepEqual(kinds(items), [ "day-divider", - "system", - "system", + "system-group", "message", - "system", - "system", + "system-group", ]); }); +test("buildTimelineItems: a member joining then leaving is one lifecycle group", () => { + const start = dayAt(2026, 6, 14); + const entries = [ + memberJoinedEntry({ id: "joined", target: "member-a", createdAt: start }), + 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", "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) }), diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 72b83f0a07..1a931022c0 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -62,11 +62,8 @@ 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"; + mode: "arrival" | "departure"; target: string; }; @@ -81,21 +78,19 @@ function parseMembershipChangePayload( actor?: unknown; target?: unknown; }; + if (payload.type === "member_left" && typeof payload.actor === "string") { + const target = payload.actor.trim().toLowerCase(); + return target ? { mode: "departure", target } : null; + } 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 }; + return target ? { mode: "arrival", target } : null; } catch { return null; } @@ -106,8 +101,10 @@ function membershipChangesCanGroup( second: MembershipChangePayload, ): boolean { return ( - first.mode === second.mode && - (first.mode === "joined" || first.actor === second.actor) + (first.mode === "arrival" && second.mode === "arrival") || + (first.mode === "arrival" && + second.mode === "departure" && + first.target === second.target) ); } @@ -116,6 +113,11 @@ function membershipChangesCanGroup( * history cannot repartition the rows that are already loaded. Their key is * likewise the newest entry's key: extending the oldest visible group changes * its contents, but not its identity or the virtual list's existing key suffix. + * + * Compatible membership activities stay together while they are contiguous. + * Arrivals (self-joins and additions) share one summary; an arrival immediately + * followed by that member leaving becomes a single lifecycle summary. That + * deliberately lets a contiguous activity run extend beyond its original hour. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], @@ -139,9 +141,7 @@ function buildMembershipGroups( barrierIndexes.has(start) || !candidatePayload || !membershipChangesCanGroup(candidatePayload, newestPayload) || - newestEntry.message.createdAt < candidate.message.createdAt || - newestEntry.message.createdAt - candidate.message.createdAt > - MEMBERSHIP_GROUP_WINDOW_SECONDS + newestEntry.message.createdAt < candidate.message.createdAt ) { break; } diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs index fbfad321e2..daae41a786 100644 --- a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs @@ -221,7 +221,7 @@ test("virtualized rows preserve their heterogeneous height estimates", () => { ); const estimates = items.map(estimateVirtualizedTimelineItemHeight); - assert.equal(estimates[0], 32); + assert.equal(estimates[0], 56); assert.ok(estimates[2] > estimates[1] + 200); assert.equal(estimates.at(-1), 96); }); diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts index e9195128c6..1ab68af181 100644 --- a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts +++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts @@ -32,7 +32,7 @@ export function estimateVirtualizedTimelineItemHeight( ): number { if (item.kind === "bottom-spacer") return 96; if (item.kind === "leading-content") return 60; - if (item.kind === "day-divider") return 32; + if (item.kind === "day-divider") return 56; return estimateTimelineItemHeight(item.item); } diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index 8dff4b7a06..72a396d502 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -1,9 +1,27 @@ -export function DayDivider({ label }: { label: string }) { +import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; + +export function DayDivider({ + label, + sticky = true, + testId = "message-timeline-day-divider", +}: { + label: string; + sticky?: boolean; + testId?: string; +}) { return (

diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 40e235d2d1..b3de6c8863 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -689,8 +689,8 @@ const MessageTimelineBase = React.forwardRef< {showUnreadPill ? (

diff --git a/desktop/src/features/messages/ui/SystemMessageAvatars.tsx b/desktop/src/features/messages/ui/SystemMessageAvatars.tsx new file mode 100644 index 0000000000..e3792259b5 --- /dev/null +++ b/desktop/src/features/messages/ui/SystemMessageAvatars.tsx @@ -0,0 +1,196 @@ +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const MAX_MEMBERSHIP_AVATARS = 5; + +function resolveAvatarUrl( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): string | null { + if (!pubkey || !profiles) return null; + return profiles[pubkey.toLowerCase()]?.avatarUrl ?? null; +} + +function isKnownAgentPubkey( + pubkey: string | undefined, + profiles: UserProfileLookup | undefined, + personaLookup?: Map, + agentPubkeys?: ReadonlySet, +) { + if (!pubkey) return false; + const normalizedPubkey = normalizePubkey(pubkey); + return ( + agentPubkeys?.has(normalizedPubkey) === true || + profiles?.[normalizedPubkey]?.isAgent === true || + personaLookup?.has(normalizedPubkey) === true + ); +} + +export function SystemMessageAvatar({ + actorPubkey, + agentPubkeys, + currentPubkey, + personaLookup, + profiles, + targetPubkey, +}: { + actorPubkey: string | undefined; + agentPubkeys?: ReadonlySet; + currentPubkey: string | undefined; + personaLookup?: Map; + profiles: UserProfileLookup | undefined; + targetPubkey: string | undefined; +}) { + const hasActorAndTarget = + actorPubkey && targetPubkey && actorPubkey !== targetPubkey; + const actorLabel = actorPubkey + ? resolveUserLabel({ + pubkey: actorPubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }) + : "Someone"; + const singlePubkey = actorPubkey ?? targetPubkey; + + if (!hasActorAndTarget) { + const isSingleAgent = isKnownAgentPubkey( + singlePubkey, + profiles, + personaLookup, + agentPubkeys, + ); + const avatar = ( + + ); + if (singlePubkey) { + return ( + + + + ); + } + return avatar; + } + + const isActorAgent = isKnownAgentPubkey( + actorPubkey, + profiles, + personaLookup, + agentPubkeys, + ); + const targetLabel = resolveUserLabel({ + pubkey: targetPubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + const dualAvatar = ( +
+ + +
+ ); + return ( + + + + ); +} + +export function MembershipAvatarStack({ + currentPubkey, + profiles, + pubkeys, +}: { + currentPubkey: string | undefined; + profiles: UserProfileLookup | undefined; + pubkeys: readonly string[]; +}) { + const visiblePubkeys = pubkeys.slice(0, MAX_MEMBERSHIP_AVATARS); + if (visiblePubkeys.length === 0) return null; + return ( +
+ {visiblePubkeys.map((pubkey, index) => { + const label = resolveUserLabel({ + pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + }); + return ( +
0 && "-ml-1")} + data-testid="system-message-avatar" + key={pubkey} + style={{ zIndex: index + 1 }} + > + + + +
+ ); + })} +
+ ); +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index c4637d2823..f4060342e5 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -15,6 +15,7 @@ import { } from "@/features/profile/lib/identity"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -29,12 +30,17 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { + addedByActionPrefix, describeChannelTextFieldChange, toInlineName, } from "../lib/systemEventCopy"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { + MembershipAvatarStack, + SystemMessageAvatar, +} from "./SystemMessageAvatars"; const SYSTEM_ACTION_BUTTON_CLASS = "h-6 w-6 rounded-full p-0"; const SYSTEM_ACTION_ICON_CLASS = "!h-4 !w-4"; @@ -77,34 +83,29 @@ function buildGroupedMembershipPayload( 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 joinedThenLeft = buildJoinedThenLeftPayload(payloads); + if (joinedThenLeft) return joinedThenLeft; - const targets: string[] = []; - for (const payload of payloads) { + 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 || - (isSelfJoinGroup - ? payloadActor !== payloadTarget - : payloadActor !== actor || payloadActor === payloadTarget) - ) { + if (payload?.type !== "member_joined" || !payloadActor || !payloadTarget) { return null; } - targets.push(payloadTarget); - } + 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 { @@ -114,6 +115,19 @@ function buildGroupedMembershipPayload( }; } + const actor = membershipArrivals[0].actor; + const isSameAdderGroup = membershipArrivals.every( + ({ actor: candidateActor, target }) => + candidateActor === actor && candidateActor !== target, + ); + if (!isSameAdderGroup) { + return { + type: "members_joined", + target: targets[0], + targets, + }; + } + return { type: "members_added", actor, @@ -122,6 +136,30 @@ function buildGroupedMembershipPayload( }; } +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" || + !arrivalTarget || + arrivalTarget !== departureActor + ) { + return null; + } + + return { type: "member_joined_then_left", target: arrival.target }; +} + function aggregateGroupedReactions( messages: readonly TimelineMessage[], ): TimelineReaction[] { @@ -277,116 +315,17 @@ function ProfileName({ ); } -function SystemMessageAvatar({ - actorPubkey, - agentPubkeys, - currentPubkey, - personaLookup, - profiles, - targetPubkey, -}: { - actorPubkey: string | undefined; - agentPubkeys?: ReadonlySet; - currentPubkey: string | undefined; - personaLookup?: Map; - profiles: UserProfileLookup | undefined; - targetPubkey: string | undefined; -}) { - const hasActorAndTarget = - actorPubkey && targetPubkey && actorPubkey !== targetPubkey; - const actorLabel = actorPubkey - ? resolveUserLabel({ - pubkey: actorPubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }) - : "Someone"; - - const singlePubkey = actorPubkey ?? targetPubkey; - - if (!hasActorAndTarget) { - const isSingleAgent = isKnownAgentPubkey( - singlePubkey, - profiles, - personaLookup, - agentPubkeys, - ); - const avatar = ( - - ); - - if (singlePubkey) { - return ( - - - - ); - } - - return avatar; - } - - const isActorAgent = isKnownAgentPubkey( - actorPubkey, - profiles, - personaLookup, - agentPubkeys, - ); - const targetLabel = resolveUserLabel({ - pubkey: targetPubkey, - currentPubkey, - profiles, - preferResolvedSelfLabel: true, - }); - - const dualAvatar = ( -
- - -
- ); - - return ( - - - - ); +function membershipActivityPubkeys(payload: SystemMessagePayload): string[] { + const pubkeys = + payload.type === "members_added" || payload.type === "members_joined" + ? (payload.targets ?? []) + : payload.type === "member_removed" + ? [payload.target ?? payload.actor] + : [payload.target ?? payload.actor]; + + return [ + ...new Set(pubkeys.filter((pubkey): pubkey is string => Boolean(pubkey))), + ]; } function MembershipPersonName({ @@ -508,6 +447,10 @@ function describeSystemEvent( personaLookup?: Map, agentPubkeys?: ReadonlySet, ): SystemMessageDescription | null { + const isTargetCurrentUser = + currentPubkey !== undefined && + payload.target !== undefined && + normalizePubkey(payload.target) === normalizePubkey(currentPubkey); const isTargetAgent = isKnownAgentPubkey( payload.target, profiles, @@ -554,7 +497,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -590,6 +533,12 @@ function describeSystemEvent( ), }; + case "member_joined_then_left": + if (!payload.target) return null; + return { + title: membershipTitle, + action: "joined, then left the channel", + }; case "member_joined": { if (!payload.actor || !payload.target) return null; if (normalizePubkey(payload.actor) === normalizePubkey(payload.target)) { @@ -602,7 +551,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -693,6 +642,12 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ remove: boolean, ) => Promise; }) { + const showMembershipActivityAvatarStacks = useFeatureEnabled( + "membershipActivityAvatarStacks", + ); + const useInlineMembershipActivityAvatarLayout = useFeatureEnabled( + "membershipActivityAvatarInlineLayout", + ); const sourceMessages = React.useMemo( () => groupedMessages ?? [message], [groupedMessages, message], @@ -766,6 +721,14 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ payload.type === "member_joined" || payload.type === "members_added" || payload.type === "members_joined"; + const isMembershipActivity = + isMembershipArrival || + payload.type === "member_joined_then_left" || + payload.type === "member_left" || + payload.type === "member_removed"; + const membershipPubkeys = isMembershipActivity + ? membershipActivityPubkeys(payload) + : []; const displayedIdentityPubkey = isMembershipArrival ? payload.target : payload.actor; @@ -799,140 +762,192 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ (reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser, ); - return ( + const reactionsContent = ( +
+ { + setBadgeBurstEmoji((current) => (current === emoji ? null : current)); + }} + onSelect={(emoji) => { + void handleReactionSelect(emoji); + }} + /> + {reactionErrorMessage ? ( +

+ {reactionErrorMessage} +

+ ) : null} +
+ ); + + const reactionPicker = canToggleReactions ? (
-
- -
+ - - - {description.title} - - {displayedIdentityIsAgent ? ( - + + + + + + + React + + + {reactionErrorMessage ? ( +
+

+ {reactionErrorMessage} +

+
) : null} - -
-

- {description.action} -

-
- { - setBadgeBurstEmoji((current) => - current === emoji ? null : current, - ); - }} - onSelect={(emoji) => { - void handleReactionSelect(emoji); + { + if ( + !reactionPending && + wouldAddReaction(value) && + isPositiveEmojiParticle(value) + ) { + setBadgeBurstEmoji(value); + } + void handleReactionSelect(value) + .then(() => { + recordQuickReactionEmoji(value); + }) + .catch(() => {}) + .finally(() => { + setIsReactionPickerOpen(false); + }); }} /> - {reactionErrorMessage ? ( -

- {reactionErrorMessage} -

- ) : null} -
-
-
- {canToggleReactions ? ( -
-
- - - - - - - - React - - - {reactionErrorMessage ? ( -
-

- {reactionErrorMessage} -

-
- ) : null} - { - if ( - !reactionPending && - wouldAddReaction(value) && - isPositiveEmojiParticle(value) - ) { - setBadgeBurstEmoji(value); - } - void handleReactionSelect(value) - .then(() => { - recordQuickReactionEmoji(value); - }) - .catch(() => {}) - .finally(() => { - setIsReactionPickerOpen(false); - }); - }} - /> -
-
+ + +
+
+ ) : null; + + return ( +
+ {isMembershipActivity ? ( +
+ {showMembershipActivityAvatarStacks && + useInlineMembershipActivityAvatarLayout ? ( +
+
+ +

+ {description.title} {description.action} +

- ) : null} + ) : ( + <> + {showMembershipActivityAvatarStacks ? ( +
+ +
+ ) : null} +
+

+ {description.title} {description.action} +

+
+ + )} +
{reactionsContent}
+ ) : ( +
+ +
+ + + {description.title} + + {displayedIdentityIsAgent ? ( + + ) : null} + + +

+ {description.action} +

+ {reactionsContent} +
+
+ )} +
+ {reactionPicker}
); diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 9c5b143dbc..b07f195cab 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -3,7 +3,6 @@ import { VList } from "virtua"; import type { VListHandle } from "virtua"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; -import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; import { buildTimelineDayGroups, buildTimelineItems, @@ -29,10 +28,12 @@ import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManag import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; +import { TimelineRowShell } from "./TimelineRowShell"; import { UnreadDivider } from "./UnreadDivider"; import { useTimelineRetention } from "./useTimelineRetention"; import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; @@ -345,7 +346,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ "relative flex flex-col", !hideDayDividers && group.headingTimestamp !== null && - "before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']", + "before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']", )} data-day-label={ group.headingTimestamp === null @@ -437,6 +438,8 @@ function VirtualizedTimelineRows({ typeof window === "undefined" ? 1_000 : window.innerHeight, ); const hasInitialPositionedRef = React.useRef(false); + const pinnedDayLabelRef = React.useRef(null); + const pinnedDayTranslateYRef = React.useRef(0); const estimateCallCountRef = React.useRef(0); const estimateItemSize = React.useCallback( (item: VirtualizedTimelineItem) => { @@ -462,6 +465,17 @@ function VirtualizedTimelineRows({ [dayGroups, hideDayDividers, historyExhausted, leadingContent], ); const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); + const dayDividerItems = React.useMemo( + () => + items.flatMap((item, index) => + item.kind === "day-divider" ? [{ index, item }] : [], + ), + [items], + ); + const [pinnedDay, setPinnedDay] = React.useState<{ + label: string | null; + incomingLabel: string | null; + }>({ label: null, incomingLabel: null }); itemsLengthRef.current = items.length; const previousKeysRef = React.useRef([]); const [prependShiftEpoch, clearPrependShift] = React.useReducer( @@ -475,6 +489,128 @@ function VirtualizedTimelineRows({ cancelBottomSettle, ); + const updatePinnedDayLabel = React.useCallback( + (offset: number) => { + const list = listRef.current; + const scroller = hostRef.current?.firstElementChild; + const pinnedLabel = pinnedDayLabelRef.current; + if (!list || !(scroller instanceof HTMLDivElement) || !pinnedLabel) { + return; + } + + const pinnedTop = + pinnedLabel.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + pinnedDayTranslateYRef.current; + const [pinnedPill, incomingPinnedPill] = + pinnedLabel.querySelectorAll("p"); + const pinnedPillHeight = pinnedPill?.offsetHeight ?? 0; + if (pinnedPillHeight === 0) return; + const renderedDividerPillTop = ( + divider: (typeof dayDividerItems)[number], + ) => { + const label = formatDayHeading(divider.item.headingTimestamp); + const source = [ + ...scroller.querySelectorAll( + '[data-testid="message-timeline-day-divider"]', + ), + ].find((element) => element.dataset.dayLabel === label); + const pill = source?.querySelector("p"); + return pill + ? pill.getBoundingClientRect().top - + scroller.getBoundingClientRect().top + : null; + }; + const sourcePills = [ + ...scroller.querySelectorAll( + '[data-testid="message-timeline-day-divider"] p', + ), + ]; + // Source dividers are normally visible in the feed. Only hide the one + // that physically overlaps the floating chip at the handoff point. + for (const pill of sourcePills) { + pill.style.removeProperty("visibility"); + } + + let activeDividerIndex = -1; + for (const [index, divider] of dayDividerItems.entries()) { + if (list.getItemOffset(divider.index) > offset + pinnedTop) break; + activeDividerIndex = index; + } + const candidateDivider = dayDividerItems[activeDividerIndex]; + // Retain the previous date while the next in-flow divider is still + // above the sticky slot. This avoids changing the label before the + // moving chip reaches its handoff point. + if ( + activeDividerIndex > 0 && + candidateDivider && + (renderedDividerPillTop(candidateDivider) ?? -Infinity) > pinnedTop + ) { + activeDividerIndex -= 1; + } + const activeDivider = dayDividerItems[activeDividerIndex]; + const nextDivider = dayDividerItems[activeDividerIndex + 1]; + const nextDividerTop = nextDivider + ? (renderedDividerPillTop(nextDivider) ?? + list.getItemOffset(nextDivider.index) - offset) + : null; + const nextTranslateY = + nextDividerTop === null + ? 0 + : Math.max( + -pinnedPillHeight, + Math.min(0, nextDividerTop - pinnedTop - pinnedPillHeight), + ); + if (pinnedDayTranslateYRef.current !== nextTranslateY) { + pinnedDayTranslateYRef.current = nextTranslateY; + pinnedLabel.style.transform = `translateY(${nextTranslateY}px)`; + } + const nextLabel = activeDivider + ? formatDayHeading(activeDivider.item.headingTimestamp) + : null; + const incomingLabel = + nextDivider && nextTranslateY < 0 + ? formatDayHeading(nextDivider.item.headingTimestamp) + : null; + const activeSourcePill = sourcePills.find( + (pill) => pill.parentElement?.dataset.dayLabel === nextLabel, + ); + if (activeSourcePill) { + const sourceTop = + activeSourcePill.getBoundingClientRect().top - + scroller.getBoundingClientRect().top; + const overlayTop = pinnedTop; + const sourceBottom = sourceTop + activeSourcePill.offsetHeight; + const overlayBottom = overlayTop + pinnedPillHeight; + if (sourceBottom > overlayTop && sourceTop < overlayBottom) { + activeSourcePill.style.visibility = "hidden"; + } + } + const incomingSourcePill = sourcePills.find( + (pill) => pill.parentElement?.dataset.dayLabel === incomingLabel, + ); + if (incomingSourcePill) { + incomingSourcePill.style.visibility = "hidden"; + } + if (pinnedPill) { + pinnedPill.textContent = nextLabel ?? ""; + pinnedPill.style.visibility = nextLabel ? "visible" : "hidden"; + } + if (incomingPinnedPill) { + incomingPinnedPill.textContent = incomingLabel ?? ""; + incomingPinnedPill.style.visibility = incomingLabel + ? "visible" + : "hidden"; + } + setPinnedDay((current) => + current.label === nextLabel && current.incomingLabel === incomingLabel + ? current + : { label: nextLabel, incomingLabel }, + ); + }, + [dayDividerItems], + ); + React.useEffect( () => () => { cancelBottomSettle(); @@ -523,6 +659,10 @@ function VirtualizedTimelineRows({ return () => onVirtualizerScrollerChange?.(null); }, [onVirtualizerScrollerChange]); + React.useLayoutEffect(() => { + updatePinnedDayLabel(listRef.current?.scrollOffset ?? 0); + }, [updatePinnedDayLabel]); + React.useLayoutEffect(() => { if (!onVirtualizerApiChange) return; const api: TimelineVirtualizerApi = { @@ -578,6 +718,7 @@ function VirtualizedTimelineRows({ // channel above its newest message. The settle hook's wheel, pointer, // touch, and key listeners are the authoritative user-interaction gate. onAtBottomStateChange?.(distanceFromBottom <= 32); + updatePinnedDayLabel(offset); if (offset <= 200) { // Layout scrolls near the top must not poison the reader's next input. armUpwardMomentum(onStartReached?.() ?? false); @@ -588,11 +729,12 @@ function VirtualizedTimelineRows({ onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged, + updatePinnedDayLabel, ], ); return ( -
+
- -
+
); } @@ -653,26 +787,32 @@ function VirtualizedTimelineRows({ }}
-
- ); -} - -function TimelineRowShell({ - children, - item, - useContentVisibility = true, -}: { - children: React.ReactNode; - item: TimelineNonDayItem; - useContentVisibility?: boolean; -}) { - return ( -
- {children} +
+
+ +
+
+ + +
+
); } diff --git a/desktop/src/features/messages/ui/TimelineRowShell.tsx b/desktop/src/features/messages/ui/TimelineRowShell.tsx new file mode 100644 index 0000000000..f69f461c81 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineRowShell.tsx @@ -0,0 +1,27 @@ +import type * as React from "react"; +import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; +import { + getTimelineItemKey, + type TimelineNonDayItem, +} from "@/features/messages/lib/timelineItems"; +import { cn } from "@/shared/lib/cn"; + +export function TimelineRowShell({ + children, + item, + useContentVisibility = true, +}: { + children: React.ReactNode; + item: TimelineNonDayItem; + useContentVisibility?: boolean; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index da51ab1b88..d3e0e34dc6 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -593,6 +593,11 @@ export function UserProfilePopover({ data-testid="user-profile-popover" onMouseEnter={handleContentMouseEnter} onMouseLeave={handleMouseLeave} + // This is a hover card: moving focus into its first button on open + // makes the profile header look keyboard-selected before the user has + // interacted with it. Keep focus on the trigger; Tab still enters the + // card and shows its normal focus treatment when needed. + onOpenAutoFocus={(event) => event.preventDefault()} side="top" sideOffset={8} > diff --git a/desktop/src/shared/layout/chromeLayout.ts b/desktop/src/shared/layout/chromeLayout.ts index 2c9fe627f7..7a28e0a90a 100644 --- a/desktop/src/shared/layout/chromeLayout.ts +++ b/desktop/src/shared/layout/chromeLayout.ts @@ -53,6 +53,9 @@ export const channelChrome = { contentPadding: "pt-(--buzz-channel-content-top-padding,5.75rem)", /** Absolute/fixed top offset below the measured channel header chrome. */ top: "top-(--buzz-channel-content-top-padding,5.75rem)", + /** Sticky timeline controls sit slightly below the channel navigation. */ + stickyTimelineTop: + "top-[calc(var(--buzz-channel-content-top-padding,5.75rem)+0.5rem)]", /** Height matching the measured channel header chrome. */ headerHeight: "h-(--buzz-channel-content-top-padding,5.75rem)", /** Negative margin for overlaid channel chrome that should not affect flow. */ diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index e9c5b42638..1ab656412b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1826,59 +1826,80 @@ test("channel date divider keeps the date sticky while the separator rule scroll const timeline = page.getByTestId("message-timeline"); await timeline.evaluate((element) => { - const firstGroup = element.querySelector( - '[data-testid="message-timeline-day-group"]', - ); - if (!firstGroup) { - throw new Error("missing first day group"); - } - const groupRect = firstGroup.getBoundingClientRect(); - const stickyTop = Number.parseFloat( - getComputedStyle( - firstGroup.querySelector( - '[data-testid="message-timeline-day-divider"]', - ) ?? firstGroup, - ).top, - ); - element.scrollTop += - groupRect.top - (element.getBoundingClientRect().top + stickyTop - 32); + element.scrollTop = element.scrollHeight * 0.2; element.dispatchEvent(new Event("scroll", { bubbles: true })); }); - await page.waitForTimeout(50); + + const [headerBox, stickyPillBox] = await Promise.all([ + page.getByTestId("chat-header").boundingBox(), + page + .getByTestId("message-timeline-sticky-day-divider-content") + .locator("p") + .first() + .boundingBox(), + ]); + if (!headerBox || !stickyPillBox) { + throw new Error("missing channel header or sticky day divider"); + } + expect( + Math.abs(stickyPillBox.y - (headerBox.y + headerBox.height) - 8), + ).toBeLessThanOrEqual(1); + await expect( + page.getByTestId("message-timeline-sticky-day-divider"), + ).toHaveCSS("opacity", "1"); + await expect( + page.getByTestId("message-timeline-day-divider").last().locator("p"), + ).toHaveCSS("visibility", "visible"); const metrics = await timeline.evaluate((element) => { - const firstGroup = element.querySelector( - '[data-testid="message-timeline-day-group"]', + const pinnedDivider = element.parentElement?.querySelector( + '[data-testid="message-timeline-sticky-day-divider"]', ); - const firstDivider = firstGroup?.querySelector( - '[data-testid="message-timeline-day-divider"]', + const pinnedPill = pinnedDivider?.querySelector( + '[data-testid="message-timeline-sticky-day-divider-content"] p', ); - const firstDividerPill = firstDivider?.querySelector("p"); - if (!firstGroup || !firstDivider || !firstDividerPill) { - throw new Error("missing day group or divider"); + if (!pinnedDivider || !pinnedPill) { + throw new Error("missing sticky day divider"); } - const groupRect = firstGroup.getBoundingClientRect(); - const dividerRect = firstDivider.getBoundingClientRect(); - const groupBefore = getComputedStyle(firstGroup, "::before"); - const dividerBefore = getComputedStyle(firstDivider, "::before"); - return { - dividerBeforeContent: dividerBefore.content, - dividerPillBackground: getComputedStyle(firstDividerPill).backgroundColor, - dividerPillShadow: getComputedStyle(firstDividerPill).boxShadow, - dividerPosition: getComputedStyle(firstDivider).position, - dividerTop: dividerRect.top, - dividerZIndex: getComputedStyle(firstDivider).zIndex, - groupBeforeContent: groupBefore.content, - groupBeforePosition: groupBefore.position, - groupTop: groupRect.top, - ruleTop: groupRect.top + Number.parseFloat(groupBefore.top), + dividerPillBackground: getComputedStyle(pinnedPill).backgroundColor, + dividerPillShadow: getComputedStyle(pinnedPill).boxShadow, + dividerZIndex: getComputedStyle(pinnedDivider).zIndex, }; }); - expect(metrics.dividerPosition).toBe("sticky"); expect(Number.parseInt(metrics.dividerZIndex, 10)).toBeGreaterThan(10); + await expect( + page.getByTestId("message-timeline-sticky-day-divider"), + ).toHaveCSS("overflow", "visible"); + + const dividerAlignment = await timeline.evaluate((element) => { + const group = [ + ...element.querySelectorAll( + '[data-testid="message-timeline-day-group"]', + ), + ].find((candidate) => { + const pill = candidate.querySelector("p"); + return pill && getComputedStyle(pill).visibility === "visible"; + }); + const pill = group?.querySelector("p"); + if (!group || !pill) throw new Error("missing visible day divider"); + + const rule = getComputedStyle(group, "::before"); + const groupBox = group.getBoundingClientRect(); + const pillBox = pill.getBoundingClientRect(); + return { + chipCenter: pillBox.top + pillBox.height / 2, + ruleCenter: + groupBox.top + + Number.parseFloat(rule.top) + + Number.parseFloat(rule.height) / 2, + }; + }); + expect( + Math.abs(dividerAlignment.chipCenter - dividerAlignment.ruleCenter), + ).toBeLessThanOrEqual(0.5); await expect .poll(async () => { const headerZIndex = await page @@ -1931,11 +1952,42 @@ test("channel date divider keeps the date sticky while the separator rule scroll expect(metrics.dividerPillBackground).not.toBe("rgba(0, 0, 0, 0)"); expect(metrics.dividerPillBackground).not.toBe("transparent"); expect(metrics.dividerPillShadow).toBe("none"); - expect(metrics.dividerBeforeContent).toBe("none"); - expect(metrics.groupBeforePosition).toBe("absolute"); - expect(metrics.groupBeforeContent).not.toBe("none"); - expect(metrics.groupTop).toBeLessThan(metrics.dividerTop - 8); - expect(metrics.ruleTop).toBeLessThan(metrics.dividerTop - 8); +}); + +test("places the membership activity avatar debug toggle above the channel surface", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-engineering").click(); + const toggle = page.getByTestId("membership-activity-avatar-debug-toggle"); + await expect(toggle).toBeVisible(); + const layoutToggle = page.getByTestId( + "membership-activity-avatar-layout-toggle", + ); + await expect( + layoutToggle.getByRole("button", { name: "Top" }), + ).toHaveAttribute("aria-pressed", "true"); + await layoutToggle.getByRole("button", { name: "Inline" }).click(); + await expect( + layoutToggle.getByRole("button", { name: "Inline" }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + await toggle.evaluate( + (element) => element.parentElement?.dataset.testid === "app-top-chrome", + ), + ).toBe(true); + + const [toggleBox, channelSurfaceBox] = await Promise.all([ + toggle.boundingBox(), + page.locator("[data-buzz-content-surface]").boundingBox(), + ]); + if (!toggleBox || !channelSurfaceBox) { + throw new Error("missing debug toggle or channel surface"); + } + expect(toggleBox.y + toggleBox.height).toBeLessThanOrEqual( + channelSurfaceBox.y, + ); }); test("shows and clears activity indicators for active channel agents", async ({ diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 5e31235a18..3cef132f9e 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -5,6 +5,7 @@ import { openChannelBrowser, TEST_IDENTITIES, } from "../helpers/bridge"; +import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); @@ -1129,7 +1130,7 @@ test("system add rows use plain names while remove rows retain agent mention sty ).toHaveText("portal"); }); -test("groups member additions and joins with hidden names in the standard tooltip", async ({ +test("groups contiguous arrival activity with hidden names in the standard tooltip", async ({ page, }) => { const actor = { @@ -1178,20 +1179,25 @@ test("groups member additions and joins with hidden names in the standard toolti const groupedRow = page .getByTestId("system-message-row") - .filter({ hasText: "added by Alice Chen" }); + .filter({ hasText: "joined the channel along with" }); for (const visibleName of [ "Erica Chapman", "Peter Griffin", "Marcia Thomas", - "Jordan Lee", ]) { await expect(groupedRow).toContainText(visibleName); } await expect( - groupedRow.locator("p").filter({ hasText: "added by" }), + groupedRow.locator("p").filter({ hasText: "joined the channel" }), ).toContainText( - "added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", + "alice joined the channel along with Erica Chapman, Peter Griffin, Marcia Thomas, and 3 others", ); + const avatarStack = groupedRow.getByTestId("system-message-avatar-stack"); + await expect(avatarStack).toHaveCount(1); + await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(5); + await expect( + groupedRow.locator("p").filter({ hasText: "joined the channel" }), + ).toHaveCSS("text-align", "center"); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); const visibleName = groupedRow.getByText("Peter Griffin", { exact: true }); @@ -1199,7 +1205,7 @@ test("groups member additions and joins with hidden names in the standard toolti await visibleName.hover(); await expect(visibleName).toHaveCSS("text-decoration-line", "underline"); - const othersTrigger = groupedRow.getByRole("button", { name: "2 others" }); + const othersTrigger = groupedRow.getByRole("button", { name: "3 others" }); // Park the pointer off-target first: the previous hover leaves the mouse at a // fixed viewport point, and any later reflow (new rows, scroll-to-bottom, a // different text wrap) can slide this button under it. Without this the @@ -1210,54 +1216,20 @@ test("groups member additions and joins with hidden names in the standard toolti await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline"); const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toContainText("Jordan Lee"); 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 page.mouse.move(0, 0); - await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none"); - await joinedOthersTrigger.hover(); - // Scope to the *open* tooltip: the first row's tooltip stays mounted with - // data-state="closed" while it animates out, so a bare role=tooltip lookup - // matches two elements and trips strict mode. - const joinedTooltip = page.locator( - '[role="tooltip"]:not([data-state="closed"])', - ); - await expect(joinedTooltip).toContainText("Olivia Park"); - await expect(joinedTooltip).toContainText("Sam Rivera"); + await page.evaluate((storageKey) => { + const overrides = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + overrides.membershipActivityAvatarStacks = false; + window.localStorage.setItem(storageKey, JSON.stringify(overrides)); + window.dispatchEvent(new StorageEvent("storage", { key: storageKey })); + }, FEATURE_OVERRIDES_STORAGE_KEY); + await expect(avatarStack).toHaveCount(0); + await expect(groupedRow).toContainText("joined the channel along with"); }); test("system agent profile exposes owned agent actions", async ({ page }) => { @@ -1305,7 +1277,7 @@ test("system agent profile exposes owned agent actions", async ({ page }) => { ); }); -test("system agent avatar exposes owned agent actions", async ({ page }) => { +test("system agent activity avatar stack is decorative", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -1334,15 +1306,49 @@ test("system agent avatar exposes owned agent actions", async ({ page }) => { .getByTestId("system-message-row") .filter({ hasText: "mira" }) .filter({ hasText: "joined the channel" }); - await joinedRow.getByTestId("system-message-avatar").hover(); + const avatarStack = joinedRow.getByTestId("system-message-avatar-stack"); + await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(1); + await expect(avatarStack.locator("button")).toHaveCount(0); +}); - const profilePopover = page.locator( - '[data-testid="user-profile-popover"][data-state="open"]', +test("membership activity folds a member joining then leaving", 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); + + await page.evaluate( + ({ alicePubkey, kind }) => { + const createdAt = Math.floor(Date.now() / 1_000); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ + type: "member_joined", + actor: alicePubkey, + target: alicePubkey, + }), + createdAt, + kind, + }); + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ type: "member_left", actor: alicePubkey }), + createdAt: createdAt + 1, + kind, + }); + }, + { alicePubkey: TEST_IDENTITIES.alice.pubkey, kind: SYSTEM_MESSAGE_KIND }, ); - await expect(profilePopover).toBeVisible(); - await expectOwnedAgentProfileActions( - profilePopover, - PROFILE_ONLY_AGENT_PUBKEY, + await waitForTimelineSettled(page); + const lifecycleRow = page + .getByTestId("system-message-row") + .filter({ hasText: "alice" }) + .filter({ hasText: "joined, then left the channel" }); + await expect(lifecycleRow).toBeVisible(); + await expect(lifecycleRow.getByTestId("system-message-avatar")).toHaveCount( + 1, ); }); diff --git a/desktop/tests/e2e/unread-pill.spec.ts b/desktop/tests/e2e/unread-pill.spec.ts index 88169d9636..07ad69a58f 100644 --- a/desktop/tests/e2e/unread-pill.spec.ts +++ b/desktop/tests/e2e/unread-pill.spec.ts @@ -125,6 +125,26 @@ test.describe("unread pill & divider", () => { const pill = page.getByTestId("message-unread-pill"); await expect(pill).toBeVisible(); await expect(pill).toContainText("20 new messages"); + await expect( + page.getByTestId("message-timeline-sticky-day-divider"), + ).toHaveAttribute("data-day-label", /.+/); + + const { pillTop, stickyDayTop } = await page.evaluate(() => { + const pill = document.querySelector( + '[data-testid="message-unread-pill"]', + ); + const stickyDay = document.querySelector( + '[data-testid="message-timeline-sticky-day-divider"]', + ); + if (!pill || !stickyDay) { + throw new Error("missing top timeline affordance"); + } + return { + pillTop: pill.getBoundingClientRect().top, + stickyDayTop: stickyDay.getBoundingClientRect().top, + }; + }); + expect(Math.abs(pillTop - stickyDayTop)).toBeLessThanOrEqual(1); }); test("02-unread-divider-visible", async ({ page }) => { diff --git a/preview-features.json b/preview-features.json index 388f1c39b0..9cb3344d0d 100644 --- a/preview-features.json +++ b/preview-features.json @@ -30,6 +30,20 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] + }, + { + "id": "membershipActivityAvatarStacks", + "name": "Membership activity avatars", + "description": "Show avatars above grouped channel join and leave activity. Turn this off to compare the text-only layout.", + "defaultEnabled": true, + "platforms": ["desktop"] + }, + { + "id": "membershipActivityAvatarInlineLayout", + "name": "Inline membership activity avatars", + "description": "Place membership activity avatar stacks to the left of their text instead of above it.", + "defaultEnabled": false, + "platforms": ["desktop"] } ] } From 57a7f11ebd758a572c7d90f18840c74085778f0b Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 19:31:23 +0100 Subject: [PATCH 2/8] feat(desktop): finalize inline activity avatars Signed-off-by: kenny lopez --- .../features/channels/ui/ChannelScreen.tsx | 4 - .../MembershipActivityAvatarDebugToggle.tsx | 75 ------------------- .../features/messages/ui/SystemMessageRow.tsx | 49 +++--------- desktop/tests/e2e/channels.spec.ts | 36 --------- desktop/tests/e2e/mentions.spec.ts | 14 +--- preview-features.json | 14 ---- 6 files changed, 13 insertions(+), 179 deletions(-) delete mode 100644 desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 476436d5e0..b235468cd8 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -84,7 +84,6 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -import { MembershipActivityAvatarDebugToggle } from "./MembershipActivityAvatarDebugToggle"; const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ @@ -993,9 +992,6 @@ export function ChannelScreen({ onViewActivity={handleOpenAgentSession} relayUrl={activeCommunity?.relayUrl} /> - ); diff --git a/desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx b/desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx deleted file mode 100644 index 96a0764ff9..0000000000 --- a/desktop/src/features/channels/ui/MembershipActivityAvatarDebugToggle.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react"; -import { createPortal } from "react-dom"; -import { useFeatureToggle } from "@/shared/features"; -import { Switch } from "@/shared/ui/switch"; - -export function MembershipActivityAvatarDebugToggle({ - channelType, -}: { - channelType: string | null | undefined; -}) { - const [host, setHost] = React.useState(null); - const [showAvatarStacks, setShowAvatarStacks] = useFeatureToggle( - "membershipActivityAvatarStacks", - ); - const [useInlineAvatarLayout, setUseInlineAvatarLayout] = useFeatureToggle( - "membershipActivityAvatarInlineLayout", - ); - - React.useLayoutEffect(() => { - setHost( - document.querySelector("[data-testid='app-top-chrome']"), - ); - }, []); - - if (channelType === undefined || channelType === "forum" || !host) - return null; - - return createPortal( -
- Activity avatars - - -
- - -
-
, - host, - ); -} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index f4060342e5..8689b68740 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -15,7 +15,6 @@ import { } from "@/features/profile/lib/identity"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -642,12 +641,6 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ remove: boolean, ) => Promise; }) { - const showMembershipActivityAvatarStacks = useFeatureEnabled( - "membershipActivityAvatarStacks", - ); - const useInlineMembershipActivityAvatarLayout = useFeatureEnabled( - "membershipActivityAvatarInlineLayout", - ); const sourceMessages = React.useMemo( () => groupedMessages ?? [message], [groupedMessages, message], @@ -869,38 +862,18 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ > {isMembershipActivity ? (
- {showMembershipActivityAvatarStacks && - useInlineMembershipActivityAvatarLayout ? ( -
-
- -

- {description.title} {description.action} -

-
+
+
+ +

+ {description.title} {description.action} +

- ) : ( - <> - {showMembershipActivityAvatarStacks ? ( -
- -
- ) : null} -
-

- {description.title} {description.action} -

-
- - )} +
{reactionsContent}
) : ( diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 1ab656412b..e84a613068 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1954,42 +1954,6 @@ test("channel date divider keeps the date sticky while the separator rule scroll expect(metrics.dividerPillShadow).toBe("none"); }); -test("places the membership activity avatar debug toggle above the channel surface", async ({ - page, -}) => { - await page.goto("/"); - - await page.getByTestId("channel-engineering").click(); - const toggle = page.getByTestId("membership-activity-avatar-debug-toggle"); - await expect(toggle).toBeVisible(); - const layoutToggle = page.getByTestId( - "membership-activity-avatar-layout-toggle", - ); - await expect( - layoutToggle.getByRole("button", { name: "Top" }), - ).toHaveAttribute("aria-pressed", "true"); - await layoutToggle.getByRole("button", { name: "Inline" }).click(); - await expect( - layoutToggle.getByRole("button", { name: "Inline" }), - ).toHaveAttribute("aria-pressed", "true"); - expect( - await toggle.evaluate( - (element) => element.parentElement?.dataset.testid === "app-top-chrome", - ), - ).toBe(true); - - const [toggleBox, channelSurfaceBox] = await Promise.all([ - toggle.boundingBox(), - page.locator("[data-buzz-content-surface]").boundingBox(), - ]); - if (!toggleBox || !channelSurfaceBox) { - throw new Error("missing debug toggle or channel surface"); - } - expect(toggleBox.y + toggleBox.height).toBeLessThanOrEqual( - channelSurfaceBox.y, - ); -}); - test("shows and clears activity indicators for active channel agents", async ({ page, }) => { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 3cef132f9e..0428d04200 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -5,7 +5,6 @@ import { openChannelBrowser, TEST_IDENTITIES, } from "../helpers/bridge"; -import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); @@ -1197,7 +1196,7 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(5); await expect( groupedRow.locator("p").filter({ hasText: "joined the channel" }), - ).toHaveCSS("text-align", "center"); + ).toHaveCSS("text-align", "left"); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); const visibleName = groupedRow.getByText("Peter Griffin", { exact: true }); @@ -1220,16 +1219,7 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await expect(tooltip).toContainText("Olivia Park"); await expect(tooltip).toContainText("Sam Rivera"); - await page.evaluate((storageKey) => { - const overrides = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - overrides.membershipActivityAvatarStacks = false; - window.localStorage.setItem(storageKey, JSON.stringify(overrides)); - window.dispatchEvent(new StorageEvent("storage", { key: storageKey })); - }, FEATURE_OVERRIDES_STORAGE_KEY); - await expect(avatarStack).toHaveCount(0); - await expect(groupedRow).toContainText("joined the channel along with"); + await expect(avatarStack.locator("..")).toHaveCSS("align-items", "center"); }); test("system agent profile exposes owned agent actions", async ({ page }) => { diff --git a/preview-features.json b/preview-features.json index 9cb3344d0d..388f1c39b0 100644 --- a/preview-features.json +++ b/preview-features.json @@ -30,20 +30,6 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] - }, - { - "id": "membershipActivityAvatarStacks", - "name": "Membership activity avatars", - "description": "Show avatars above grouped channel join and leave activity. Turn this off to compare the text-only layout.", - "defaultEnabled": true, - "platforms": ["desktop"] - }, - { - "id": "membershipActivityAvatarInlineLayout", - "name": "Inline membership activity avatars", - "description": "Place membership activity avatar stacks to the left of their text instead of above it.", - "defaultEnabled": false, - "platforms": ["desktop"] } ] } From 06dbd57a8f444d8a5b62f2011929a9bf4b112254 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 07:23:03 +0100 Subject: [PATCH 3/8] refactor(desktop): split timeline message rows Signed-off-by: kenny lopez --- .../messages/ui/TimelineMessageList.tsx | 217 +---------------- .../messages/ui/TimelineMessageRow.tsx | 228 ++++++++++++++++++ 2 files changed, 229 insertions(+), 216 deletions(-) create mode 100644 desktop/src/features/messages/ui/TimelineMessageRow.tsx diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index b07f195cab..5c0a548d29 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -17,22 +17,17 @@ import { type VirtualizedTimelineItem, virtualizedItemKey, } from "@/features/messages/lib/virtualizedTimelineItems"; -import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; -import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; import type { TimelineMessage } from "@/features/messages/types"; -import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { DayDivider } from "./DayDivider"; -import { MessageRow } from "./MessageRow"; -import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; -import { SystemMessageRow } from "./SystemMessageRow"; +import { MessageRowItem, SystemRow } from "./TimelineMessageRow"; import { TimelineRowShell } from "./TimelineRowShell"; import { UnreadDivider } from "./UnreadDivider"; import { useTimelineRetention } from "./useTimelineRetention"; @@ -816,213 +811,3 @@ function VirtualizedTimelineRows({
); } - -function SystemRow({ - currentPubkey, - entries, - entry, - footer, - onToggleReaction, - profiles, - ownerProfiles, -}: { - currentPubkey?: string; - entries?: MainTimelineEntry[]; - entry?: MainTimelineEntry; - footer: React.ReactNode; - onToggleReaction?: TimelineMessageListProps["onToggleReaction"]; - profiles?: UserProfileLookup; - ownerProfiles?: 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 ( -
- - {footer} -
- ); -} - -type MessageRowItemProps = Pick< - TimelineMessageListProps, - | "channelId" - | "currentPubkey" - | "followThreadById" - | "highlightedMessageId" - | "huddleMemberPubkeys" - | "huddleMemberPubkeysPending" - | "hideAgentAccessBadges" - | "isFollowingThreadById" - | "onDelete" - | "onEdit" - | "onMarkUnread" - | "onMarkRead" - | "onReply" - | "onOpenThread" - | "onToggleReaction" - | "profiles" - | "searchActiveMessageId" - | "searchMatchingMessageIds" - | "searchQuery" - | "threadUnreadCounts" - | "unfollowThreadById" -> & { - entry: MainTimelineEntry; - footer: React.ReactNode; - isContinuation?: boolean; - isFollowedByContinuation?: boolean; - isUnread?: boolean; - playEntrance?: boolean; - onEntranceComplete?: (messageId: string) => void; - videoReviewContext: ReturnType; -}; - -function MessageRowItem({ - channelId, - currentPubkey, - entry, - followThreadById, - footer, - highlightedMessageId, - huddleMemberPubkeys, - huddleMemberPubkeysPending, - hideAgentAccessBadges, - isContinuation = false, - isFollowedByContinuation = false, - isFollowingThreadById, - isUnread, - playEntrance = false, - onEntranceComplete, - onDelete, - onEdit, - onMarkUnread, - onMarkRead, - onReply, - onOpenThread, - onToggleReaction, - profiles, - searchActiveMessageId, - searchMatchingMessageIds, - searchQuery, - threadUnreadCounts, - unfollowThreadById, - videoReviewContext, -}: MessageRowItemProps) { - const { message, summary } = entry; - const canManage = canManageMessageForCurrentUser( - message, - currentPubkey, - profiles, - ); - const canDelete = canManage && onDelete ? onDelete : undefined; - const canEdit = canManage && onEdit ? onEdit : undefined; - - if (summary && onOpenThread) { - const isHighlighted = message.id === highlightedMessageId; - return ( -
- followThreadById(message.id) : undefined - } - onMarkRead={onMarkRead} - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onReply={onReply} - onUnfollowThread={ - unfollowThreadById - ? () => unfollowThreadById(message.id) - : undefined - } - profiles={profiles} - showDepthGuides={false} - videoReviewContext={videoReviewContext} - /> - - {footer} -
- ); - } - - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - - return ( -
- - {footer} -
- ); -} diff --git a/desktop/src/features/messages/ui/TimelineMessageRow.tsx b/desktop/src/features/messages/ui/TimelineMessageRow.tsx new file mode 100644 index 0000000000..283760fb02 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineMessageRow.tsx @@ -0,0 +1,228 @@ +import * as React from "react"; + +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; +import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext"; +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { MessageRow } from "./MessageRow"; +import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { SystemMessageRow } from "./SystemMessageRow"; + +type ToggleReaction = ( + message: TimelineMessage, + emoji: string, + remove: boolean, +) => Promise; + +type SystemRowProps = { + currentPubkey?: string; + entries?: MainTimelineEntry[]; + entry?: MainTimelineEntry; + footer: React.ReactNode; + onToggleReaction?: ToggleReaction; + profiles?: UserProfileLookup; + ownerProfiles?: UserProfileLookup; +}; + +export function SystemRow({ + currentPubkey, + entries, + entry, + footer, + onToggleReaction, + profiles, + ownerProfiles, +}: SystemRowProps) { + const systemEntries = entries ?? (entry ? [entry] : []); + const firstEntry = systemEntries[0]; + const groupedMessages = React.useMemo( + () => entries?.map((systemEntry) => systemEntry.message), + [entries], + ); + if (!firstEntry) return null; + + return ( +
+ + {footer} +
+ ); +} + +type MessageRowItemProps = { + channelId?: string | null; + currentPubkey?: string; + entry: MainTimelineEntry; + followThreadById?: (rootId: string) => void; + footer: React.ReactNode; + highlightedMessageId?: string | null; + huddleMemberPubkeys?: readonly string[]; + huddleMemberPubkeysPending?: boolean; + hideAgentAccessBadges?: boolean; + isContinuation?: boolean; + isFollowedByContinuation?: boolean; + isFollowingThreadById?: (rootId: string) => boolean; + isUnread?: boolean; + playEntrance?: boolean; + onEntranceComplete?: (messageId: string) => void; + onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; + onMarkRead?: (message: TimelineMessage) => void; + onReply?: (message: TimelineMessage) => void; + onOpenThread?: (message: TimelineMessage) => void; + onToggleReaction?: ToggleReaction; + profiles?: UserProfileLookup; + searchActiveMessageId?: string | null; + searchMatchingMessageIds?: Set; + searchQuery?: string; + threadUnreadCounts?: ReadonlyMap; + unfollowThreadById?: (rootId: string) => void; + videoReviewContext: ReturnType; +}; + +export function MessageRowItem({ + channelId, + currentPubkey, + entry, + followThreadById, + footer, + highlightedMessageId, + huddleMemberPubkeys, + huddleMemberPubkeysPending, + hideAgentAccessBadges, + isContinuation = false, + isFollowedByContinuation = false, + isFollowingThreadById, + isUnread, + playEntrance = false, + onEntranceComplete, + onDelete, + onEdit, + onMarkUnread, + onMarkRead, + onReply, + onOpenThread, + onToggleReaction, + profiles, + searchActiveMessageId, + searchMatchingMessageIds, + searchQuery, + threadUnreadCounts, + unfollowThreadById, + videoReviewContext, +}: MessageRowItemProps) { + const { message, summary } = entry; + const canManage = canManageMessageForCurrentUser( + message, + currentPubkey, + profiles, + ); + const canDelete = canManage && onDelete ? onDelete : undefined; + const canEdit = canManage && onEdit ? onEdit : undefined; + + if (summary && onOpenThread) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ followThreadById(message.id) : undefined + } + onMarkRead={onMarkRead} + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onReply={onReply} + onUnfollowThread={ + unfollowThreadById + ? () => unfollowThreadById(message.id) + : undefined + } + profiles={profiles} + showDepthGuides={false} + videoReviewContext={videoReviewContext} + /> + + {footer} +
+ ); + } + + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + + return ( +
+ + {footer} +
+ ); +} From be5515101c719559f415eabd12c80c1dad507ebf Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 07:53:40 +0100 Subject: [PATCH 4/8] test(desktop): update membership activity expectations Signed-off-by: kenny lopez --- desktop/tests/e2e/mentions.spec.ts | 4 ++-- desktop/tests/e2e/messaging.spec.ts | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 0428d04200..ed025c6b2c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1114,7 +1114,7 @@ test("system add rows use plain names while remove rows retain agent mention sty const addedRow = page .getByTestId("system-message-row") .filter({ hasText: "portal" }) - .filter({ hasText: "added by" }); + .filter({ hasText: "joined the channel" }); const removedRow = page .getByTestId("system-message-row") .filter({ hasText: "removed portal from the channel" }); @@ -1251,7 +1251,7 @@ test("system agent profile exposes owned agent actions", async ({ page }) => { const joinedRow = page .getByTestId("system-message-row") .filter({ hasText: "mira" }) - .filter({ hasText: "added by" }); + .filter({ hasText: "joined the channel" }); const agentName = joinedRow.getByText("mira", { exact: true }); await expect(agentName).toHaveText("mira"); await expect(agentName).not.toHaveAttribute("data-mention"); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index c6f5aefb9b..5d833f7713 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -130,14 +130,6 @@ test("agent owner label identifies the agent and owner", async ({ page }) => { await expect(ownerTreatment.locator(".sr-only")).toHaveText( "Agent managed by", ); - - const joinedRow = page - .getByTestId("system-message-row") - .filter({ hasText: "alice" }) - .filter({ hasText: "joined the channel" }); - await expect(joinedRow.getByTestId("message-agent-owner")).toContainText( - "managed bybob", - ); }); test("send a message and see it in timeline", async ({ page }) => { From 80a097735363fc3c8acf1b1387e0b7d50778518e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 08:10:06 +0100 Subject: [PATCH 5/8] fix(desktop): preserve membership activity semantics Signed-off-by: kenny lopez --- desktop/src/features/messages/lib/timelineItems.ts | 3 ++- desktop/src/features/messages/ui/SystemMessageRow.tsx | 7 ++----- desktop/src/features/messages/ui/TimelineMessageList.tsx | 4 +++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 1a931022c0..a9eae81230 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -141,7 +141,8 @@ function buildMembershipGroups( barrierIndexes.has(start) || !candidatePayload || !membershipChangesCanGroup(candidatePayload, newestPayload) || - newestEntry.message.createdAt < candidate.message.createdAt + newestEntry.message.createdAt < candidate.message.createdAt || + newestEntry.message.createdAt - candidate.message.createdAt > 60 * 60 ) { break; } diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 8689b68740..f94145386b 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -120,11 +120,7 @@ function buildGroupedMembershipPayload( candidateActor === actor && candidateActor !== target, ); if (!isSameAdderGroup) { - return { - type: "members_joined", - target: targets[0], - targets, - }; + return null; } return { @@ -151,6 +147,7 @@ function buildJoinedThenLeftPayload( arrival?.type !== "member_joined" || departure?.type !== "member_left" || !arrivalTarget || + normalizePubkey(arrival.actor) !== arrivalTarget || arrivalTarget !== departureActor ) { return null; diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 5c0a548d29..7f859e7878 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -787,7 +787,9 @@ function VirtualizedTimelineRows({ className={cn( "pointer-events-none absolute inset-x-0 z-20", channelChrome.stickyTimelineTop, - pinnedDay.label ? "opacity-100" : "opacity-0", + pinnedDay.label || pinnedDay.incomingLabel + ? "opacity-100" + : "opacity-0", )} data-day-label={pinnedDay.label ?? undefined} data-testid="message-timeline-sticky-day-divider" From f9084fe988de3b51ee88fedd24f05dedac10fdea Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 09:57:10 +0100 Subject: [PATCH 6/8] fix(desktop): keep incompatible membership activity separate Signed-off-by: kenny lopez --- .../messages/lib/timelineItems.test.mjs | 16 +++++---- .../features/messages/lib/timelineItems.ts | 34 ++++++++++++------- .../features/messages/ui/SystemMessageRow.tsx | 1 + desktop/tests/e2e/mentions.spec.ts | 4 +-- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 83af860407..caa4f4d905 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -173,11 +173,11 @@ test("buildTimelineItems: prepending membership history preserves the loaded suf const prependedKeys = prependedItems.slice(1).map((item) => item.key); assert.deepEqual(loadedKeys, ["c", "message"]); - assert.deepEqual(prependedKeys, ["c", "message"]); + assert.deepEqual(prependedKeys, ["a", "c", "message"]); assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys); }); -test("buildTimelineItems: contiguous member additions extend a group outside one hour", () => { +test("buildTimelineItems: membership groups stop after one hour", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -186,15 +186,15 @@ test("buildTimelineItems: contiguous member additions extend a group outside one ]; const { items } = buildTimelineItems(entries, null); - assert.deepEqual(kinds(items), ["day-divider", "system-group"]); + assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]); const group = items.find((item) => item.kind === "system-group"); assert.deepEqual( group?.entries.map((groupEntry) => groupEntry.message.id), - ["a", "b", "c"], + ["b", "c"], ); }); -test("buildTimelineItems: arrivals share a group but intervening rows break it", () => { +test("buildTimelineItems: incompatible arrivals remain separate", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -222,9 +222,11 @@ test("buildTimelineItems: arrivals share a group but intervening rows break it", const { items } = buildTimelineItems(entries, null); assert.deepEqual(kinds(items), [ "day-divider", - "system-group", + "system", + "system", "message", - "system-group", + "system", + "system", ]); }); diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index a9eae81230..71c69eb22d 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -62,10 +62,10 @@ function entryRenderKey(entry: MainTimelineEntry): string { return entry.message.renderKey ?? entry.message.id; } -type MembershipChangePayload = { - mode: "arrival" | "departure"; - target: string; -}; +type MembershipChangePayload = + | { mode: "self-arrival"; target: string } + | { actor: string; mode: "addition"; target: string } + | { mode: "departure"; target: string }; function parseMembershipChangePayload( entry: MainTimelineEntry, @@ -84,13 +84,18 @@ function parseMembershipChangePayload( } 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(); - return target ? { mode: "arrival", target } : null; + if (!actor || !target) return null; + return actor === target + ? { mode: "self-arrival", target } + : { actor, mode: "addition", target }; } catch { return null; } @@ -100,11 +105,16 @@ function membershipChangesCanGroup( first: MembershipChangePayload, second: MembershipChangePayload, ): boolean { + if (first.mode === "self-arrival") { + return ( + second.mode === "self-arrival" || + (second.mode === "departure" && first.target === second.target) + ); + } return ( - (first.mode === "arrival" && second.mode === "arrival") || - (first.mode === "arrival" && - second.mode === "departure" && - first.target === second.target) + first.mode === "addition" && + second.mode === "addition" && + first.actor === second.actor ); } @@ -115,9 +125,9 @@ 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. - * Arrivals (self-joins and additions) share one summary; an arrival immediately - * followed by that member leaving becomes a single lifecycle summary. That - * deliberately lets a contiguous activity run extend beyond its original hour. + * 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. Groups do not cross the one-hour activity window. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index f94145386b..1da16e437f 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -146,6 +146,7 @@ function buildJoinedThenLeftPayload( if ( arrival?.type !== "member_joined" || departure?.type !== "member_left" || + !arrival.actor || !arrivalTarget || normalizePubkey(arrival.actor) !== arrivalTarget || arrivalTarget !== departureActor diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ed025c6b2c..0428d04200 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1114,7 +1114,7 @@ test("system add rows use plain names while remove rows retain agent mention sty const addedRow = page .getByTestId("system-message-row") .filter({ hasText: "portal" }) - .filter({ hasText: "joined the channel" }); + .filter({ hasText: "added by" }); const removedRow = page .getByTestId("system-message-row") .filter({ hasText: "removed portal from the channel" }); @@ -1251,7 +1251,7 @@ test("system agent profile exposes owned agent actions", async ({ page }) => { const joinedRow = page .getByTestId("system-message-row") .filter({ hasText: "mira" }) - .filter({ hasText: "joined the channel" }); + .filter({ hasText: "added by" }); const agentName = joinedRow.getByText("mira", { exact: true }); await expect(agentName).toHaveText("mira"); await expect(agentName).not.toHaveAttribute("data-mention"); From 3aecb7708bc048516ce9ff4904b0d7fb6e9c7cf3 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 10:01:44 +0100 Subject: [PATCH 7/8] fix(desktop): use rolling membership activity window Signed-off-by: kenny lopez --- desktop/src/features/messages/lib/timelineItems.test.mjs | 8 ++++---- desktop/src/features/messages/lib/timelineItems.ts | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index caa4f4d905..2b87b5ebc3 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -173,11 +173,11 @@ test("buildTimelineItems: prepending membership history preserves the loaded suf const prependedKeys = prependedItems.slice(1).map((item) => item.key); assert.deepEqual(loadedKeys, ["c", "message"]); - assert.deepEqual(prependedKeys, ["a", "c", "message"]); + assert.deepEqual(prependedKeys, ["c", "message"]); assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys); }); -test("buildTimelineItems: membership groups stop after one hour", () => { +test("buildTimelineItems: contiguous member additions extend a group outside one hour", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -186,11 +186,11 @@ test("buildTimelineItems: membership groups stop after one hour", () => { ]; const { items } = buildTimelineItems(entries, null); - assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]); + 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), - ["b", "c"], + ["a", "b", "c"], ); }); diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index 71c69eb22d..c283871025 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -127,7 +127,8 @@ function membershipChangesCanGroup( * 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. Groups do not cross the one-hour activity window. + * lifecycle summary. Each adjacent event must fall within the one-hour activity + * window, so uninterrupted activity can extend beyond an hour overall. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], @@ -146,13 +147,14 @@ function buildMembershipGroups( let start = end; while (start > 0) { const candidate = entries[start - 1]; + const nextEntry = entries[start]; const candidatePayload = parseMembershipChangePayload(candidate); if ( barrierIndexes.has(start) || !candidatePayload || !membershipChangesCanGroup(candidatePayload, newestPayload) || newestEntry.message.createdAt < candidate.message.createdAt || - newestEntry.message.createdAt - candidate.message.createdAt > 60 * 60 + nextEntry.message.createdAt - candidate.message.createdAt > 60 * 60 ) { break; } From b27c9a241ae3e2eb4f33d0e9e035a7715082b659 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 4 Aug 2026 11:58:41 +0100 Subject: [PATCH 8/8] fix(desktop): index grouped membership activity Signed-off-by: kenny lopez --- .../features/messages/ui/TimelineMessageList.tsx | 14 +++++++++----- desktop/tests/e2e/mentions.spec.ts | 11 +++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 7f859e7878..bf2da03f40 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -365,10 +365,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); -function timelineItemMessageId(item: TimelineNonDayItem): string | null { +function timelineItemMessageIds(item: TimelineNonDayItem): string[] { + if (item.kind === "system-group") { + return item.entries.map((entry) => entry.message.id); + } return item.kind === "message" || item.kind === "system" - ? item.entry.message.id - : null; + ? [item.entry.message.id] + : []; } type VirtualizedTimelineRowsProps = { @@ -633,8 +636,9 @@ function VirtualizedTimelineRows({ const byId = new Map(); items.forEach((item, index) => { if (item.kind !== "timeline-item") return; - const messageId = timelineItemMessageId(item.item); - if (messageId) byId.set(messageId, index); + for (const messageId of timelineItemMessageIds(item.item)) { + byId.set(messageId, index); + } }); return byId; }, [items]); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 0428d04200..d9dcffa2e4 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1178,7 +1178,7 @@ test("groups contiguous arrival activity with hidden names in the standard toolt const groupedRow = page .getByTestId("system-message-row") - .filter({ hasText: "joined the channel along with" }); + .filter({ hasText: "added by Alice Chen, along with" }); for (const visibleName of [ "Erica Chapman", "Peter Griffin", @@ -1187,15 +1187,15 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await expect(groupedRow).toContainText(visibleName); } await expect( - groupedRow.locator("p").filter({ hasText: "joined the channel" }), + groupedRow.locator("p").filter({ hasText: "added by Alice Chen" }), ).toContainText( - "alice joined the channel along with Erica Chapman, Peter Griffin, Marcia Thomas, and 3 others", + "Erica Chapman added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", ); const avatarStack = groupedRow.getByTestId("system-message-avatar-stack"); await expect(avatarStack).toHaveCount(1); await expect(avatarStack.getByTestId("system-message-avatar")).toHaveCount(5); await expect( - groupedRow.locator("p").filter({ hasText: "joined the channel" }), + groupedRow.locator("p").filter({ hasText: "added by Alice Chen" }), ).toHaveCSS("text-align", "left"); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); @@ -1204,7 +1204,7 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await visibleName.hover(); await expect(visibleName).toHaveCSS("text-decoration-line", "underline"); - const othersTrigger = groupedRow.getByRole("button", { name: "3 others" }); + const othersTrigger = groupedRow.getByRole("button", { name: "2 others" }); // Park the pointer off-target first: the previous hover leaves the mouse at a // fixed viewport point, and any later reflow (new rows, scroll-to-bottom, a // different text wrap) can slide this button under it. Without this the @@ -1215,7 +1215,6 @@ test("groups contiguous arrival activity with hidden names in the standard toolt await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline"); const tooltip = page.getByRole("tooltip"); - await expect(tooltip).toContainText("Jordan Lee"); await expect(tooltip).toContainText("Olivia Park"); await expect(tooltip).toContainText("Sam Rivera");