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

289 lines
8.7 KiB
TypeScript
Raw Normal View History

import React, { useMemo, useCallback, useEffect, useRef } from 'react';
2025-12-13 02:34:34 +00:00
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,
2025-12-13 02:34:34 +00:00
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
2025-12-13 02:34:34 +00:00
key={message.id || index}
className={`p-3 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-3">
{/* 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">
2025-12-13 02:34:34 +00:00
{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">
2025-12-13 02:34:34 +00:00
{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),
}}
/>
2025-12-13 02:34:34 +00:00
{/* Attachments */}
{message.attachment_url && (
<div className="mt-2 text-xs text-kodo-content-dim">
aesthetic-improvements: reduce decorative cyan in chat, auth, player, streaming, and dashboard (80/20 rule, batch 13) - Chat: ChatSidebar loading spinner and decorative icon, VirtualizedChatMessages decorative attachment badge, ChatPage decorative icon and loading spinner border/text, ChatMessage decorative username indicator and icon (7 instances) - Auth: TwoFactorVerify decorative icon (1 instance) - Player: PlayerLoading decorative spinner (1 instance) - Streaming: PlaybackSummary decorative icon (1 instance) - Dashboard: DashboardPage decorative chart color and gradient and icon (3 instances) - Total: ~13 files, ~13 instances replaced - Preserved: Active/selected states (ChatSidebar selected conversation, ChatMessage isMe message bubble and highlighted message, DashboardPage selected button 30J, ChatInput drag active overlay and emoji picker active, TrackFilters active filter badge, TrackHistory current track, TrackGridDensitySelector selected density, PlaybackSpeedControl selected speed, ViewToggle selected view mode, TrackList selected tracks, TrackListRow selected state, PlaylistList selected view mode, QualitySelector selected quality, SettingsPage selected tab and theme, LoginForm checkbox accent - focus/interaction, RegisterPage checkbox accent - focus/interaction), functional links (ForgotPasswordPage link, TwoFactorVerify links, RegisterPage links, AuthLayout link, ProfileForm links, LoginPage link, RegisterPage link), design system variants, semantic status indicators, interactive states, functional loading indicators, informational alerts/toasts - Action 11.3.1.3 in progress (thirteenth batch: chat, auth, player, streaming, and dashboard components)
2026-01-16 10:32:55 +00:00
<span className="inline-block px-2 py-1 bg-kodo-steel/20 text-kodo-steel rounded-full">
2025-12-13 02:34:34 +00:00
📎 Pièce jointe
</span>
</div>
)}
</div>
</div>
</div>
),
2025-12-13 02:34:34 +00:00
[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];
2025-12-13 02:34:34 +00:00
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;
2025-12-13 02:34:34 +00:00
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">
2025-12-13 02:34:34 +00:00
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"
>
2025-12-13 02:34:34 +00:00
<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>
);
}
2025-12-13 02:34:34 +00:00
// Hook pour gérer l'état des messages avec pagination
// ... imports
import { apiClient } from '@/services/api/client';
2025-12-13 02:34:34 +00:00
// ... (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);
2025-12-13 02:34:34 +00:00
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,
},
2025-12-13 02:34:34 +00:00
});
// 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;
2025-12-13 02:34:34 +00:00
if (pageNum === 1) {
setMessages(newMessages);
} else {
setMessages((prev) => [...newMessages, ...prev]);
}
setHasNextPage(paginatedData.has_next || false);
2025-12-13 02:34:34 +00:00
setPage(pageNum);
} catch (error) {
logger.error('Erreur lors du chargement des messages:', { error });
2025-12-13 02:34:34 +00:00
} finally {
setIsFetching(false);
}
2025-12-13 02:34:34 +00:00
},
[conversationId, isFetching],
);
// ...
const fetchNextPage = useCallback(() => {
if (hasNextPage && !isFetching) {
fetchMessages(page + 1);
}
}, [hasNextPage, isFetching, fetchMessages, page]);
const addMessage = useCallback((message: Message) => {
2025-12-13 02:34:34 +00:00
setMessages((prev) => [...prev, message]);
}, []);
2025-12-13 02:34:34 +00:00
const updateMessage = useCallback(
(messageId: string, updates: Partial<Message>) => {
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, ...updates } : msg,
),
);
},
[],
);
const deleteMessage = useCallback((messageId: string) => {
2025-12-13 02:34:34 +00:00
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,
};
}