feat: add day info to chat message timeline (#214)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-02 15:57:23 -07:00
committed by GitHub
parent 1f21085765
commit 3e006a2d9e
10 changed files with 334 additions and 126 deletions

View File

@@ -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()
);
}

View File

@@ -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,

View File

@@ -0,0 +1,18 @@
import { Separator } from "@/shared/ui/separator";
export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="flex items-center gap-3 py-2"
data-testid="message-timeline-day-divider"
data-day-label={label}
>
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{label}
</p>
<Separator className="flex-1" />
</section>
);
}

View File

@@ -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)
</p>
) : null}
<p className="whitespace-nowrap">{message.time}</p>
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
/>
</div>
</div>
{renderBody()}

View File

@@ -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 (
<div className="relative min-h-0 flex-1">
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{isFetchingOlder ? (
<div className="flex justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : null}
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
<div
className="flex items-center gap-3 py-2"
data-testid="message-timeline-beginning"
>
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Beginning of conversation
</p>
<Separator className="flex-1" />
</div>
) : null}
<TooltipProvider delayDuration={200}>
<div className="relative min-h-0 flex-1">
{stickyDayLabel && !isAtBottom ? (
<div
className="flex items-center gap-3"
data-testid="message-timeline-day-divider"
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-4 pt-2 sm:px-6"
data-testid="message-timeline-sticky-day"
>
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Today
<p className="rounded-full bg-muted/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground shadow-sm backdrop-blur-sm">
{stickyDayLabel}
</p>
<Separator className="flex-1" />
</div>
{isLoading ? <TimelineSkeleton /> : null}
{!isLoading && messages.length === 0 ? (
<div
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{!isLoading && messages.length > 0 ? (
<TimelineMessageList
activeReplyTargetId={activeReplyTargetId}
currentPubkey={currentPubkey}
highlightedMessageId={highlightedMessageId}
messages={messages}
onEdit={onEdit}
onReply={onReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
) : null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="message-scroll-to-latest"
onClick={() => {
scrollToBottom("smooth");
}}
size="sm"
type="button"
) : null}
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
ref={contentRef}
>
<ArrowDown className="h-4 w-4" />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
</Button>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{isFetchingOlder ? (
<div className="flex justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : null}
{!hasOlderMessages && !isLoading && messages.length > 0 ? (
<div
className="flex items-center gap-3 py-2"
data-testid="message-timeline-beginning"
>
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Beginning of conversation
</p>
<Separator className="flex-1" />
</div>
) : null}
{isLoading ? <TimelineSkeleton /> : null}
{!isLoading && messages.length === 0 ? (
<div
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
data-testid="message-empty"
>
<p className="text-base font-semibold tracking-tight">
{emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{!isLoading && messages.length > 0 ? (
<TimelineMessageList
activeReplyTargetId={activeReplyTargetId}
currentPubkey={currentPubkey}
highlightedMessageId={highlightedMessageId}
messages={messages}
onEdit={onEdit}
onReply={onReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
) : null}
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
</div>
</div>
) : null}
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
data-testid="message-scroll-to-latest"
onClick={() => {
scrollToBottom("smooth");
}}
size="sm"
type="button"
>
<ArrowDown className="h-4 w-4" />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
</Button>
</div>
) : null}
</div>
</TooltipProvider>
);
});

View File

@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<p className="cursor-default whitespace-nowrap">{time}</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -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({
<ArrowRightLeft className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">{description}</p>
<span className="ml-auto whitespace-nowrap text-xs text-muted-foreground/60">
{time}
<span className="ml-auto text-xs text-muted-foreground/60">
<MessageTimestamp createdAt={createdAt} time={time} />
</span>
</div>
);

View File

@@ -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 ? (
<SystemMessageRow
key={message.id}
body={message.body}
currentPubkey={currentPubkey}
profiles={profiles}
time={message.time}
/>
) : (
<MessageRow
key={message.id}
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
message={message}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
/>
),
);
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(
<DayDivider
key={`day-${message.createdAt}`}
label={formatDayHeading(message.createdAt)}
/>,
);
}
if (message.kind === KIND_SYSTEM_MESSAGE) {
elements.push(
<SystemMessageRow
key={message.id}
body={message.body}
createdAt={message.createdAt}
currentPubkey={currentPubkey}
profiles={profiles}
time={message.time}
/>,
);
} else {
elements.push(
<MessageRow
key={message.id}
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
message={message}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
/>,
);
}
}
return elements;
});

View File

@@ -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<HTMLDivElement | null>,
) {
const [label, setLabel] = React.useState<string | null>(null);
const update = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const dividers = container.querySelectorAll<HTMLElement>(
"[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;
}

View File

@@ -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 }) => {