veza/apps/web/src/features/chat/components/VirtualizedChatMessages.tsx

261 lines
8.5 KiB
TypeScript
Raw Normal View History

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,
};
}