feat(chat): improve chat UI with date separators, sender names, and message alignment

- Add shared ChatMessages component with date separators (Aujourd'hui, Hier, etc.)
- Show sender name and avatar on incoming messages
- Own messages aligned to the right, others to the left
- Show message timestamps on each message
- Typing indicator shows member names
- Optimistic messages shown with reduced opacity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-11 17:32:13 +02:00
parent 3b1d8bd2e5
commit fe001b7fc2
4 changed files with 215 additions and 86 deletions

View file

@ -0,0 +1,183 @@
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
Avatar,
ChatContainer,
Message,
MessageInput,
MessageList,
MessageSeparator,
TypingIndicator,
} from "@chatscope/chat-ui-kit-react";
import { useMemo } from "react";
interface ChatMessage {
id: string;
userId: string;
text: string;
createdAt: string;
clientId: string;
optimistic?: boolean;
}
interface Member {
id: string;
name: string;
avatar_url: string | null;
}
interface ChatMessagesProps {
messages: ChatMessage[];
currentUserId: string;
members: Member[];
typingUsers: string[];
hasMoreMessages: boolean;
onLoadMore?: () => void;
onSend: (text: string) => void;
onTyping: () => void;
placeholder?: string;
}
function formatDateSeparator(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
if (isToday) return "Aujourd'hui";
if (isYesterday) return "Hier";
return date.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
function formatTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
function getInitials(name: string): string {
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export function ChatMessages({
messages,
currentUserId,
members,
typingUsers,
hasMoreMessages,
onLoadMore,
onSend,
onTyping,
placeholder = "Envoyer un message...",
}: ChatMessagesProps) {
const membersById = useMemo(() => {
const map = new Map<string, Member>();
for (const m of members) {
map.set(m.id, m);
}
return map;
}, [members]);
const getMemberName = (userId: string) =>
membersById.get(userId)?.name ?? "Utilisateur";
const typingContent = useMemo(() => {
if (typingUsers.length === 0) return null;
const names = typingUsers.map(getMemberName);
if (names.length === 1) return `${names[0]} écrit...`;
return `${names.join(", ")} écrivent...`;
}, [typingUsers, membersById]);
// Build messages with date separators
const elements = useMemo(() => {
const result: React.ReactNode[] = [];
let lastDate = "";
for (const msg of messages) {
const msgDate = new Date(msg.createdAt).toDateString();
if (msgDate !== lastDate) {
lastDate = msgDate;
result.push(
<MessageSeparator key={`sep-${msgDate}`}>
{formatDateSeparator(msg.createdAt)}
</MessageSeparator>
);
}
const isOwn = msg.userId === currentUserId;
const sender = getMemberName(msg.userId);
const member = membersById.get(msg.userId);
result.push(
<Message
key={msg.id}
model={{
message: msg.text,
sentTime: formatTime(msg.createdAt),
sender,
direction: isOwn ? "outgoing" : "incoming",
position: "single",
}}
avatarSpacer={isOwn}
style={{ opacity: msg.optimistic ? 0.6 : 1 }}
>
{!isOwn && (
<Avatar
src={member?.avatar_url ?? undefined}
name={sender}
size="sm"
>
{!member?.avatar_url && (
<div className="w-full h-full flex items-center justify-center bg-[#804EEC] text-white text-xs font-semibold rounded-full">
{getInitials(sender)}
</div>
)}
</Avatar>
)}
<Message.Header sender={!isOwn ? sender : undefined} sentTime={formatTime(msg.createdAt)} />
</Message>
);
}
return result;
}, [messages, currentUserId, membersById]);
const handleSend = (_innerHtml: string, textContent: string) => {
const text = textContent.trim();
if (!text) return;
onSend(text);
};
return (
<ChatContainer style={{ height: "100%" }}>
<MessageList
typingIndicator={
typingContent ? <TypingIndicator content={typingContent} /> : undefined
}
onYReachStart={hasMoreMessages ? onLoadMore : undefined}
>
{elements}
</MessageList>
<MessageInput
placeholder={placeholder}
onSend={handleSend}
onChange={onTyping}
attachButton={false}
/>
</ChatContainer>
);
}

View file

@ -1,15 +1,9 @@
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
ChatContainer,
Message,
MessageInput,
MessageList,
TypingIndicator,
} from "@chatscope/chat-ui-kit-react";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { useEffect } from "react";
import { useChat } from "../hooks/useChat";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { ChatMessages } from "./ChatMessages";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloDiscussionSectionProps {
@ -29,6 +23,8 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
markAsRead,
} = useChat(tablo.id);
const { data: members = [] } = useTabloMembers(tablo.id);
// Mark as read when opening the discussion
useEffect(() => {
if (messages.length > 0) {
@ -36,15 +32,9 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
}
}, [messages.length, markAsRead]);
const handleSend = (_innerHtml: string, textContent: string) => {
const text = textContent.trim();
if (!text) return;
sendMessage(text);
};
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex justify-between items-start mb-6">
<div className="flex justify-between items-start mb-4 shrink-0">
<div>
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
@ -53,34 +43,16 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
</div>
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">
<ChatContainer style={{ height: "100%" }}>
<MessageList
typingIndicator={
typingUsers.length > 0 ? (
<TypingIndicator content={`${typingUsers.length} personne(s) écrit...`} />
) : null
}
onYReachStart={hasMoreMessages ? loadMoreMessages : undefined}
>
{messages.map((msg) => (
<Message
key={msg.id}
model={{
message: msg.text,
sender: msg.userId,
direction: msg.userId === user.id ? "outgoing" : "incoming",
position: "normal",
}}
/>
))}
</MessageList>
<MessageInput
placeholder="Envoyer un message..."
onSend={handleSend}
onChange={sendTyping}
attachButton={false}
/>
</ChatContainer>
<ChatMessages
messages={messages}
currentUserId={user.id}
members={members}
typingUsers={typingUsers}
hasMoreMessages={hasMoreMessages}
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onTyping={sendTyping}
/>
</div>
</div>
);

View file

@ -32,6 +32,7 @@ vi.mock("../hooks/tablos", () => ({
{ id: "tablo-2", name: "Test Tablo 2" },
],
}),
useTabloMembers: () => ({ data: [] }),
}));
describe("ChatPage", () => {

View file

@ -1,18 +1,11 @@
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
ChatContainer,
MessageList,
Message,
MessageInput,
TypingIndicator,
} from "@chatscope/chat-ui-kit-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChatChannelPreview } from "../components/ChatChannelPreview";
import { ChatHeader } from "../components/ChatHeader";
import { ChatMessages } from "../components/ChatMessages";
import { useChat } from "../hooks/useChat";
import { useChatUnread } from "../hooks/useChatUnread";
import { useTablosList } from "../hooks/tablos";
import { useTablosList, useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
export function ChatPage() {
@ -29,9 +22,12 @@ export function ChatPage() {
sendTyping,
typingUsers,
onlineUsers,
loadMoreMessages,
hasMoreMessages,
markAsRead,
} = useChat(channelId);
const { data: members = [] } = useTabloMembers(channelId ?? "");
const activeTablo = tablos?.find((t) => t.id === channelId) ?? null;
// Mark as read when channel is focused
@ -41,12 +37,6 @@ export function ChatPage() {
}
}, [channelId, messages.length, markAsRead]);
const handleSend = (_innerHtml: string, textContent: string) => {
const text = textContent.trim();
if (!text) return;
sendMessage(text);
};
const handleChannelSelect = (tabloId: string) => {
navigate(`/chat/${tabloId}`);
};
@ -88,34 +78,17 @@ export function ChatPage() {
onlineUsers={onlineUsers}
/>
<div className="flex-1 overflow-hidden">
<ChatContainer>
<MessageList
typingIndicator={
typingUsers.length > 0 ? (
<TypingIndicator content="typing..." />
) : undefined
}
>
{messages.map((msg) => (
<Message
key={msg.id}
model={{
message: msg.text,
sentTime: msg.createdAt,
sender: msg.userId,
direction: msg.userId === user.id ? "outgoing" : "incoming",
position: "single",
}}
/>
))}
</MessageList>
<MessageInput
placeholder="Type a message..."
onSend={handleSend}
onChange={() => sendTyping()}
attachButton={false}
/>
</ChatContainer>
<ChatMessages
messages={messages}
currentUserId={user.id}
members={members}
typingUsers={typingUsers}
hasMoreMessages={hasMoreMessages}
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onTyping={sendTyping}
placeholder="Type a message..."
/>
</div>
</>
) : (