- 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
288 lines
8.7 KiB
TypeScript
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,
|
|
};
|
|
}
|