diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts new file mode 100644 index 0000000000..ee14df78d5 --- /dev/null +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -0,0 +1,75 @@ +/** + * Shared date/time formatters for the message timeline. + * + * - `formatTime` — short clock time ("2:34 PM"), used in message rows. + * - `formatFullDateTime` — verbose string for tooltips + * ("Wednesday, April 2, 2026 at 2:34 PM"). + * - `formatDayHeading` — label for day dividers / sticky headers. + * Returns "Today", "Yesterday", or a date like "Monday, March 31, 2026". + * - `isSameDay` — compare two unix-second timestamps. + */ + +const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + +const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", +}); + +/** Short clock time, e.g. "2:34 PM". */ +export function formatTime(unixSeconds: number): string { + return TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); +} + +/** Full date + time for tooltips, e.g. "Wednesday, April 2, 2026 at 2:34 PM". */ +export function formatFullDateTime(unixSeconds: number): string { + return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); +} + +/** + * Human-friendly day label for dividers and sticky headers. + * Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". + */ +export function formatDayHeading(unixSeconds: number): string { + const date = new Date(unixSeconds * 1_000); + const now = new Date(); + + if (isSameDayDate(date, now)) { + return "Today"; + } + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (isSameDayDate(date, yesterday)) { + return "Yesterday"; + } + + return DAY_HEADING_FORMATTER.format(date); +} + +/** True when two unix-second timestamps fall on the same calendar day (local time). */ +export function isSameDay(a: number, b: number): boolean { + return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000)); +} + +function isSameDayDate(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index c8939ae116..723edc7d0f 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -18,14 +18,10 @@ import { KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; +import { formatTime } from "@/features/messages/lib/dateFormatters"; const HEX_RE = /^[0-9a-f]+$/i; -const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - function isTimelineContentEvent(event: RelayEvent) { return ( event.kind === KIND_STREAM_MESSAGE || @@ -299,7 +295,7 @@ export function formatTimelineMessages( currentUserAvatarUrl, profiles, }), - time: TIME_FORMATTER.format(new Date(event.created_at * 1_000)), + time: formatTime(event.created_at), body: edit ? edit.content : event.content, parentId: thread.parentId, rootId: thread.rootId, diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx new file mode 100644 index 0000000000..93a03cf21a --- /dev/null +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -0,0 +1,18 @@ +import { Separator } from "@/shared/ui/separator"; + +export function DayDivider({ label }: { label: string }) { + return ( +
+ +

+ {label} +

+ +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 45db1a497f..7a5f6e0f48 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -10,6 +10,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import { MessageActionBar } from "./MessageActionBar"; +import { MessageTimestamp } from "./MessageTimestamp"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -264,7 +265,10 @@ export const MessageRow = React.memo( (edited)

) : null} -

{message.time}

+ {renderBody()} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 02c32cb0b7..69f446f349 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -5,9 +5,11 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { Button } from "@/shared/ui/button"; import { Separator } from "@/shared/ui/separator"; +import { TooltipProvider } from "@/shared/ui/tooltip"; import { TimelineSkeleton } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; +import { useStickyDayHeader } from "./useStickyDayHeader"; import { useTimelineScrollManager } from "./useTimelineScrollManager"; type MessageTimelineProps = { @@ -81,101 +83,104 @@ export const MessageTimeline = React.memo(function MessageTimeline({ sentinelRef: topSentinelRef, }); + const stickyDayLabel = useStickyDayHeader(scrollContainerRef); + return ( -
-
-
-
- - {isFetchingOlder ? ( -
- -
- ) : null} - - {!hasOlderMessages && !isLoading && messages.length > 0 ? ( -
- -

- Beginning of conversation -

- -
- ) : null} - + +
+ {stickyDayLabel && !isAtBottom ? (
- -

- Today +

+ {stickyDayLabel}

-
- - {isLoading ? : null} - - {!isLoading && messages.length === 0 ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

-
- ) : null} - - {!isLoading && messages.length > 0 ? ( - - ) : null} - -
-
-
- - {!isAtBottom ? ( -
- +
+ + {isFetchingOlder ? ( +
+ +
+ ) : null} + + {!hasOlderMessages && !isLoading && messages.length > 0 ? ( +
+ +

+ Beginning of conversation +

+ +
+ ) : null} + + {isLoading ? : null} + + {!isLoading && messages.length === 0 ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} + + {!isLoading && messages.length > 0 ? ( + + ) : null} + +
+
- ) : null} -
+ + {!isAtBottom ? ( +
+ +
+ ) : null} +
+ ); }); diff --git a/desktop/src/features/messages/ui/MessageTimestamp.tsx b/desktop/src/features/messages/ui/MessageTimestamp.tsx new file mode 100644 index 0000000000..fe6ff873bc --- /dev/null +++ b/desktop/src/features/messages/ui/MessageTimestamp.tsx @@ -0,0 +1,21 @@ +import { formatFullDateTime } from "@/features/messages/lib/dateFormatters"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +export function MessageTimestamp({ + createdAt, + time, +}: { + createdAt: number; + time: string; +}) { + return ( + + +

{time}

+
+ + {formatFullDateTime(createdAt)} + +
+ ); +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index eda1ce45cc..e27fac42fe 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -2,6 +2,7 @@ import { ArrowRightLeft } from "lucide-react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { MessageTimestamp } from "./MessageTimestamp"; type SystemMessagePayload = { type: string; @@ -57,11 +58,13 @@ function describeSystemEvent( export function SystemMessageRow({ body, + createdAt, time, currentPubkey, profiles, }: { body: string; + createdAt: number; time: string; currentPubkey?: string; profiles?: UserProfileLookup; @@ -87,8 +90,8 @@ export function SystemMessageRow({

{description}

- - {time} + +
); diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 3bf84ab993..9fc761ad75 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,8 +1,13 @@ import * as React from "react"; +import { + formatDayHeading, + isSameDay, +} from "@/features/messages/lib/dateFormatters"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; import { SystemMessageRow } from "./SystemMessageRow"; @@ -31,30 +36,51 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onToggleReaction, profiles, }: TimelineMessageListProps) { - return messages.map((message) => - message.kind === KIND_SYSTEM_MESSAGE ? ( - - ) : ( - - ), - ); + const elements: React.ReactNode[] = []; + + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const prev = i > 0 ? messages[i - 1] : null; + + if (!prev || !isSameDay(prev.createdAt, message.createdAt)) { + elements.push( + , + ); + } + + if (message.kind === KIND_SYSTEM_MESSAGE) { + elements.push( + , + ); + } else { + elements.push( + , + ); + } + } + + return elements; }); diff --git a/desktop/src/features/messages/ui/useStickyDayHeader.ts b/desktop/src/features/messages/ui/useStickyDayHeader.ts new file mode 100644 index 0000000000..eac386cb56 --- /dev/null +++ b/desktop/src/features/messages/ui/useStickyDayHeader.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +/** + * Tracks the day label of the topmost visible day-divider in the scroll + * container so a floating header can mirror it. + * + * Returns `null` when the first divider is still fully visible (no header + * needed) or when there are no dividers in the DOM. + */ +export function useStickyDayHeader( + scrollContainerRef: React.RefObject, +) { + const [label, setLabel] = React.useState(null); + + const update = React.useCallback(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + const dividers = container.querySelectorAll( + "[data-testid='message-timeline-day-divider']", + ); + if (dividers.length === 0) { + setLabel(null); + return; + } + + const containerTop = container.getBoundingClientRect().top; + + // Walk dividers from the end — the last one whose top is at or above the + // container's top edge is the "current" day. + let current: string | null = null; + for (let i = dividers.length - 1; i >= 0; i--) { + const rect = dividers[i].getBoundingClientRect(); + if (rect.top <= containerTop + 4) { + current = dividers[i].getAttribute("data-day-label"); + break; + } + } + + setLabel(current); + }, [scrollContainerRef]); + + React.useEffect(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + container.addEventListener("scroll", update, { passive: true }); + // Run once on mount so the header appears if already scrolled. + update(); + return () => { + container.removeEventListener("scroll", update); + }; + }, [scrollContainerRef, update]); + + return label; +} diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 3c6e05a16d..922d539d76 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -223,13 +223,13 @@ test("replaces the channel pane when switching channels", async ({ page }) => { "Welcome to #general", ); await expect(page.getByTestId("message-timeline")).toHaveCount(1); - await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(1); + await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0); await page.getByTestId("channel-engineering").click(); await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("message-empty")).toBeVisible(); await expect(page.getByTestId("message-timeline")).toHaveCount(1); - await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(1); + await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0); }); test("sends a mocked channel message", async ({ page }) => {