Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,8 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
Comment thread
klopez4212 marked this conversation as resolved.
Resolved
data-testid="channel-composer-overlay"
ref={composerWrapperRef}
>
<div className="pointer-events-auto">
Expand Down
45 changes: 44 additions & 1 deletion desktop/src/features/messages/lib/timelineItems.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";

import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { buildTimelineItems, getTimelineItemKey } from "./timelineItems.ts";
import {
buildTimelineDayGroups,
buildTimelineItems,
getTimelineItemKey,
} from "./timelineItems.ts";

function dayAt(year, month, day, hour = 12) {
return Math.floor(
Expand Down Expand Up @@ -100,3 +104,42 @@ test("getTimelineItemKey: keys are unique across the stream", () => {
const keys = items.map(getTimelineItemKey);
assert.equal(new Set(keys).size, keys.length);
});

test("buildTimelineDayGroups: moves non-day rows under their day section", () => {
const entries = [
entry({ id: "d1a", createdAt: dayAt(2026, 6, 12) }),
entry({ id: "d1b", createdAt: dayAt(2026, 6, 12, 13) }),
entry({ id: "d2a", createdAt: dayAt(2026, 6, 13) }),
entry({ id: "d2b", createdAt: dayAt(2026, 6, 13, 13) }),
];
const { items } = buildTimelineItems(entries, "d2b");

const groups = buildTimelineDayGroups(items);

assert.equal(groups.length, 2);
assert.deepEqual(
groups.map((group) => group.items.map((item) => item.kind)),
[
["message", "message"],
["message", "unread-divider", "message"],
],
);
assert.ok(groups.every((group) => group.headingTimestamp !== null));
});

test("buildTimelineDayGroups: preserves leading rows without a day divider", () => {
const leadingRows = [
{ kind: "unread-divider", key: "unread-a" },
{ kind: "message", key: "a", entry: entry({ id: "a" }) },
];

const groups = buildTimelineDayGroups(leadingRows);

assert.deepEqual(groups, [
{
key: "day-undated",
headingTimestamp: null,
items: leadingRows,
},
]);
});
40 changes: 40 additions & 0 deletions desktop/src/features/messages/lib/timelineItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export type TimelineItemsResult = {
items: TimelineItem[];
};

export type TimelineNonDayItem = Exclude<TimelineItem, { kind: "day-divider" }>;

export type TimelineDayGroup = {
key: string;
headingTimestamp: number | null;
items: TimelineNonDayItem[];
};

/** Stable per-item key, unique across the flattened stream. */
export function getTimelineItemKey(item: TimelineItem): string {
return item.key;
Expand Down Expand Up @@ -84,3 +92,35 @@ export function buildTimelineItems(

return { items };
}

export function buildTimelineDayGroups(
items: readonly TimelineItem[],
): TimelineDayGroup[] {
const groups: TimelineDayGroup[] = [];
let currentGroup: TimelineDayGroup | null = null;

for (const item of items) {
if (item.kind === "day-divider") {
currentGroup = {
key: item.key,
headingTimestamp: item.headingTimestamp,
items: [],
};
groups.push(currentGroup);
continue;
}

if (!currentGroup) {
currentGroup = {
key: "day-undated",
headingTimestamp: null,
items: [],
};
groups.push(currentGroup);
}

currentGroup.items.push(item);
}

return groups;
}
4 changes: 2 additions & 2 deletions desktop/src/features/messages/ui/DayDivider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="sticky top-(--buzz-channel-content-top-padding,5.75rem) z-[5] flex justify-center before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']"
className="sticky top-(--buzz-channel-content-top-padding,5.75rem) z-20 flex justify-center"
data-testid="message-timeline-day-divider"
data-day-label={label}
>
<p className="relative z-10 shrink-0 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm">
<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">
{label}
</p>
</section>
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,8 @@ export function MessageThreadPanel({
) : null}

<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
data-testid="thread-composer-overlay"
ref={threadComposerWrapperRef}
>
<div className="pointer-events-auto">
Expand Down
47 changes: 34 additions & 13 deletions desktop/src/features/messages/ui/TimelineMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import * as React from "react";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate";
import {
buildTimelineDayGroups,
buildTimelineItems,
getTimelineItemKey,
type TimelineItem,
type TimelineNonDayItem,
} from "@/features/messages/lib/timelineItems";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
Expand Down Expand Up @@ -163,14 +164,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
() => buildTimelineItems(entries, firstUnreadMessageId),
[entries, firstUnreadMessageId],
);
const dayGroups = React.useMemo(
() => buildTimelineDayGroups(itemsResult.items),
[itemsResult.items],
);

const renderItem = React.useCallback(
(item: TimelineItem) => {
(item: TimelineNonDayItem) => {
switch (item.kind) {
case "day-divider":
// Heading is resolved at render time (not baked into the item) so
// "Today"/"Yesterday" track the wall clock, not build time.
return <DayDivider label={formatDayHeading(item.headingTimestamp)} />;
case "unread-divider":
return <UnreadDivider />;
case "system":
Expand Down Expand Up @@ -245,14 +246,34 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({

return (
<div className="flex flex-col">
{itemsResult.items.map((item) => (
<div
className="timeline-row-cv"
key={getTimelineItemKey(item)}
style={timelineRowReserveStyle(item)}
{dayGroups.map((group) => (
<section
className={cn(
"relative flex flex-col",
group.headingTimestamp !== null &&
"before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']",
)}
data-day-label={
group.headingTimestamp === null
? undefined
: formatDayHeading(group.headingTimestamp)
}
data-testid="message-timeline-day-group"
key={group.key}
>
{renderItem(item)}
</div>
{group.headingTimestamp === null ? null : (
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
)}
{group.items.map((item) => (
<div
className="timeline-row-cv"
key={getTimelineItemKey(item)}
style={timelineRowReserveStyle(item)}
>
{renderItem(item)}
</div>
))}
</section>
))}
</div>
);
Expand Down
111 changes: 111 additions & 0 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,117 @@ test("channel with messages shows content", async ({ page }) => {
);
});

test("channel date divider keeps the date sticky while the separator rule scrolls", async ({
page,
}) => {
await page.goto("/");

await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await waitForMockLiveSubscription(page, "engineering");

await page.evaluate(() => {
const firstDay = 1_700_000_000;
for (let day = 0; day < 2; day += 1) {
for (let index = 0; index < 14; index += 1) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "engineering",
content: `date handoff day ${day + 1} row ${index + 1}\nsecond line for scroll height`,
createdAt: firstDay + day * 86_400 + index,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
});
}
}
});

await expect(page.getByTestId("message-timeline-day-group")).toHaveCount(2);
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(2);

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");
}
element.scrollTop = firstGroup.offsetTop + 180;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await page.waitForTimeout(50);

const metrics = await timeline.evaluate((element) => {
const firstGroup = element.querySelector<HTMLElement>(
'[data-testid="message-timeline-day-group"]',
);
const firstDivider = firstGroup?.querySelector<HTMLElement>(
'[data-testid="message-timeline-day-divider"]',
);
const firstDividerPill = firstDivider?.querySelector<HTMLElement>("p");
if (!firstGroup || !firstDivider || !firstDividerPill) {
throw new Error("missing day group or 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),
};
});

expect(metrics.dividerPosition).toBe("sticky");
expect(Number.parseInt(metrics.dividerZIndex, 10)).toBeGreaterThan(10);
await expect
.poll(async () => {
const headerZIndex = await page
.getByTestId("chat-header")
.evaluate(
(element) =>
getComputedStyle(element.parentElement ?? element).zIndex,
);
return Number.parseInt(headerZIndex, 10);
})
.toBeGreaterThan(Number.parseInt(metrics.dividerZIndex, 10));
await expect
.poll(async () => {
const sharedBackdropZIndex = await page
.getByTestId("channel-shared-header-backdrop")
.evaluate((element) => getComputedStyle(element).zIndex);
return Number.parseInt(sharedBackdropZIndex, 10);
})
.toBeGreaterThan(Number.parseInt(metrics.dividerZIndex, 10));
await expect(page.getByTestId("channel-composer-overlay")).toBeVisible();
await expect
.poll(async () => {
const composerOverlayZIndex = await page
.getByTestId("channel-composer-overlay")
.evaluate((element) => getComputedStyle(element).zIndex);
return Number.parseInt(composerOverlayZIndex, 10);
})
.toBeGreaterThan(Number.parseInt(metrics.dividerZIndex, 10));
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 ({
page,
}) => {
Expand Down
Loading