Added Chat Page
This commit is contained in:
589
components/ChatWindow.tsx
Normal file
589
components/ChatWindow.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface MessageFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
filepath: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string | null;
|
||||
senderId: string;
|
||||
sender: User;
|
||||
createdAt: string;
|
||||
files: MessageFile[];
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: string;
|
||||
displayName: string;
|
||||
participants: Participant[];
|
||||
}
|
||||
|
||||
interface ChatWindowProps {
|
||||
conversationId: string;
|
||||
conversation: Conversation;
|
||||
onNewMessage: () => void;
|
||||
onShowGroupSettings: () => void;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
export default function ChatWindow({
|
||||
conversationId,
|
||||
conversation,
|
||||
onNewMessage,
|
||||
onShowGroupSettings,
|
||||
}: ChatWindowProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<User[]>([]);
|
||||
const [readReceipts, setReadReceipts] = useState<Record<string, Date | null>>({});
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastTypingSignalRef = useRef<number>(0);
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
const { data: userData } = useSWR<User>('/api/auth/me', fetcher);
|
||||
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
setCurrentUser(userData);
|
||||
}
|
||||
}, [userData]);
|
||||
|
||||
const { data: messagesData, error, mutate } = useSWR<{
|
||||
messages: Message[];
|
||||
hasMore: boolean;
|
||||
cursor: string | null;
|
||||
}>(`/api/conversations/${conversationId}/messages?limit=50`, fetcher, {
|
||||
refreshInterval: 1000, // Rafraîchir toutes les secondes pour le temps réel
|
||||
});
|
||||
|
||||
const messages = messagesData?.messages || [];
|
||||
|
||||
// Scroll vers le bas quand de nouveaux messages arrivent
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Marquer les messages comme lus quand on les voit
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
fetch(`/api/conversations/${conversationId}/read`, {
|
||||
method: 'POST',
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [conversationId, messages.length]);
|
||||
|
||||
// Récupérer les informations de lecture
|
||||
useEffect(() => {
|
||||
const fetchReadReceipts = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/${conversationId}/read`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const receipts: Record<string, Date | null> = {};
|
||||
data.participants.forEach((p: { userId: string; lastReadAt: string | null }) => {
|
||||
receipts[p.userId] = p.lastReadAt ? new Date(p.lastReadAt) : null;
|
||||
});
|
||||
setReadReceipts(receipts);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des lectures:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchReadReceipts();
|
||||
const interval = setInterval(fetchReadReceipts, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [conversationId]);
|
||||
|
||||
// Récupérer les utilisateurs en train d'écrire
|
||||
useEffect(() => {
|
||||
const fetchTyping = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/${conversationId}/typing`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTypingUsers(data.typing || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération du typing:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTyping();
|
||||
const interval = setInterval(fetchTyping, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [conversationId]);
|
||||
|
||||
// Écouter les événements SSE pour les nouveaux messages
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(`/api/conversations/${conversationId}/events`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'new_message') {
|
||||
mutate();
|
||||
onNewMessage();
|
||||
// Marquer comme lu immédiatement
|
||||
fetch(`/api/conversations/${conversationId}/read`, {
|
||||
method: 'POST',
|
||||
}).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [conversationId, mutate, onNewMessage]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if ((!message.trim() && files.length === 0) || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
const formData = new FormData();
|
||||
if (message.trim()) {
|
||||
formData.append('content', message);
|
||||
}
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/${conversationId}/messages`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage('');
|
||||
setFiles([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
mutate();
|
||||
onNewMessage();
|
||||
// Marquer comme lu immédiatement après l'envoi
|
||||
fetch(`/api/conversations/${conversationId}/read`, {
|
||||
method: 'POST',
|
||||
}).catch(console.error);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Erreur lors de l\'envoi du message');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
alert('Erreur lors de l\'envoi du message');
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
} else {
|
||||
// Signaler que l'utilisateur est en train d'écrire
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSignalRef.current > 1000) {
|
||||
// Envoyer le signal toutes les secondes maximum
|
||||
lastTypingSignalRef.current = now;
|
||||
fetch(`/api/conversations/${conversationId}/typing`, {
|
||||
method: 'POST',
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// Réinitialiser le timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value);
|
||||
|
||||
// Signaler que l'utilisateur est en train d'écrire
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSignalRef.current > 1000) {
|
||||
lastTypingSignalRef.current = now;
|
||||
fetch(`/api/conversations/${conversationId}/typing`, {
|
||||
method: 'POST',
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// Réinitialiser le timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files));
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const isImageFile = (fileType: string) => {
|
||||
return fileType.startsWith('image/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* En-tête */}
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-lblue to-dblue flex items-center justify-center">
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{conversation.type === 'group'
|
||||
? conversation.displayName.charAt(0).toUpperCase()
|
||||
: conversation.displayName
|
||||
.split(' ')
|
||||
.map((n) => n.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{conversation.displayName}</h3>
|
||||
{conversation.type === 'group' && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{conversation.participants.length} participant{conversation.participants.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{conversation.type === 'group' && (
|
||||
<button
|
||||
onClick={onShowGroupSettings}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title="Paramètres du groupe"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
||||
{error && (
|
||||
<div className="text-center text-red-600 py-4">
|
||||
Erreur lors du chargement des messages
|
||||
</div>
|
||||
)}
|
||||
{!messagesData && !error && (
|
||||
<div className="text-center text-gray-500 py-4">Chargement des messages...</div>
|
||||
)}
|
||||
{messages.length === 0 && messagesData && (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Aucun message. Commencez la conversation !
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => {
|
||||
const isOwnMessage = currentUser && msg.senderId === currentUser.id;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg p-3 ${
|
||||
isOwnMessage
|
||||
? 'bg-lblue text-white'
|
||||
: 'bg-white text-gray-900 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{!isOwnMessage && conversation.type === 'group' && (
|
||||
<div className="text-xs font-semibold mb-1 opacity-75">
|
||||
{msg.sender.name || msg.sender.email}
|
||||
</div>
|
||||
)}
|
||||
{msg.content && (
|
||||
<p className={`text-sm whitespace-pre-wrap ${isOwnMessage ? 'text-white' : ''}`}>
|
||||
{msg.content}
|
||||
</p>
|
||||
)}
|
||||
{msg.files && msg.files.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.files.map((file) => (
|
||||
<div key={file.id} className="rounded overflow-hidden">
|
||||
{isImageFile(file.fileType) ? (
|
||||
<a
|
||||
href={file.filepath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<img
|
||||
src={file.filepath}
|
||||
alt={file.filename}
|
||||
className="rounded max-w-full h-auto max-h-64"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={file.filepath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 p-2 rounded ${
|
||||
isOwnMessage
|
||||
? 'bg-white/20 hover:bg-white/30'
|
||||
: 'bg-gray-100 hover:bg-gray-200'
|
||||
} transition-colors`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{file.filename}</div>
|
||||
<div className="text-xs opacity-75">{formatFileSize(file.fileSize)}</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 mt-1 ${isOwnMessage ? 'text-white/70' : 'text-gray-500'}`}>
|
||||
<span className="text-xs">
|
||||
{new Date(msg.createdAt).toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{isOwnMessage && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Checkmarks pour les messages envoyés/lus */}
|
||||
{(() => {
|
||||
// Filtrer les autres participants (pas l'utilisateur actuel)
|
||||
const otherParticipants = conversation.participants.filter(
|
||||
(p) => p.id !== currentUser?.id
|
||||
);
|
||||
// Vérifier si tous les autres participants ont lu le message
|
||||
const allRead = otherParticipants.length > 0 && otherParticipants.every((p) => {
|
||||
const lastRead = readReceipts[p.id];
|
||||
if (!lastRead) return false;
|
||||
return new Date(lastRead) >= new Date(msg.createdAt);
|
||||
});
|
||||
|
||||
// Premier checkmark (toujours visible)
|
||||
const firstCheck = (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-white/70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Deuxième checkmark (seulement si lu)
|
||||
const secondCheck = allRead ? (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-blue-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ marginLeft: '-2px' }}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{firstCheck}
|
||||
{secondCheck}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Indicateur de frappe */}
|
||||
{typingUsers.length > 0 && (
|
||||
<div className="flex justify-start mb-2">
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-4 py-2 max-w-[70%]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">
|
||||
{typingUsers.length === 1
|
||||
? `${typingUsers[0].name || typingUsers[0].email} est en train d'écrire...`
|
||||
: `${typingUsers.length} personnes sont en train d'écrire...`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Zone de saisie */}
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
{/* Fichiers sélectionnés */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-gray-700">{file.name}</span>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
multiple
|
||||
className="hidden"
|
||||
id="file-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-input"
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
title="Joindre un fichier"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={handleMessageChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Tapez votre message..."
|
||||
className="flex-1 min-h-[44px] max-h-32 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent resize-none text-gray-900 placeholder-gray-400"
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={(!message.trim() && files.length === 0) || isSending}
|
||||
className="p-2 rounded-lg bg-lblue text-white hover:bg-dblue transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Envoyer"
|
||||
>
|
||||
{isSending ? (
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user