Refine desktop timeline activity presentation (#4582)

## Summary

- Make channel join/leave activity use the selected inline avatar-stack
treatment.
- Group related membership activity for one hour and preserve
profile/overflow-name interactions.
- Restore the virtualized day-divider handoff and align the sticky date
behavior with the message timeline.

## Validation

- `pnpm check`
- `pnpm test`
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml`
- Visual desktop screenshot captured with seeded membership activity

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-08-04 15:11:16 +01:00
committed by GitHub
parent f18a9cb106
commit a5bf3c5ae1
21 changed files with 1132 additions and 657 deletions

View File

@@ -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");
});

View File

@@ -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;
/**

View File

@@ -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"),

View File

@@ -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.
*

View File

@@ -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) }),

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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);
}

View File

@@ -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 (
<section
aria-label={label}
className="pointer-events-none sticky top-(--buzz-channel-content-top-padding,5.75rem) z-20 flex justify-center"
data-testid="message-timeline-day-divider"
className={cn(
sticky
? cn(
"pointer-events-none sticky z-20 flex justify-center",
channelChrome.stickyTimelineTop,
)
: "pointer-events-none flex justify-center",
)}
data-testid={testId}
data-day-label={label}
>
<p className="relative z-10 shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70">

View File

@@ -689,8 +689,8 @@ const MessageTimelineBase = React.forwardRef<
{showUnreadPill ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-30 flex translate-y-3 justify-center px-4",
channelChrome.top,
"pointer-events-none absolute inset-x-0 z-30 flex justify-center px-4",
channelChrome.stickyTimelineTop,
)}
>
<UnreadPill
@@ -709,8 +709,8 @@ const MessageTimelineBase = React.forwardRef<
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-30 flex translate-y-3 justify-center px-4",
channelChrome.top,
"pointer-events-none absolute inset-x-0 z-30 flex justify-center px-4",
channelChrome.stickyTimelineTop,
)}
data-testid="message-timeline-fetching-older"
>

View File

@@ -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<string, string>,
agentPubkeys?: ReadonlySet<string>,
) {
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<string>;
currentPubkey: string | undefined;
personaLookup?: Map<string, string>;
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 = (
<UserAvatar
avatarUrl={resolveAvatarUrl(singlePubkey, profiles)}
className="!h-9 !w-9 shrink-0 text-2xs"
displayName={actorLabel}
testId="system-message-avatar"
/>
);
if (singlePubkey) {
return (
<UserProfilePopover
botIdenticonValue={isSingleAgent ? actorLabel : undefined}
pubkey={singlePubkey}
role={isSingleAgent ? "bot" : undefined}
>
<button
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="system-message-avatar"
type="button"
>
{avatar}
</button>
</UserProfilePopover>
);
}
return avatar;
}
const isActorAgent = isKnownAgentPubkey(
actorPubkey,
profiles,
personaLookup,
agentPubkeys,
);
const targetLabel = resolveUserLabel({
pubkey: targetPubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
});
const dualAvatar = (
<div
className="relative h-9 w-9 shrink-0"
data-testid="system-message-avatar"
>
<UserAvatar
avatarUrl={resolveAvatarUrl(actorPubkey, profiles)}
className="!h-7 !w-7 border-2 border-background text-2xs"
displayName={actorLabel}
/>
<UserAvatar
avatarUrl={resolveAvatarUrl(targetPubkey, profiles)}
className="!absolute !bottom-0 !right-0 !h-7 !w-7 border-2 border-background text-2xs"
displayName={targetLabel}
/>
</div>
);
return (
<UserProfilePopover
botIdenticonValue={isActorAgent ? actorLabel : undefined}
pubkey={actorPubkey}
role={isActorAgent ? "bot" : undefined}
>
<button
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{dualAvatar}
</button>
</UserProfilePopover>
);
}
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 (
<div
aria-label={`${visiblePubkeys.length} channel member${visiblePubkeys.length === 1 ? "" : "s"}`}
className="relative z-10 flex shrink-0 items-center justify-center"
data-testid="system-message-avatar-stack"
role="img"
>
{visiblePubkeys.map((pubkey, index) => {
const label = resolveUserLabel({
pubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
});
return (
<div
className={cn("relative", index > 0 && "-ml-1")}
data-testid="system-message-avatar"
key={pubkey}
style={{ zIndex: index + 1 }}
>
<span
className="block"
style={{
...(index < visiblePubkeys.length - 1 && {
mask: "radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={resolveAvatarUrl(pubkey, profiles)}
className="h-6 w-6 text-2xs"
displayName={label}
size="sm"
/>
</span>
</div>
);
})}
</div>
);
}

View File

@@ -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<string>;
currentPubkey: string | undefined;
personaLookup?: Map<string, string>;
profiles: UserProfileLookup | undefined;
targetPubkey: string | undefined;
}) {
const hasActorAndTarget =
actorPubkey && targetPubkey && actorPubkey !== targetPubkey;
const actorLabel = actorPubkey
? resolveUserLabel({
pubkey: actorPubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
})
: "Someone";
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];
const singlePubkey = actorPubkey ?? targetPubkey;
if (!hasActorAndTarget) {
const isSingleAgent = isKnownAgentPubkey(
singlePubkey,
profiles,
personaLookup,
agentPubkeys,
);
const avatar = (
<UserAvatar
avatarUrl={resolveAvatarUrl(singlePubkey, profiles)}
className="!h-9 !w-9 shrink-0 text-2xs"
displayName={actorLabel}
testId="system-message-avatar"
/>
);
if (singlePubkey) {
return (
<UserProfilePopover
botIdenticonValue={isSingleAgent ? actorLabel : undefined}
pubkey={singlePubkey}
role={isSingleAgent ? "bot" : undefined}
>
<button
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="system-message-avatar"
type="button"
>
{avatar}
</button>
</UserProfilePopover>
);
}
return avatar;
}
const isActorAgent = isKnownAgentPubkey(
actorPubkey,
profiles,
personaLookup,
agentPubkeys,
);
const targetLabel = resolveUserLabel({
pubkey: targetPubkey,
currentPubkey,
profiles,
preferResolvedSelfLabel: true,
});
const dualAvatar = (
<div
className="relative h-9 w-9 shrink-0"
data-testid="system-message-avatar"
>
<UserAvatar
avatarUrl={resolveAvatarUrl(actorPubkey, profiles)}
className="!h-7 !w-7 border-2 border-background text-2xs"
displayName={actorLabel}
/>
<UserAvatar
avatarUrl={resolveAvatarUrl(targetPubkey, profiles)}
className="!absolute !bottom-0 !right-0 !h-7 !w-7 border-2 border-background text-2xs"
displayName={targetLabel}
/>
</div>
);
return (
<UserProfilePopover
botIdenticonValue={isActorAgent ? actorLabel : undefined}
pubkey={actorPubkey}
role={isActorAgent ? "bot" : undefined}
>
<button
className="shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{dualAvatar}
</button>
</UserProfilePopover>
);
return [
...new Set(pubkeys.filter((pubkey): pubkey is string => Boolean(pubkey))),
];
}
function MembershipPersonName({
@@ -508,6 +444,10 @@ function describeSystemEvent(
personaLookup?: Map<string, string>,
agentPubkeys?: ReadonlySet<string>,
): 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)}{" "}
<ProfileName pubkey={payload.actor} underlineOnHover>
{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)}{" "}
<ProfileName pubkey={payload.actor} underlineOnHover>
{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,
);
const reactionsContent = (
<div>
<MessageReactions
messageId={reactionMessage.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
className="mt-0.5 pt-0.5"
burstEmojiOnRender={badgeBurstEmoji}
onBurstEmojiRendered={(emoji) => {
setBadgeBurstEmoji((current) => (current === emoji ? null : current));
}}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
</p>
) : null}
</div>
);
const reactionPicker = canToggleReactions ? (
<div
className={cn(
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
"max-w-0 border-0 shadow-none translate-y-1 opacity-0",
"group-hover/message:max-w-9 group-hover/message:border group-hover/message:border-border/70 group-hover/message:shadow-xs group-hover/message:translate-y-0 group-hover/message:opacity-100",
"group-focus-within/message:max-w-9 group-focus-within/message:border group-focus-within/message:border-border/70 group-focus-within/message:shadow-xs group-focus-within/message:translate-y-0 group-focus-within/message:opacity-100",
isReactionPickerOpen
? "max-w-9 border border-border/70 shadow-xs translate-y-0 opacity-100"
: "",
)}
>
<div className="flex items-center gap-1 p-1">
<Popover
onOpenChange={setIsReactionPickerOpen}
open={isReactionPickerOpen}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className={SYSTEM_ACTION_BUTTON_CLASS}
size="sm"
type="button"
variant={isReactionPickerOpen ? "secondary" : "ghost"}
>
<SmilePlus className={SYSTEM_ACTION_ICON_CLASS} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
{reactionErrorMessage ? (
<div className="px-3 pt-3 pb-0">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
) : null}
<EmojiPicker
onSelect={(value) => {
if (
!reactionPending &&
wouldAddReaction(value) &&
isPositiveEmojiParticle(value)
) {
setBadgeBurstEmoji(value);
}
void handleReactionSelect(value)
.then(() => {
recordQuickReactionEmoji(value);
})
.catch(() => {})
.finally(() => {
setIsReactionPickerOpen(false);
});
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
) : null;
return (
<div
className="group/message relative mx-1 rounded-2xl px-2 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
className={cn(
"group/message relative mx-1 transition-colors",
isMembershipActivity
? "pb-2 pt-4"
: "rounded-2xl px-2 py-1 hover:bg-muted/50 focus-within:bg-muted/50",
)}
data-testid="system-message-row"
>
<div className="flex items-start gap-2.5">
<SystemMessageAvatar
actorPubkey={isMembershipArrival ? payload.target : payload.actor}
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targetPubkey={isMembershipArrival ? undefined : payload.target}
/>
<div
className={cn(
MESSAGE_MARKDOWN_CLASS,
"flex min-w-0 flex-1 flex-col gap-0.5",
)}
>
<MessageHeaderRow>
<MessageAuthorText as="div" className="text-foreground">
{description.title}
</MessageAuthorText>
{displayedIdentityIsAgent ? (
<MessageAgentOwner
ownerLabel={displayedOwnerLabel}
ownerPubkey={displayedOwnerPubkey}
{isMembershipActivity ? (
<div className={cn(MESSAGE_MARKDOWN_CLASS, "flex flex-col gap-1.5")}>
<div className="flex justify-center">
<div className="flex min-w-0 max-w-[min(40rem,80%)] items-center gap-2">
<MembershipAvatarStack
currentPubkey={currentPubkey}
profiles={profiles}
pubkeys={membershipPubkeys}
/>
) : null}
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
/>
</MessageHeaderRow>
<p className="-mt-0.5 text-sm leading-snug text-foreground">
{description.action}
</p>
<div>
<MessageReactions
messageId={reactionMessage.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
className="mt-0.5 pt-0.5"
burstEmojiOnRender={badgeBurstEmoji}
onBurstEmojiRendered={(emoji) => {
setBadgeBurstEmoji((current) =>
current === emoji ? null : current,
);
}}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
}}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
<p className="min-w-0 text-left text-xs font-normal leading-4 text-muted-foreground/70">
{description.title} {description.action}
</p>
) : null}
</div>
</div>
<div className="flex justify-center">{reactionsContent}</div>
</div>
) : (
<div className="flex items-start gap-2.5">
<SystemMessageAvatar
actorPubkey={isMembershipArrival ? payload.target : payload.actor}
agentPubkeys={agentPubkeys}
currentPubkey={currentPubkey}
personaLookup={personaLookup}
profiles={profiles}
targetPubkey={isMembershipArrival ? undefined : payload.target}
/>
<div
className={cn(
MESSAGE_MARKDOWN_CLASS,
"flex min-w-0 flex-1 flex-col gap-0.5",
)}
>
<MessageHeaderRow>
<MessageAuthorText as="div" className="text-foreground">
{description.title}
</MessageAuthorText>
{displayedIdentityIsAgent ? (
<MessageAgentOwner
ownerLabel={displayedOwnerLabel}
ownerPubkey={displayedOwnerPubkey}
/>
) : null}
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
/>
</MessageHeaderRow>
<p className="-mt-0.5 text-sm leading-snug text-foreground">
{description.action}
</p>
{reactionsContent}
</div>
</div>
<div className="absolute right-2 top-1 z-10 sm:top-0 sm:-translate-y-1/2">
{canToggleReactions ? (
<div
className={cn(
"overflow-hidden rounded-full border border-border/70 bg-background/95 shadow-xs backdrop-blur-sm supports-[backdrop-filter]:bg-background/85 transition-all duration-150 ease-out",
"max-w-0 border-0 shadow-none translate-y-1 opacity-0",
"group-hover/message:max-w-9 group-hover/message:border group-hover/message:border-border/70 group-hover/message:shadow-xs group-hover/message:translate-y-0 group-hover/message:opacity-100",
"group-focus-within/message:max-w-9 group-focus-within/message:border group-focus-within/message:border-border/70 group-focus-within/message:shadow-xs group-focus-within/message:translate-y-0 group-focus-within/message:opacity-100",
isReactionPickerOpen
? "max-w-9 border border-border/70 shadow-xs translate-y-0 opacity-100"
: "",
)}
>
<div className="flex items-center gap-1 p-1">
<Popover
onOpenChange={setIsReactionPickerOpen}
open={isReactionPickerOpen}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Open reactions"
className={SYSTEM_ACTION_BUTTON_CLASS}
size="sm"
type="button"
variant={isReactionPickerOpen ? "secondary" : "ghost"}
>
<SmilePlus className={SYSTEM_ACTION_ICON_CLASS} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>React</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-auto p-0 rounded-2xl overflow-hidden border-0 bg-transparent shadow-none"
side="top"
sideOffset={10}
>
{reactionErrorMessage ? (
<div className="px-3 pt-3 pb-0">
<p className="text-xs text-destructive">
{reactionErrorMessage}
</p>
</div>
) : null}
<EmojiPicker
onSelect={(value) => {
if (
!reactionPending &&
wouldAddReaction(value) &&
isPositiveEmojiParticle(value)
) {
setBadgeBurstEmoji(value);
}
void handleReactionSelect(value)
.then(() => {
recordQuickReactionEmoji(value);
})
.catch(() => {})
.finally(() => {
setIsReactionPickerOpen(false);
});
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
) : null}
</div>
)}
<div
className={cn(
"absolute right-2 z-10",
isMembershipActivity ? "top-2" : "top-1 sm:top-0 sm:-translate-y-1/2",
)}
>
{reactionPicker}
</div>
</div>
);

View File

@@ -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<HTMLDivElement>(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<readonly string[]>([]);
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<HTMLParagraphElement>("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<HTMLElement>(
'[data-testid="message-timeline-day-divider"]',
),
].find((element) => element.dataset.dayLabel === label);
const pill = source?.querySelector<HTMLElement>("p");
return pill
? pill.getBoundingClientRect().top -
scroller.getBoundingClientRect().top
: null;
};
const sourcePills = [
...scroller.querySelectorAll<HTMLElement>(
'[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<string, number>();
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 (
<div className="h-full min-h-0 w-full" ref={hostRef}>
<div className="relative h-full min-h-0 w-full" ref={hostRef}>
<PreserveVirtualizedItemVisibilityContext value={isPrepend}>
<VList
ref={listRef}
@@ -624,20 +765,12 @@ function VirtualizedTimelineRows({
const dayLabel = formatDayHeading(item.headingTimestamp);
return (
<div
// The sticky pill needs travel room, but its containing block
// is this item wrapper. The trailing spacer extends the content
// box by 4rem while the matching negative margin keeps the
// measured layout height at exactly the divider's height, so
// row spacing and Virtua's size cache are unaffected. Both the
// spacer and the pill are pointer-events-none, and the later
// (absolutely positioned) row siblings paint above the spacer.
className="relative -mb-16 flex flex-col before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']"
className="relative flex flex-col 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={dayLabel}
data-testid="message-timeline-day-group"
key={virtualizedItemKey(item)}
>
<DayDivider label={dayLabel} />
<div aria-hidden className="pointer-events-none h-16" />
<DayDivider label={dayLabel} sticky={false} />
</div>
);
}
@@ -653,236 +786,34 @@ function VirtualizedTimelineRows({
}}
</VList>
</PreserveVirtualizedItemVisibilityContext>
</div>
);
}
function TimelineRowShell({
children,
item,
useContentVisibility = true,
}: {
children: React.ReactNode;
item: TimelineNonDayItem;
useContentVisibility?: boolean;
}) {
return (
<div
className={cn(useContentVisibility && "timeline-row-cv")}
data-timeline-item-key={getTimelineItemKey(item)}
style={useContentVisibility ? timelineRowReserveStyle(item) : undefined}
>
{children}
</div>
);
}
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 (
<div className="flex flex-col gap-1 pb-2.5">
<SystemMessageRow
groupedMessages={groupedMessages}
message={firstEntry.message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
ownerProfiles={ownerProfiles}
/>
{footer}
</div>
);
}
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<typeof buildVideoReviewContextForMessage>;
};
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 (
<div
aria-hidden
className={cn(
"group/message relative mx-1 mb-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
"pointer-events-none absolute inset-x-0 z-20",
channelChrome.stickyTimelineTop,
pinnedDay.label || pinnedDay.incomingLabel
? "opacity-100"
: "opacity-0",
)}
data-day-label={pinnedDay.label ?? undefined}
data-testid="message-timeline-sticky-day-divider"
>
<MessageRow
channelId={channelId}
highlighted={false}
hoverBackground={false}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
hideAgentAccessBadge={hideAgentAccessBadges}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
isUnread={isUnread}
isContinuation={isContinuation}
playEntrance={playEntrance}
onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onOpenThread}
showDepthGuides={false}
summary={summary}
summaryIndentOffsetRem={-THREAD_REPLY_ROW_MARGIN_INLINE_REM}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
<div className="invisible flex justify-center">
<DayDivider label={pinnedDay.label ?? ""} sticky={false} testId="" />
</div>
<div
className="absolute inset-x-0 top-0 flex flex-col"
data-testid="message-timeline-sticky-day-divider-content"
ref={pinnedDayLabelRef}
>
<DayDivider label={pinnedDay.label ?? ""} sticky={false} testId="" />
<DayDivider
label={pinnedDay.incomingLabel ?? ""}
sticky={false}
testId=""
/>
</div>
</div>
);
}
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
return (
<div
className={cn(
"flex flex-col gap-1",
isFollowedByContinuation ? "pb-0" : "pb-2.5",
)}
>
<MessageRow
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
hideAgentAccessBadge={hideAgentAccessBadges}
isContinuation={isContinuation}
isUnread={isUnread}
playEntrance={playEntrance}
onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
{footer}
</div>
);
}

View File

@@ -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<void>;
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 (
<div className="flex flex-col gap-1 pb-2.5">
<SystemMessageRow
groupedMessages={groupedMessages}
message={firstEntry.message}
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
ownerProfiles={ownerProfiles}
/>
{footer}
</div>
);
}
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<string>;
searchQuery?: string;
threadUnreadCounts?: ReadonlyMap<string, number>;
unfollowThreadById?: (rootId: string) => void;
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
};
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 (
<div
className={cn(
"group/message relative mx-1 mb-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
isHighlighted &&
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
)}
>
<MessageRow
channelId={channelId}
highlighted={false}
hoverBackground={false}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
hideAgentAccessBadge={hideAgentAccessBadges}
isFollowingThread={
isFollowingThreadById
? isFollowingThreadById(message.id)
: undefined
}
isUnread={isUnread}
isContinuation={isContinuation}
playEntrance={playEntrance}
onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
onFollowThread={
followThreadById ? () => followThreadById(message.id) : undefined
}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
onUnfollowThread={
unfollowThreadById
? () => unfollowThreadById(message.id)
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onOpenThread}
showDepthGuides={false}
summary={summary}
summaryIndentOffsetRem={-THREAD_REPLY_ROW_MARGIN_INLINE_REM}
unreadCount={threadUnreadCounts?.get(message.id)}
/>
{footer}
</div>
);
}
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
const isSearchActive = message.id === searchActiveMessageId;
return (
<div
className={cn(
"flex flex-col gap-1",
isFollowedByContinuation ? "pb-0" : "pb-2.5",
)}
>
<MessageRow
channelId={channelId}
highlighted={message.id === highlightedMessageId || isSearchActive}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
hideAgentAccessBadge={hideAgentAccessBadges}
isContinuation={isContinuation}
isUnread={isUnread}
playEntrance={playEntrance}
onEntranceComplete={onEntranceComplete}
message={message}
onDelete={canDelete}
onEdit={canEdit}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
showDepthGuides={false}
videoReviewContext={videoReviewContext}
/>
{footer}
</div>
);
}

View File

@@ -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 (
<div
className={cn(useContentVisibility && "timeline-row-cv")}
data-timeline-item-key={getTimelineItemKey(item)}
style={useContentVisibility ? timelineRowReserveStyle(item) : undefined}
>
{children}
</div>
);
}

View File

@@ -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}
>

View File

@@ -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. */

View File

@@ -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<HTMLElement>(
'[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<HTMLElement>(
'[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<HTMLElement>(
'[data-testid="message-timeline-day-group"]',
const pinnedDivider = element.parentElement?.querySelector<HTMLElement>(
'[data-testid="message-timeline-sticky-day-divider"]',
);
const firstDivider = firstGroup?.querySelector<HTMLElement>(
'[data-testid="message-timeline-day-divider"]',
const pinnedPill = pinnedDivider?.querySelector<HTMLElement>(
'[data-testid="message-timeline-sticky-day-divider-content"] p',
);
const firstDividerPill = firstDivider?.querySelector<HTMLElement>("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<HTMLElement>(
'[data-testid="message-timeline-day-group"]',
),
].find((candidate) => {
const pill = candidate.querySelector<HTMLElement>("p");
return pill && getComputedStyle(pill).visibility === "visible";
});
const pill = group?.querySelector<HTMLElement>("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 ({

View File

@@ -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,
);
});

View File

@@ -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 }) => {

View File

@@ -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<HTMLElement>(
'[data-testid="message-unread-pill"]',
);
const stickyDay = document.querySelector<HTMLElement>(
'[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 }) => {