261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
|
|
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
||
|
|
import { VirtualizedList, useInfiniteScroll, useScrollPosition } from '@/components/ui/virtualized-list';
|
||
|
|
import { Message } from '@/types/api';
|
||
|
|
import { sanitizeChatMessage } from '@/utils/sanitize';
|
||
|
|
import { formatDistanceToNow } from 'date-fns';
|
||
|
|
import { fr } from 'date-fns/locale';
|
||
|
|
|
||
|
|
interface VirtualizedChatMessagesProps {
|
||
|
|
messages: Message[];
|
||
|
|
hasNextPage: boolean;
|
||
|
|
isFetching: boolean;
|
||
|
|
fetchNextPage: () => void;
|
||
|
|
className?: string;
|
||
|
|
onMessageClick?: (message: Message) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MESSAGE_HEIGHT = 80; // Hauteur estimée d'un message
|
||
|
|
const CONTAINER_HEIGHT = 400; // Hauteur du conteneur de messages
|
||
|
|
|
||
|
|
export function VirtualizedChatMessages({
|
||
|
|
messages,
|
||
|
|
hasNextPage,
|
||
|
|
isFetching,
|
||
|
|
fetchNextPage,
|
||
|
|
className = '',
|
||
|
|
onMessageClick,
|
||
|
|
}: VirtualizedChatMessagesProps) {
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const { handleItemsRendered } = useInfiniteScroll(
|
||
|
|
messages,
|
||
|
|
hasNextPage,
|
||
|
|
isFetching,
|
||
|
|
fetchNextPage,
|
||
|
|
5 // Charger plus quand on est à 5 messages du bas
|
||
|
|
);
|
||
|
|
|
||
|
|
// Mémoriser les messages pour éviter les re-renders inutiles
|
||
|
|
const memoizedMessages = useMemo(() => messages, [messages]);
|
||
|
|
|
||
|
|
// Rendu d'un message individuel
|
||
|
|
const renderMessage = useCallback(
|
||
|
|
(message: Message, index: number) => (
|
||
|
|
<div
|
||
|
|
key={message.id}
|
||
|
|
className={`p-3 border-b border-gray-200 hover:bg-gray-50 cursor-pointer transition-colors ${
|
||
|
|
onMessageClick ? 'hover:shadow-sm' : ''
|
||
|
|
}`}
|
||
|
|
onClick={() => onMessageClick?.(message)}
|
||
|
|
style={{ minHeight: MESSAGE_HEIGHT }}
|
||
|
|
>
|
||
|
|
<div className="flex items-start space-x-3">
|
||
|
|
{/* Avatar */}
|
||
|
|
<div className="flex-shrink-0">
|
||
|
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||
|
|
{message.user?.username?.charAt(0).toUpperCase() || '?'}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Contenu du message */}
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="flex items-center space-x-2 mb-1">
|
||
|
|
<span className="text-sm font-medium text-gray-900">
|
||
|
|
{message.user?.username || 'Utilisateur inconnu'}
|
||
|
|
</span>
|
||
|
|
<span className="text-xs text-gray-500">
|
||
|
|
{formatDistanceToNow(new Date(message.created_at), {
|
||
|
|
addSuffix: true,
|
||
|
|
locale: fr,
|
||
|
|
})}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Contenu du message avec sanitisation */}
|
||
|
|
<div
|
||
|
|
className="text-sm text-gray-700 break-words"
|
||
|
|
dangerouslySetInnerHTML={{
|
||
|
|
__html: sanitizeChatMessage(message.content),
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Métadonnées du message */}
|
||
|
|
{message.metadata && (
|
||
|
|
<div className="mt-2 text-xs text-gray-500">
|
||
|
|
{message.metadata.message_type && (
|
||
|
|
<span className="inline-block px-2 py-1 bg-gray-100 rounded-full mr-2">
|
||
|
|
{message.metadata.message_type}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{message.metadata.attachments && message.metadata.attachments.length > 0 && (
|
||
|
|
<span className="inline-block px-2 py-1 bg-blue-100 text-blue-800 rounded-full">
|
||
|
|
📎 {message.metadata.attachments.length} fichier(s)
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Indicateurs de statut */}
|
||
|
|
<div className="mt-1 flex items-center space-x-1">
|
||
|
|
{message.is_edited && (
|
||
|
|
<span className="text-xs text-gray-400">(modifié)</span>
|
||
|
|
)}
|
||
|
|
{message.is_deleted && (
|
||
|
|
<span className="text-xs text-red-400">(supprimé)</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
[onMessageClick]
|
||
|
|
);
|
||
|
|
|
||
|
|
// Gestion du scroll vers le bas pour les nouveaux messages
|
||
|
|
const scrollToBottom = useCallback(() => {
|
||
|
|
if (containerRef.current) {
|
||
|
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Auto-scroll vers le bas quand de nouveaux messages arrivent
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isFetching && messages.length > 0) {
|
||
|
|
const lastMessage = messages[messages.length - 1];
|
||
|
|
const isRecentMessage = Date.now() - new Date(lastMessage.created_at).getTime() < 5000; // 5 secondes
|
||
|
|
|
||
|
|
if (isRecentMessage) {
|
||
|
|
scrollToBottom();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, [messages.length, isFetching, scrollToBottom]);
|
||
|
|
|
||
|
|
// Indicateur de chargement
|
||
|
|
const loadingIndicator = useMemo(() => {
|
||
|
|
if (!isFetching) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex justify-center items-center py-4">
|
||
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||
|
|
<span className="ml-2 text-sm text-gray-500">Chargement des messages...</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}, [isFetching]);
|
||
|
|
|
||
|
|
// Message vide
|
||
|
|
if (messages.length === 0 && !isFetching) {
|
||
|
|
return (
|
||
|
|
<div className={`flex items-center justify-center h-full ${className}`}>
|
||
|
|
<div className="text-center">
|
||
|
|
<div className="text-gray-400 text-6xl mb-4">💬</div>
|
||
|
|
<p className="text-gray-500">Aucun message dans cette conversation</p>
|
||
|
|
<p className="text-sm text-gray-400 mt-2">
|
||
|
|
Soyez le premier à envoyer un message !
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={`relative ${className}`}>
|
||
|
|
{/* Indicateur de chargement en haut */}
|
||
|
|
{isFetching && hasNextPage && (
|
||
|
|
<div className="absolute top-0 left-0 right-0 z-10 bg-white/80 backdrop-blur-sm">
|
||
|
|
{loadingIndicator}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Liste virtualisée des messages */}
|
||
|
|
<VirtualizedList
|
||
|
|
ref={containerRef}
|
||
|
|
items={memoizedMessages}
|
||
|
|
itemHeight={MESSAGE_HEIGHT}
|
||
|
|
containerHeight={CONTAINER_HEIGHT}
|
||
|
|
renderItem={renderMessage}
|
||
|
|
onItemsRendered={handleItemsRendered}
|
||
|
|
className="scroll-smooth"
|
||
|
|
overscan={10}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Bouton pour revenir en bas */}
|
||
|
|
<button
|
||
|
|
onClick={scrollToBottom}
|
||
|
|
className="absolute bottom-4 right-4 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-2 shadow-lg transition-colors"
|
||
|
|
title="Revenir en bas"
|
||
|
|
>
|
||
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Hook pour gérer l'état des messages avec pagination
|
||
|
|
export function useChatMessages(conversationId: string) {
|
||
|
|
const [messages, setMessages] = React.useState<Message[]>([]);
|
||
|
|
const [hasNextPage, setHasNextPage] = React.useState(true);
|
||
|
|
const [isFetching, setIsFetching] = React.useState(false);
|
||
|
|
const [page, setPage] = React.useState(1);
|
||
|
|
|
||
|
|
const fetchMessages = useCallback(async (pageNum: number = 1) => {
|
||
|
|
if (isFetching) return;
|
||
|
|
|
||
|
|
setIsFetching(true);
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/conversations/${conversationId}/messages?page=${pageNum}&limit=50`);
|
||
|
|
const data = await response.json();
|
||
|
|
|
||
|
|
if (pageNum === 1) {
|
||
|
|
setMessages(data.messages || []);
|
||
|
|
} else {
|
||
|
|
setMessages(prev => [...(data.messages || []), ...prev]);
|
||
|
|
}
|
||
|
|
|
||
|
|
setHasNextPage(data.has_next_page || false);
|
||
|
|
setPage(pageNum);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Erreur lors du chargement des messages:', error);
|
||
|
|
} finally {
|
||
|
|
setIsFetching(false);
|
||
|
|
}
|
||
|
|
}, [conversationId, isFetching]);
|
||
|
|
|
||
|
|
const fetchNextPage = useCallback(() => {
|
||
|
|
if (hasNextPage && !isFetching) {
|
||
|
|
fetchMessages(page + 1);
|
||
|
|
}
|
||
|
|
}, [hasNextPage, isFetching, fetchMessages, page]);
|
||
|
|
|
||
|
|
const addMessage = useCallback((message: Message) => {
|
||
|
|
setMessages(prev => [...prev, message]);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const updateMessage = useCallback((messageId: string, updates: Partial<Message>) => {
|
||
|
|
setMessages(prev =>
|
||
|
|
prev.map(msg =>
|
||
|
|
msg.id === messageId ? { ...msg, ...updates } : msg
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const deleteMessage = useCallback((messageId: string) => {
|
||
|
|
setMessages(prev => prev.filter(msg => msg.id !== messageId));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Charger les messages au montage
|
||
|
|
useEffect(() => {
|
||
|
|
fetchMessages(1);
|
||
|
|
}, [conversationId]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
messages,
|
||
|
|
hasNextPage,
|
||
|
|
isFetching,
|
||
|
|
fetchNextPage,
|
||
|
|
addMessage,
|
||
|
|
updateMessage,
|
||
|
|
deleteMessage,
|
||
|
|
};
|
||
|
|
}
|