Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.
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="pointer-events-none sticky top-(--buzz-channel-content-top-padding,5.75rem) z-20 flex justify-center"
Comment thread
klopez4212 marked this conversation as resolved.
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
5 changes: 3 additions & 2 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ export function MessageThreadPanel({
const threadFooter = (
<>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-20 flex justify-center px-4">
<div className="pointer-events-none absolute inset-x-0 bottom-36 z-50 flex justify-center px-4">
<Button
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-2xs font-medium text-muted-foreground shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
data-testid="thread-scroll-to-latest"
Expand All @@ -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
6 changes: 3 additions & 3 deletions desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ const MessageTimelineBase = React.forwardRef<
{showUnreadPill ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex translate-y-3 justify-center px-4",
"pointer-events-none absolute inset-x-0 z-30 flex translate-y-3 justify-center px-4",
channelChrome.top,
)}
>
Expand All @@ -391,7 +391,7 @@ const MessageTimelineBase = React.forwardRef<
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex translate-y-3 justify-center px-4",
"pointer-events-none absolute inset-x-0 z-30 flex translate-y-3 justify-center px-4",
channelChrome.top,
)}
data-testid="message-timeline-fetching-older"
Expand Down Expand Up @@ -618,7 +618,7 @@ const MessageTimelineBase = React.forwardRef<
{!isAtBottom ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 z-20 flex justify-center px-4",
"pointer-events-none absolute inset-x-0 z-50 flex justify-center px-4",
hasComposerOverlay ? "bottom-36" : "bottom-4",
)}
>
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