2025-12-03 21:56:50 +00:00
|
|
|
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
2025-12-13 02:34:34 +00:00
|
|
|
import {
|
|
|
|
|
VirtualizedList,
|
|
|
|
|
useInfiniteScroll,
|
|
|
|
|
} from '@/components/ui/virtualized-list';
|
2025-12-03 21:56:50 +00:00
|
|
|
import { Message } from '@/types/api';
|
|
|
|
|
import { sanitizeChatMessage } from '@/utils/sanitize';
|
|
|
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
|
import { fr } from 'date-fns/locale';
|
2026-01-07 10:15:48 +00:00
|
|
|
import { logger } from '@/utils/logger';
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
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
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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}
|
2026-01-16 00:56:50 +00:00
|
|
|
className={`p-3 border-b border-kodo-steel hover:bg-kodo-void cursor-pointer transition-colors ${
|
2026-01-13 18:47:57 +00:00
|
|
|
onMessageClick ? 'hover:shadow-sm' : ''
|
|
|
|
|
}`}
|
2025-12-03 21:56:50 +00:00
|
|
|
onClick={() => onMessageClick?.(message)}
|
|
|
|
|
style={{ minHeight: MESSAGE_HEIGHT }}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start space-x-3">
|
|
|
|
|
{/* Avatar */}
|
|
|
|
|
<div className="flex-shrink-0">
|
2026-01-16 00:56:50 +00:00
|
|
|
<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() || '?'}
|
2025-12-03 21:56:50 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Contenu du message */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center space-x-2 mb-1">
|
2026-01-16 00:56:50 +00:00
|
|
|
<span className="text-sm font-medium text-kodo-text-main">
|
2025-12-13 02:34:34 +00:00
|
|
|
{message.sender?.username || 'Utilisateur inconnu'}
|
2025-12-03 21:56:50 +00:00
|
|
|
</span>
|
2026-01-16 00:56:50 +00:00
|
|
|
<span className="text-xs text-kodo-content-dim">
|
2025-12-03 21:56:50 +00:00
|
|
|
{formatDistanceToNow(new Date(message.created_at), {
|
|
|
|
|
addSuffix: true,
|
|
|
|
|
locale: fr,
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Contenu du message avec sanitisation */}
|
|
|
|
|
<div
|
2026-01-16 00:56:50 +00:00
|
|
|
className="text-sm text-kodo-text-main break-words"
|
2025-12-03 21:56:50 +00:00
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
|
__html: sanitizeChatMessage(message.content),
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
{/* Attachments */}
|
|
|
|
|
{message.attachment_url && (
|
2026-01-16 00:56:50 +00:00
|
|
|
<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>
|
2025-12-03 21:56:50 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
2025-12-13 02:34:34 +00:00
|
|
|
[onMessageClick],
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
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
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
return (
|
|
|
|
|
<div className="flex justify-center items-center py-4">
|
2026-01-16 00:56:50 +00:00
|
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-kodo-cyan"></div>
|
|
|
|
|
<span className="ml-2 text-sm text-kodo-content-dim">
|
2025-12-13 02:34:34 +00:00
|
|
|
Chargement des messages...
|
|
|
|
|
</span>
|
2025-12-03 21:56:50 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}, [isFetching]);
|
|
|
|
|
|
|
|
|
|
// Message vide
|
|
|
|
|
if (messages.length === 0 && !isFetching) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={`flex items-center justify-center h-full ${className}`}>
|
|
|
|
|
<div className="text-center">
|
2026-01-16 00:56:50 +00:00
|
|
|
<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">
|
2025-12-03 21:56:50 +00:00
|
|
|
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}
|
2026-01-16 00:56:50 +00:00
|
|
|
className="absolute bottom-4 right-4 bg-kodo-cyan hover:bg-kodo-cyan text-white rounded-full p-2 shadow-lg transition-colors"
|
2025-12-03 21:56:50 +00:00
|
|
|
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"
|
|
|
|
|
/>
|
2025-12-03 21:56:50 +00:00
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
// Hook pour gérer l'état des messages avec pagination
|
|
|
|
|
// ... imports
|
2025-12-22 21:56:37 +00:00
|
|
|
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
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
// 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 {
|
2025-12-22 21:56:37 +00:00
|
|
|
// 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
|
|
|
});
|
2025-12-22 21:56:37 +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]);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 21:56:37 +00:00
|
|
|
setHasNextPage(paginatedData.has_next || false);
|
2025-12-13 02:34:34 +00:00
|
|
|
setPage(pageNum);
|
|
|
|
|
} catch (error) {
|
2026-01-07 10:15:48 +00:00
|
|
|
logger.error('Erreur lors du chargement des messages:', { error });
|
2025-12-13 02:34:34 +00:00
|
|
|
} finally {
|
|
|
|
|
setIsFetching(false);
|
2025-12-03 21:56:50 +00:00
|
|
|
}
|
2025-12-13 02:34:34 +00:00
|
|
|
},
|
|
|
|
|
[conversationId, isFetching],
|
|
|
|
|
);
|
|
|
|
|
// ...
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
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-03 21:56:50 +00:00
|
|
|
}, []);
|
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
const deleteMessage = useCallback((messageId: string) => {
|
2025-12-13 02:34:34 +00:00
|
|
|
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
|
2025-12-03 21:56:50 +00:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Charger les messages au montage
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchMessages(1);
|
|
|
|
|
}, [conversationId]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
messages,
|
|
|
|
|
hasNextPage,
|
|
|
|
|
isFetching,
|
|
|
|
|
fetchNextPage,
|
|
|
|
|
addMessage,
|
|
|
|
|
updateMessage,
|
|
|
|
|
deleteMessage,
|
|
|
|
|
};
|
|
|
|
|
}
|