veza/apps/web/src/features/chat/components/VirtualizedChatMessages.tsx
senke 6974c12a25 aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 11:50:46 +01:00

288 lines
8.7 KiB
TypeScript

import React, { useMemo, useCallback, useEffect, useRef } from 'react';
import {
VirtualizedList,
useInfiniteScroll,
} 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';
import { logger } from '@/utils/logger';
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 || index}
className={`p-4 border-b border-kodo-steel hover:bg-kodo-void cursor-pointer transition-colors ${
onMessageClick ? 'hover:shadow-sm' : ''
}`}
onClick={() => onMessageClick?.(message)}
style={{ minHeight: MESSAGE_HEIGHT }}
>
<div className="flex items-start space-x-4">
{/* Avatar */}
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-kodo-cyan rounded-full flex items-center justify-center text-white text-sm font-medium">
{message.sender?.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-kodo-text-main">
{message.sender?.username || 'Utilisateur inconnu'}
</span>
<span className="text-xs text-kodo-content-dim">
{formatDistanceToNow(new Date(message.created_at), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
{/* Contenu du message avec sanitisation */}
<div
className="text-sm text-kodo-text-main break-words"
dangerouslySetInnerHTML={{
__html: sanitizeChatMessage(message.content),
}}
/>
{/* Attachments */}
{message.attachment_url && (
<div className="mt-2 text-xs text-kodo-content-dim">
<span className="inline-block px-2 py-1 bg-kodo-steel/20 text-kodo-steel rounded-full">
📎 Pièce jointe
</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-kodo-steel"></div>
<span className="ml-2 text-sm text-kodo-content-dim">
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-kodo-content-dim text-6xl mb-4">💬</div>
<p className="text-kodo-content-dim">Aucun message dans cette conversation</p>
<p className="text-sm text-kodo-content-dim 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-kodo-cyan hover:bg-kodo-cyan 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
// ... imports
import { apiClient } from '@/services/api/client';
// ... (props interface same)
// ... (VirtualizedChatMessages component)
// Replace message.user with message.sender
// Remove metadata/is_edited/is_deleted if not in type OR cast/guard if expected
// 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 {
// Use apiClient.get for messages
const response = await apiClient.get<{ data: Message[] }>('/messages', {
params: {
conversation_id: conversationId,
page: pageNum,
limit: 50,
},
});
// apiClient unwrap déjà le format { success, data }
const data = response.data;
const newMessages = (data.data as unknown as Message[]) || [];
// Note: has_next peut être dans data si c'est une PaginatedResponse
const paginatedData = data as any;
if (pageNum === 1) {
setMessages(newMessages);
} else {
setMessages((prev) => [...newMessages, ...prev]);
}
setHasNextPage(paginatedData.has_next || false);
setPage(pageNum);
} catch (error) {
logger.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,
};
}