From 392270b713dbd7f7dbb53c660980ffb937a4cf56 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 1 Jul 2026 12:10:24 +0100 Subject: [PATCH 1/3] Keep channel day dividers sticky --- .../src/features/channels/ui/ChannelPane.tsx | 3 +- .../messages/lib/timelineItems.test.mjs | 45 ++++++- .../features/messages/lib/timelineItems.ts | 40 +++++++ .../src/features/messages/ui/DayDivider.tsx | 4 +- .../messages/ui/MessageThreadPanel.tsx | 3 +- .../messages/ui/TimelineMessageList.tsx | 47 ++++++-- desktop/tests/e2e/channels.spec.ts | 111 ++++++++++++++++++ 7 files changed, 235 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f89245e535..c6c4ea58ab 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -651,7 +651,8 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : (
diff --git a/desktop/src/features/messages/lib/timelineItems.test.mjs b/desktop/src/features/messages/lib/timelineItems.test.mjs index 58ea21722b..54b6319f9d 100644 --- a/desktop/src/features/messages/lib/timelineItems.test.mjs +++ b/desktop/src/features/messages/lib/timelineItems.test.mjs @@ -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( @@ -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, + }, + ]); +}); diff --git a/desktop/src/features/messages/lib/timelineItems.ts b/desktop/src/features/messages/lib/timelineItems.ts index bb20ac2163..a4cacd2b84 100644 --- a/desktop/src/features/messages/lib/timelineItems.ts +++ b/desktop/src/features/messages/lib/timelineItems.ts @@ -30,6 +30,14 @@ export type TimelineItemsResult = { items: TimelineItem[]; }; +export type TimelineNonDayItem = Exclude; + +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; @@ -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; +} diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index e5007a709b..ab8dfa0843 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -2,11 +2,11 @@ export function DayDivider({ label }: { label: string }) { return (
-

+

{label}

diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index a146c76cea..bb9029a09b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -815,7 +815,8 @@ export function MessageThreadPanel({ ) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index deb66e5467..c94f9475a3 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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"; @@ -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 ; case "unread-divider": return ; case "system": @@ -245,14 +246,34 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ return (
- {itemsResult.items.map((item) => ( -
( +
- {renderItem(item)} -
+ {group.headingTimestamp === null ? null : ( + + )} + {group.items.map((item) => ( +
+ {renderItem(item)} +
+ ))} + ))}
); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 7d6423fea4..032d2ede91 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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( + '[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( + '[data-testid="message-timeline-day-group"]', + ); + const firstDivider = firstGroup?.querySelector( + '[data-testid="message-timeline-day-divider"]', + ); + const firstDividerPill = firstDivider?.querySelector("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, }) => { From af8c90dcc1abcd322752c66c2ef7ef7cf8652e0c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 1 Jul 2026 13:01:27 +0100 Subject: [PATCH 2/3] Fix sticky divider layering --- desktop/src/features/messages/ui/DayDivider.tsx | 2 +- desktop/src/features/messages/ui/MessageThreadPanel.tsx | 2 +- desktop/src/features/messages/ui/MessageTimeline.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index ab8dfa0843..8dff4b7a06 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) { return (
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index bb9029a09b..6df725a259 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -797,7 +797,7 @@ export function MessageThreadPanel({ const threadFooter = ( <> {!isAtBottom ? ( -
+