mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
✨ 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
This commit is contained in:
parent
b39ec16ff0
commit
a4ae5b7e5e
5 changed files with 198 additions and 1 deletions
|
|
@ -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 { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getMessageTimestamp } from '~/utils';
|
||||
|
||||
/**
|
||||
* Inline message timestamp shown next to the author name in the message header.
|
||||
* Reveals on row hover (desktop) and stays visible on touch. 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();
|
||||
const timestamp = useMemo(
|
||||
() => getMessageTimestamp(value, i18n.language),
|
||||
[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 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue