mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
196
desktop/src/features/messages/ui/SystemMessageAvatars.tsx
Normal file
196
desktop/src/features/messages/ui/SystemMessageAvatars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
228
desktop/src/features/messages/ui/TimelineMessageRow.tsx
Normal file
228
desktop/src/features/messages/ui/TimelineMessageRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
desktop/src/features/messages/ui/TimelineRowShell.tsx
Normal file
27
desktop/src/features/messages/ui/TimelineRowShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user