mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-15 23:43:06 +03:00
⌚ feat: Show Message Timestamps on Hover (#13709)
* ✨ feat: Show Message Timestamps on Hover Reveal a message's time inline next to the author name on hover. Recent messages (under 24h) show a relative time ("10 minutes ago") with the absolute date on hover; older messages show the absolute date directly. A shared MessageTimestamp component is used by both MessageRender and ContentRender, with createdAt added to their memo comparators so the timestamp appears once it's available. Resolves #5199 * 🎨 fix: Gate message timestamp reveal on hover capability, not width Use a (hover: hover) media query instead of the md: breakpoint so the timestamp stays visible on touch and other non-hover devices (e.g. tablets wider than md), while still revealing on hover/focus where a pointer supports it. * 🎨 fix: Show message timestamps across all renderers and keep them live Extend the hover timestamp to the Assistants (MessageParts), shared-link, search, and parallel/multi-response renderers so every prompt and response shows when it was sent. The parent message's createdAt is threaded down to parallel column headers (SiblingHeader). Add a shared, ref-counted minute ticker (useTimeTick) so relative labels like "2 minutes ago" stay current while a conversation is left open instead of freezing at first render.
This commit is contained in:
@@ -6,14 +6,14 @@ import type {
|
||||
TAttachment,
|
||||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
|
||||
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
|
||||
import { mapAttachments, groupSequentialToolCalls } from '~/utils';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import { EditTextPart, EmptyText } from './Parts';
|
||||
import PendingSkillCall from './Parts/PendingSkillCall';
|
||||
import { EditTextPart, EmptyText } from './Parts';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import ToolCallGroup from './ToolCallGroup';
|
||||
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
|
||||
import Container from './Container';
|
||||
import Part from './Part';
|
||||
|
||||
@@ -105,6 +105,8 @@ type ContentPartsProps = {
|
||||
* the full message object) so `React.memo` stays shallow-happy.
|
||||
*/
|
||||
manualSkills?: string[];
|
||||
/** ISO timestamp of the parent message, surfaced in parallel column headers. */
|
||||
createdAt?: string | null;
|
||||
conversationId?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
@@ -142,6 +144,7 @@ const ContentParts = memo(function ContentParts({
|
||||
conversationId,
|
||||
isCreatedByUser,
|
||||
isLatestMessage,
|
||||
createdAt,
|
||||
}: ContentPartsProps) {
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
@@ -375,6 +378,7 @@ const ContentParts = memo(function ContentParts({
|
||||
<ParallelContentRenderer
|
||||
content={content}
|
||||
messageId={messageId}
|
||||
createdAt={createdAt}
|
||||
conversationId={conversationId}
|
||||
attachments={attachments}
|
||||
searchResults={searchResults}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { EmptyText } from './Parts';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import SiblingHeader from './SiblingHeader';
|
||||
import { EmptyText } from './Parts';
|
||||
import Container from './Container';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -137,6 +137,7 @@ type ParallelColumnsProps = {
|
||||
columns: ParallelColumn[];
|
||||
groupId: number;
|
||||
messageId: string;
|
||||
createdAt?: string | null;
|
||||
isSubmitting: boolean;
|
||||
lastContentIdx: number;
|
||||
conversationId?: string | null;
|
||||
@@ -150,6 +151,7 @@ export const ParallelColumns = memo(function ParallelColumns({
|
||||
columns,
|
||||
groupId,
|
||||
messageId,
|
||||
createdAt,
|
||||
conversationId,
|
||||
isSubmitting,
|
||||
lastContentIdx,
|
||||
@@ -169,6 +171,7 @@ export const ParallelColumns = memo(function ParallelColumns({
|
||||
<SiblingHeader
|
||||
agentId={agentId}
|
||||
messageId={messageId}
|
||||
createdAt={createdAt}
|
||||
isSubmitting={isSubmitting}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
@@ -193,6 +196,7 @@ export const ParallelColumns = memo(function ParallelColumns({
|
||||
type ParallelContentRendererProps = {
|
||||
content?: Array<TMessageContentParts | undefined>;
|
||||
messageId: string;
|
||||
createdAt?: string | null;
|
||||
conversationId?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
@@ -207,6 +211,7 @@ type ParallelContentRendererProps = {
|
||||
export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
||||
content,
|
||||
messageId,
|
||||
createdAt,
|
||||
conversationId,
|
||||
attachments,
|
||||
searchResults,
|
||||
@@ -253,6 +258,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
||||
columns={columns}
|
||||
groupId={groupId}
|
||||
messageId={messageId}
|
||||
createdAt={createdAt}
|
||||
renderPart={renderPart}
|
||||
isSubmitting={isSubmitting}
|
||||
conversationId={conversationId}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GitBranchPlus } from 'lucide-react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { EModelEndpoint, parseEphemeralAgentId, stripAgentIdSuffix } from 'librechat-data-provider';
|
||||
import type { TMessage, Agent } from 'librechat-data-provider';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import { useBranchMessageMutation } from '~/data-provider/Messages';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
@@ -14,6 +15,8 @@ type SiblingHeaderProps = {
|
||||
agentId?: string;
|
||||
/** The messageId of the parent message */
|
||||
messageId?: string;
|
||||
/** ISO timestamp of the parent message */
|
||||
createdAt?: string | null;
|
||||
/** The conversationId */
|
||||
conversationId?: string | null;
|
||||
/** Whether a submission is in progress */
|
||||
@@ -27,6 +30,7 @@ type SiblingHeaderProps = {
|
||||
export default function SiblingHeader({
|
||||
agentId,
|
||||
messageId,
|
||||
createdAt,
|
||||
conversationId,
|
||||
isSubmitting,
|
||||
}: SiblingHeaderProps) {
|
||||
@@ -117,6 +121,7 @@ export default function SiblingHeader({
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
|
||||
<MessageTimestamp value={createdAt} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import ContentParts from './Content/ContentParts';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
@@ -134,6 +135,7 @@ export default function Message(props: TMessageProps) {
|
||||
{getHeaderPrefixForScreenReader(message, localize)}
|
||||
</span>
|
||||
{name}
|
||||
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
|
||||
</h2>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import Icon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import SearchContent from './Content/SearchContent';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import SearchButtons from './SearchButtons';
|
||||
@@ -26,7 +27,10 @@ const MessageBody = ({ message, messageLabel, fontSize }) => (
|
||||
<div
|
||||
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>
|
||||
{messageLabel}
|
||||
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
|
||||
</div>
|
||||
<SearchContent message={message} />
|
||||
<SubRow classes="text-xs">
|
||||
<MinimalHoverButtons message={message} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
@@ -72,6 +73,7 @@ function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRende
|
||||
prevMsg.text === nextMsg.text &&
|
||||
prevMsg.error === nextMsg.error &&
|
||||
prevMsg.unfinished === nextMsg.unfinished &&
|
||||
prevMsg.createdAt === nextMsg.createdAt &&
|
||||
prevMsg.depth === nextMsg.depth &&
|
||||
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&
|
||||
(prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) &&
|
||||
@@ -212,6 +214,7 @@ const MessageRender = memo(function MessageRender({
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>
|
||||
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
|
||||
{messageLabel}
|
||||
<MessageTimestamp value={msg.createdAt ?? msg.clientTimestamp} />
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
31
client/src/components/Chat/Messages/ui/MessageTimestamp.tsx
Normal file
31
client/src/components/Chat/Messages/ui/MessageTimestamp.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useTimeTick from '~/hooks/useTimeTick';
|
||||
import { getMessageTimestamp } from '~/utils';
|
||||
|
||||
/**
|
||||
* Inline message timestamp shown next to the author name in the message header.
|
||||
* On hover-capable pointers it reveals on row hover/focus; on touch and other
|
||||
* non-hover devices it stays visible. Recent messages show the relative form
|
||||
* ("10 minutes ago") with the absolute date on hover; older messages show the
|
||||
* absolute date directly.
|
||||
*/
|
||||
export default function MessageTimestamp({ value }: { value?: string | null }) {
|
||||
const { i18n } = useTranslation();
|
||||
// Re-render on a shared interval so relative labels stay current while idle.
|
||||
useTimeTick();
|
||||
const timestamp = getMessageTimestamp(value, i18n.language);
|
||||
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<time
|
||||
dateTime={timestamp.iso}
|
||||
title={timestamp.isRecent ? timestamp.absolute : undefined}
|
||||
className="ml-2 text-xs font-normal text-text-secondary transition-opacity duration-200 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0"
|
||||
>
|
||||
{timestamp.isRecent ? timestamp.relative : timestamp.absolute}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common';
|
||||
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
@@ -70,6 +71,7 @@ function areContentRenderPropsEqual(prev: ContentRenderProps, next: ContentRende
|
||||
prevMsg.text === nextMsg.text &&
|
||||
prevMsg.error === nextMsg.error &&
|
||||
prevMsg.unfinished === nextMsg.unfinished &&
|
||||
prevMsg.createdAt === nextMsg.createdAt &&
|
||||
prevMsg.depth === nextMsg.depth &&
|
||||
prevMsg.isCreatedByUser === nextMsg.isCreatedByUser &&
|
||||
(prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) &&
|
||||
@@ -205,6 +207,7 @@ const ContentRender = memo(function ContentRender({
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>
|
||||
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
|
||||
{messageLabel}
|
||||
<MessageTimestamp value={msg.createdAt ?? msg.clientTimestamp} />
|
||||
</h2>
|
||||
)}
|
||||
|
||||
@@ -223,6 +226,7 @@ const ContentRender = memo(function ContentRender({
|
||||
isLatestMessage={isLatestMessage}
|
||||
isSubmitting={isSubmitting}
|
||||
isCreatedByUser={msg.isCreatedByUser}
|
||||
createdAt={msg.createdAt ?? msg.clientTimestamp}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={msg.content as Array<TMessageContentParts | undefined>}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
|
||||
import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
@@ -65,7 +66,10 @@ export default function Message(props: TMessageProps) {
|
||||
<div
|
||||
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
||||
<div className={cn('select-none font-semibold', fontSize)}>
|
||||
{messageLabel}
|
||||
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
|
||||
</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||
<MessageContext.Provider
|
||||
|
||||
33
client/src/hooks/useTimeTick.ts
Normal file
33
client/src/hooks/useTimeTick.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let tick = 0;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const subscribe = (onStoreChange: () => void): (() => void) => {
|
||||
listeners.add(onStoreChange);
|
||||
if (intervalId === null) {
|
||||
intervalId = setInterval(() => {
|
||||
tick += 1;
|
||||
listeners.forEach((listener) => listener());
|
||||
}, 60_000);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(onStoreChange);
|
||||
if (listeners.size === 0 && intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = (): number => tick;
|
||||
|
||||
/**
|
||||
* Subscribes to a shared, ref-counted ticker that fires once a minute, so components
|
||||
* displaying relative time stay current while a view is left open. A single interval
|
||||
* is shared across all subscribers and is cleared when the last one unsubscribes.
|
||||
*/
|
||||
export default function useTimeTick(): number {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { getMessageAriaLabel, getHeaderPrefixForScreenReader } from '../messages';
|
||||
import {
|
||||
isValidTimestamp,
|
||||
getMessageAriaLabel,
|
||||
getMessageTimestamp,
|
||||
getHeaderPrefixForScreenReader,
|
||||
} from '../messages';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
com_endpoint_message: 'Message',
|
||||
@@ -80,3 +85,70 @@ describe('getHeaderPrefixForScreenReader', () => {
|
||||
expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidTimestamp', () => {
|
||||
it('returns false for missing values', () => {
|
||||
expect(isValidTimestamp(undefined)).toBe(false);
|
||||
expect(isValidTimestamp(null)).toBe(false);
|
||||
expect(isValidTimestamp('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unparseable strings', () => {
|
||||
expect(isValidTimestamp('not-a-date')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for ISO date strings', () => {
|
||||
expect(isValidTimestamp('2026-06-12T15:42:00.000Z')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageTimestamp', () => {
|
||||
const NOW = new Date('2026-06-12T15:42:00.000Z').getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns null for missing or invalid values', () => {
|
||||
expect(getMessageTimestamp(undefined, 'en-US')).toBeNull();
|
||||
expect(getMessageTimestamp(null, 'en-US')).toBeNull();
|
||||
expect(getMessageTimestamp('not-a-date', 'en-US')).toBeNull();
|
||||
});
|
||||
|
||||
it('formats relative and absolute time for a recent message', () => {
|
||||
const twoHoursAgo = new Date(NOW - 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = getMessageTimestamp(twoHoursAgo, 'en-US');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.relative).toBe('2 hours ago');
|
||||
expect(result?.iso).toBe(twoHoursAgo);
|
||||
expect(result?.absolute).toContain('2026');
|
||||
});
|
||||
|
||||
it('flags messages under 24h as recent (prefer relative)', () => {
|
||||
const justUnderADay = new Date(NOW - 23 * 60 * 60 * 1000).toISOString();
|
||||
expect(getMessageTimestamp(justUnderADay, 'en-US')?.isRecent).toBe(true);
|
||||
});
|
||||
|
||||
it('flags older messages as not recent (prefer absolute date)', () => {
|
||||
const overADay = new Date(NOW - 25 * 60 * 60 * 1000).toISOString();
|
||||
const monthAgo = new Date(NOW - 38 * 24 * 60 * 60 * 1000).toISOString();
|
||||
expect(getMessageTimestamp(overADay, 'en-US')?.isRecent).toBe(false);
|
||||
expect(getMessageTimestamp(monthAgo, 'en-US')?.isRecent).toBe(false);
|
||||
});
|
||||
|
||||
it('uses "now" for the current instant', () => {
|
||||
const result = getMessageTimestamp(new Date(NOW).toISOString(), 'en-US');
|
||||
expect(result?.relative).toBe('now');
|
||||
expect(result?.isRecent).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the default locale for a malformed locale tag', () => {
|
||||
const iso = new Date(NOW - 60 * 1000).toISOString();
|
||||
expect(() => getMessageTimestamp(iso, 'not a locale!!')).not.toThrow();
|
||||
expect(getMessageTimestamp(iso, 'not a locale!!')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,6 +344,94 @@ export const getHeaderPrefixForScreenReader = (
|
||||
: `${localize('com_ui_response')}${suffix}: `;
|
||||
};
|
||||
|
||||
export type MessageTimestamp = {
|
||||
/** Localized relative time, e.g. "2 hours ago". */
|
||||
relative: string;
|
||||
/** Localized absolute date and time, e.g. "Jun 12, 2026, 3:42 PM". */
|
||||
absolute: string;
|
||||
/** ISO 8601 string for the `<time>` element's `dateTime` attribute. */
|
||||
iso: string;
|
||||
/**
|
||||
* True when the message is recent enough that the relative form ("10 minutes ago")
|
||||
* reads better than the absolute date. Past this window the absolute date is clearer.
|
||||
*/
|
||||
isRecent: boolean;
|
||||
};
|
||||
|
||||
/** Below this age the relative form is preferred over the absolute date. */
|
||||
const RECENT_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Returns true when `value` parses to a valid date. */
|
||||
export const isValidTimestamp = (value?: string | null): value is string => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return !Number.isNaN(new Date(value).getTime());
|
||||
};
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
|
||||
{ amount: 60, unit: 'second' },
|
||||
{ amount: 60, unit: 'minute' },
|
||||
{ amount: 24, unit: 'hour' },
|
||||
{ amount: 7, unit: 'day' },
|
||||
{ amount: 4.34524, unit: 'week' },
|
||||
{ amount: 12, unit: 'month' },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
|
||||
];
|
||||
|
||||
/** Returns the locale only when it is a syntactically valid BCP-47 tag, else undefined. */
|
||||
const resolveLocale = (locale?: string): string | undefined => {
|
||||
if (!locale) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
Intl.DateTimeFormat.supportedLocalesOf(locale);
|
||||
return locale;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const formatRelativeTime = (from: Date, to: Date, locale?: string): string => {
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
let duration = (from.getTime() - to.getTime()) / 1000;
|
||||
for (const division of RELATIVE_TIME_DIVISIONS) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return formatter.format(Math.round(duration), division.unit);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
return formatter.format(Math.round(duration), 'year');
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a message timestamp into locale-aware relative and absolute strings.
|
||||
* Returns null when the value is missing or unparseable, so callers can skip
|
||||
* rendering the timestamp entirely.
|
||||
*/
|
||||
export const getMessageTimestamp = (
|
||||
value?: string | null,
|
||||
locale?: string,
|
||||
): MessageTimestamp | null => {
|
||||
if (!isValidTimestamp(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
const now = new Date(Date.now());
|
||||
const safeLocale = resolveLocale(locale);
|
||||
|
||||
return {
|
||||
iso: date.toISOString(),
|
||||
relative: formatRelativeTime(date, now, safeLocale),
|
||||
absolute: new Intl.DateTimeFormat(safeLocale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date),
|
||||
isRecent: Math.abs(now.getTime() - date.getTime()) < RECENT_THRESHOLD_MS,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates initial content parts for dual message display with agent-based grouping.
|
||||
* Sets up primary and added agent content parts with agentId for column rendering.
|
||||
|
||||
Reference in New Issue
Block a user