From 91e4f4ed30475373bf5cc9ed27063943f2935595 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 11 Jun 2026 21:04:12 +0300 Subject: [PATCH] frontend: messages visual polish Same layout, better inbox: avatars on conversation rows, a numeric unread badge (kept live by the socket handler), primary-tinted timestamps for unread rows and softer hover states. In the thread: day separators (Today/Yesterday/date), consecutive same-sender messages within 5 minutes grouped with one sender label + one timestamp and tightened corners, and a "Start a conversation" button on the empty state. Co-Authored-By: Claude Fable 5 --- .../components/messages/messages-view.tsx | 191 +++++++++++++----- frontend/lib/messages.ts | 2 + 2 files changed, 147 insertions(+), 46 deletions(-) diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 4f74582..50bde55 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -3,6 +3,7 @@ import { Mail, Plus, Search, SendHorizonal } from "lucide-react"; import { type FormEvent, + Fragment, useEffect, useMemo, useRef, @@ -22,6 +23,7 @@ import { } from "@/components/ui/dialog"; import { Empty, + EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, @@ -59,11 +61,36 @@ function formatTime(iso: string): string { }); } +function sameDay(a: string, b: string): boolean { + return new Date(a).toDateString() === new Date(b).toDateString(); +} + +// Consecutive messages from one sender within this window render as a group: +// one sender label, one timestamp, tighter spacing. +const GROUP_WINDOW_MS = 5 * 60 * 1000; + export function MessagesView() { const { t } = useTranslation(); const { data: session } = authClient.useSession(); const myId = session?.user?.id ?? ""; + // "Today" / "Yesterday" / "Jun 9, 2026" for the thread's day separators. + const formatDay = (iso: string): string => { + const date = new Date(iso); + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (date.toDateString() === now.toDateString()) return t("messages.today"); + if (date.toDateString() === yesterday.toDateString()) { + return t("messages.yesterday"); + } + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + const [conversations, setConversations] = useState([]); const [selectedId, setSelectedId] = useState(null); const [messages, setMessages] = useState([]); @@ -107,11 +134,13 @@ export function MessagesView() { return prev; } const isSelected = selectedIdRef.current === msg.conversationId; + const incoming = msg.senderId !== myIdRef.current && !isSelected; const updated: ConversationSummary = { ...existing, lastMessage: msg, updatedAt: msg.createdAt, - unread: msg.senderId !== myIdRef.current && !isSelected, + unread: incoming, + unreadCount: incoming ? existing.unreadCount + 1 : 0, }; return [updated, ...prev.filter((c) => c.id !== msg.conversationId)]; }); @@ -166,7 +195,9 @@ export function MessagesView() { socket.emit("conversation:join", id); socket.emit("message:read", id); setConversations((prev) => - prev.map((c) => (c.id === id ? { ...c, unread: false } : c)), + prev.map((c) => + c.id === id ? { ...c, unread: false, unreadCount: 0 } : c, + ), ); }; @@ -262,35 +293,58 @@ export function MessagesView() { return ( ); }) @@ -324,36 +378,75 @@ export function MessagesView() { className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4" ref={scrollRef} > -
- {messages.map((m) => { +
+ {messages.map((m, i) => { + const prev = messages[i - 1]; + const next = messages[i + 1]; const out = m.senderId === myId; + const newDay = !prev || !sameDay(prev.createdAt, m.createdAt); + // A bubble starts/ends a group at sender changes, day breaks + // or >5 min gaps; the group shares one label + timestamp. + const startsGroup = + newDay || + !prev || + prev.senderId !== m.senderId || + +new Date(m.createdAt) - +new Date(prev.createdAt) > + GROUP_WINDOW_MS; + const endsGroup = + !next || + next.senderId !== m.senderId || + !sameDay(m.createdAt, next.createdAt) || + +new Date(next.createdAt) - +new Date(m.createdAt) > + GROUP_WINDOW_MS; return ( -
- {selected.isGroup && !out && ( - - {m.senderName} - + + {newDay && ( +
0 && "mt-5", + )} + > +
+ + {formatDay(m.createdAt)} + +
+
)}
- {m.body} + {selected.isGroup && !out && startsGroup && ( + + {m.senderName} + + )} +
+ {m.body} +
+ {endsGroup && ( + + {formatTime(m.createdAt)} + + )}
- - {formatTime(m.createdAt)} - -
+ ); })}
@@ -394,6 +487,12 @@ export function MessagesView() { {t("messages.emptyDescription")} + + +
)} diff --git a/frontend/lib/messages.ts b/frontend/lib/messages.ts index e9c770a..8f463bb 100644 --- a/frontend/lib/messages.ts +++ b/frontend/lib/messages.ts @@ -22,6 +22,8 @@ export type ConversationSummary = { participants: Participant[]; lastMessage: ConversationMessage | null; unread: boolean; + // Messages from others newer than the caller's read pointer. + unreadCount: number; updatedAt: string; };