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..2b87b5ebc3 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: incompatible arrivals remain separate", () => { const start = dayAt(2026, 6, 14); const entries = [ memberAddedEntry({ id: "a", target: "target-a", createdAt: start }), @@ -216,6 +230,22 @@ test("buildTimelineItems: actor changes and intervening rows break member-add gr ]); }); +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..c283871025 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -62,13 +62,10 @@ function entryRenderKey(entry: MainTimelineEntry): string { return entry.message.renderKey ?? entry.message.id; } -const MEMBERSHIP_GROUP_WINDOW_SECONDS = 5 * 60; - -type MembershipChangePayload = { - actor: string | null; - mode: "added" | "joined"; - target: string; -}; +type MembershipChangePayload = + | { mode: "self-arrival"; target: string } + | { actor: string; mode: "addition"; target: string } + | { mode: "departure"; target: string }; function parseMembershipChangePayload( entry: MainTimelineEntry, @@ -81,6 +78,10 @@ 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" || @@ -92,10 +93,9 @@ function parseMembershipChangePayload( 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 }; + ? { mode: "self-arrival", target } + : { actor, mode: "addition", target }; } catch { return null; } @@ -105,9 +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 === second.mode && - (first.mode === "joined" || first.actor === second.actor) + first.mode === "addition" && + second.mode === "addition" && + first.actor === second.actor ); } @@ -116,6 +123,12 @@ 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. + * Self-joins and additions from one administrator each form their own summary; + * a self-join immediately followed by that member leaving becomes a single + * lifecycle summary. Each adjacent event must fall within the one-hour activity + * window, so uninterrupted activity can extend beyond an hour overall. */ function buildMembershipGroups( entries: readonly MainTimelineEntry[], @@ -134,14 +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 > - MEMBERSHIP_GROUP_WINDOW_SECONDS + nextEntry.message.createdAt - candidate.message.createdAt > 60 * 60 ) { 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..1da16e437f 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -29,12 +29,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 +82,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 +114,15 @@ function buildGroupedMembershipPayload( }; } + const actor = membershipArrivals[0].actor; + const isSameAdderGroup = membershipArrivals.every( + ({ actor: candidateActor, target }) => + candidateActor === actor && candidateActor !== target, + ); + if (!isSameAdderGroup) { + return null; + } + return { type: "members_added", actor, @@ -122,6 +131,32 @@ 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" || + !arrival.actor || + !arrivalTarget || + normalizePubkey(arrival.actor) !== arrivalTarget || + arrivalTarget !== departureActor + ) { + return null; + } + + return { type: "member_joined_then_left", target: arrival.target }; +} + function aggregateGroupedReactions( messages: readonly TimelineMessage[], ): TimelineReaction[] { @@ -277,116 +312,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 +444,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 +494,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -590,6 +530,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 +548,7 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - added by{" "} + {addedByActionPrefix(isTargetCurrentUser)}{" "} {resolveInlineDisplayLabel( payload.actor, @@ -766,6 +712,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 +753,172 @@ 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; + + return ( +
+ {isMembershipActivity ? ( +
+
+
+ +

+ {description.title} {description.action}

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

- {reactionErrorMessage} -

-
- ) : null} - { - if ( - !reactionPending && - wouldAddReaction(value) && - isPositiveEmojiParticle(value) - ) { - setBadgeBurstEmoji(value); - } - void handleReactionSelect(value) - .then(() => { - recordQuickReactionEmoji(value); - }) - .catch(() => {}) - .finally(() => { - setIsReactionPickerOpen(false); - }); - }} - /> -
-
-
-
- ) : null} + ) : ( +
+ +
+ + + {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..bf2da03f40 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, @@ -18,21 +17,18 @@ 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"; import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; @@ -345,7 +341,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 @@ -369,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 = { @@ -437,6 +436,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 +463,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 +487,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(); @@ -502,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]); @@ -523,6 +658,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 +717,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 +728,12 @@ function VirtualizedTimelineRows({ onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged, + updatePinnedDayLabel, ], ); return ( -
+
- -
+
); } @@ -653,236 +786,34 @@ function VirtualizedTimelineRows({ }}
-
- ); -} - -function TimelineRowShell({ - children, - item, - useContentVisibility = true, -}: { - children: React.ReactNode; - item: TimelineNonDayItem; - useContentVisibility?: boolean; -}) { - return ( -
- {children} -
- ); -} - -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} +
+ ); +} 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..e84a613068 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,6 @@ 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("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..d9dcffa2e4 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1129,7 +1129,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 +1178,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: "added by Alice Chen, 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: "added by Alice Chen" }), ).toContainText( - "added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 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: "added by Alice Chen" }), + ).toHaveCSS("text-align", "left"); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); const visibleName = groupedRow.getByText("Peter Griffin", { exact: true }); @@ -1213,51 +1218,7 @@ test("groups member additions and joins with hidden names in the standard toolti 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 expect(avatarStack.locator("..")).toHaveCSS("align-items", "center"); }); test("system agent profile exposes owned agent actions", async ({ page }) => { @@ -1305,7 +1266,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 +1295,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/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 }) => { 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 }) => {