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 (
+
+ );
+}
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 ? (
-
-
+
+ {!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 }) => {