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:
parent
3b1d8bd2e5
commit
fe001b7fc2
4 changed files with 215 additions and 86 deletions
183
apps/main/src/components/ChatMessages.tsx
Normal file
183
apps/main/src/components/ChatMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ vi.mock("../hooks/tablos", () => ({
|
|||
{ id: "tablo-2", name: "Test Tablo 2" },
|
||||
],
|
||||
}),
|
||||
useTabloMembers: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
describe("ChatPage", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in a new issue