feat: add date separators between messages from different days
Shows a subtle horizontal line with the date label (Today, Yesterday, or full date) when messages span multiple days. Helps orient users when scrolling through long conversation histories. Includes i18n support (EN/FR) for the date labels.
This commit is contained in:
@@ -5,6 +5,7 @@ import { TypingIndicator } from './TypingIndicator';
|
|||||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||||
import { Bot, ArrowDown } from 'lucide-react';
|
import { Bot, ArrowDown } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
import { getLocale, type TranslationKey } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@@ -41,6 +42,24 @@ function hasStreamedText(messages: ChatMessage[]): boolean {
|
|||||||
return last.blocks.some(b => b.type === 'text' && b.text.trim().length > 0) || (last.content?.trim().length > 0);
|
return last.blocks.some(b => b.type === 'text' && b.text.trim().length > 0) || (last.content?.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateSeparator(ts: number, t: (k: TranslationKey) => string): string {
|
||||||
|
const date = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const locale = getLocale();
|
||||||
|
const bcp47 = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
|
|
||||||
|
if (date.toDateString() === now.toDateString()) return t('time.today');
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) return t('time.yesterday');
|
||||||
|
return date.toLocaleDateString(bcp47, { weekday: 'long', day: 'numeric', month: 'long', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateKey(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */
|
/** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */
|
||||||
const SCROLL_THRESHOLD = 150;
|
const SCROLL_THRESHOLD = 150;
|
||||||
|
|
||||||
@@ -111,9 +130,26 @@ export function Chat({ messages, isGenerating, status, onSend, onAbort }: Props)
|
|||||||
<div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div>
|
<div className="text-sm mt-1 text-zinc-500">{t('chat.welcomeSub')}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.filter(hasVisibleContent).map(msg => (
|
{(() => {
|
||||||
<ChatMessageComponent key={msg.id} message={msg} onRetry={!isGenerating ? handleSend : undefined} />
|
let lastDateKey = '';
|
||||||
))}
|
return messages.filter(hasVisibleContent).map(msg => {
|
||||||
|
const dk = getDateKey(msg.timestamp);
|
||||||
|
const showSep = dk !== lastDateKey;
|
||||||
|
lastDateKey = dk;
|
||||||
|
return (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{showSep && (
|
||||||
|
<div className="flex items-center gap-3 py-3 px-4 select-none" aria-label={formatDateSeparator(msg.timestamp, t)}>
|
||||||
|
<div className="flex-1 h-px bg-white/8" />
|
||||||
|
<span className="text-[11px] font-medium text-zinc-500 uppercase tracking-wider">{formatDateSeparator(msg.timestamp, t)}</span>
|
||||||
|
<div className="flex-1 h-px bg-white/8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
{showTyping && <TypingIndicator />}
|
{showTyping && <TypingIndicator />}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const en = {
|
|||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
'time.yesterday': 'Yesterday',
|
'time.yesterday': 'Yesterday',
|
||||||
|
'time.today': 'Today',
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
'shortcuts.title': 'Keyboard Shortcuts',
|
'shortcuts.title': 'Keyboard Shortcuts',
|
||||||
@@ -131,6 +132,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'message.retry': 'Renvoyer le message',
|
'message.retry': 'Renvoyer le message',
|
||||||
|
|
||||||
'time.yesterday': 'Hier',
|
'time.yesterday': 'Hier',
|
||||||
|
'time.today': "Aujourd'hui",
|
||||||
|
|
||||||
'shortcuts.title': 'Raccourcis clavier',
|
'shortcuts.title': 'Raccourcis clavier',
|
||||||
'shortcuts.send': 'Envoyer le message',
|
'shortcuts.send': 'Envoyer le message',
|
||||||
|
|||||||
Reference in New Issue
Block a user