feat: mise en place de la messagerie [#17]

This commit is contained in:
Luc SORIGNET
2025-05-26 13:24:42 +02:00
parent e2df29d851
commit d37145b73e
64 changed files with 13113 additions and 853 deletions

View File

@ -3,7 +3,6 @@ import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import {
LayoutDashboard,
FileText,
@ -13,11 +12,8 @@ import {
Calendar,
Settings,
LogOut,
Menu,
X,
Mail,
MessageSquare,
} from 'lucide-react';
import DropdownMenu from '@/components/DropdownMenu';
import Popup from '@/components/Popup';
import {
@ -86,7 +82,7 @@ export default function Layout({ children }) {
id: 'messagerie',
name: t('messagerie'),
url: FE_ADMIN_MESSAGERIE_URL,
icon: Mail,
icon: MessageSquare,
},
settings: {
id: 'settings',

View File

@ -1,5 +1,5 @@
'use client';
import React from 'react';
import React, { useEffect } from 'react';
import SidebarTabs from '@/components/SidebarTabs';
import EmailSender from '@/components/Admin/EmailSender';
import InstantMessaging from '@/components/Admin/InstantMessaging';
@ -26,11 +26,8 @@ export default function MessageriePage({ csrfToken }) {
];
return (
<div className="flex h-full w-full">
<SidebarTabs
tabs={tabs}
onTabChange={(tabId) => logger.debug(`Onglet actif : ${tabId}`)}
/>
<div className="h-full flex flex-col p-0 m-0">
<SidebarTabs tabs={tabs} />
</div>
);
}

View File

@ -1,31 +1,64 @@
'use client';
// src/components/Layout.js
import React, { useState } from 'react';
import ProfileSelector from '@/components/ProfileSelector';
import { useRouter } from 'next/navigation'; // Ajout de l'importation
import { MessageSquare, LogOut, Settings, Home } from 'lucide-react'; // Ajout de l'importation de l'icône Home
import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
import {
FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL,
FE_PARENTS_SETTINGS_URL,
} from '@/utils/Url'; // Ajout de l'importation de l'URL de la page d'accueil parent
} from '@/utils/Url';
import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
import { getRightStr, RIGHTS } from '@/utils/rights';
import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer';
export default function Layout({ children }) {
const router = useRouter(); // Définition de router
const [messages, setMessages] = useState([]);
const router = useRouter();
const pathname = usePathname();
const [isPopupVisible, setIsPopupVisible] = useState(false);
const { profileRole, user, clearContext } = useEstablishment();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { clearContext } = useEstablishment();
const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
// Vérifier si on est sur la page messagerie
const isMessagingPage = pathname?.includes('/messagerie');
// Configuration des éléments de la sidebar pour les parents
const sidebarItems = [
{
id: 'home',
name: 'Accueil',
url: FE_PARENTS_HOME_URL,
icon: Home,
},
{
id: 'messagerie',
name: 'Messagerie',
url: FE_PARENTS_MESSAGERIE_URL,
icon: MessageSquare,
},
{
id: 'settings',
name: 'Paramètres',
url: FE_PARENTS_SETTINGS_URL,
icon: Settings,
},
];
// Déterminer la page actuelle pour la sidebar
const getCurrentPage = () => {
if (pathname?.includes('/messagerie')) return 'messagerie';
if (pathname?.includes('/settings')) return 'settings';
return 'home';
};
const currentPage = getCurrentPage();
const handleDisconnect = () => {
setIsPopupVisible(true);
};
@ -35,52 +68,63 @@ export default function Layout({ children }) {
disconnect();
clearContext();
};
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false);
}, [pathname]);
return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
<div className="flex flex-col min-h-screen bg-gray-50">
{/* Entête */}
<header className="h-16 bg-white border-b border-gray-200 px-4 md:px-8 py-4 flex items-center justify-between fixed top-0 left-0 right-0 z-10">
<div className="flex items-center space-x-2">
{/* Suppression du menu profil parent */}
{/* Bouton hamburger pour mobile */}
<button
onClick={toggleSidebar}
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
>
<Menu size={20} />
</button>
<div className="text-lg md:text-xl p-2 font-semibold">Accueil</div>
</div>
<div className="flex items-center space-x-2 md:space-x-4">
<button
className="p-1 md:p-2 rounded-full hover:bg-gray-200"
onClick={() => {
router.push(FE_PARENTS_HOME_URL);
}}
>
<Home className="h-5 w-5 md:h-6 md:w-6" />
</button>
<div className="relative">
<button
className="p-1 md:p-2 rounded-full hover:bg-gray-200"
onClick={() => {
router.push(FE_PARENTS_MESSAGERIE_URL);
}}
>
<MessageSquare className="h-5 w-5 md:h-6 md:w-6" />
</button>
{messages.length > 0 && (
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-emerald-600"></span>
)}
</div>
<ProfileSelector className="w-64 border-b border-gray-200 " />
{/* Suppression du DropdownMenu profil parent */}
</div>
</header>
{/* Content */}
<div className="pt-16 md:pt-20 p-4 md:p-8 flex-1">
{' '}
{/* Ajout de flex-1 pour utiliser toute la hauteur disponible */}
{children}
</div>
{/* Footer responsive */}
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
{/* Sidebar */}
<div
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
isSidebarOpen ? 'block' : 'hidden md:block'
}`}
>
<Sidebar
currentPage={currentPage}
items={sidebarItems}
onCloseMobile={toggleSidebar}
/>
</div>
{/* Overlay for mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
onClick={toggleSidebar}
/>
)}
{/* Main container */}
<div
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
>
{children}
</div>
{/* Footer */}
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
<Popup
isOpen={isPopupVisible}
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
onConfirm={confirmDisconnect}
onCancel={() => setIsPopupVisible(false)}
/>
</ProtectedRoute>
);
}

View File

@ -1,15 +1,28 @@
'use client';
import React, { useEffect, useState } from 'react';
import Chat from '@/components/Chat';
import React from 'react';
import InstantChat from '@/components/Chat/InstantChat';
import { useEstablishment } from '@/context/EstablishmentContext';
export default function MessageriePage() {
const { user, selectedEstablishmentId } = useEstablishment();
if (!user) return <div>Chargement...</div>;
if (!user?.user_id || !selectedEstablishmentId) {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement de la messagerie...</p>
</div>
</div>
);
}
return (
<Chat userProfileId={user.id} establishmentId={selectedEstablishmentId} />
<div className="h-full flex flex-col">
<InstantChat
userProfileId={user.user_id}
establishmentId={selectedEstablishmentId}
/>
</div>
);
}

View File

@ -0,0 +1,34 @@
import {
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
BE_GESTIONEMAIL_SEND_EMAIL_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import { getCsrfToken } from '@/utils/getCsrfToken';
// Recherche de destinataires pour email
export const searchRecipients = (establishmentId, query) => {
const url = `${BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
// Envoyer un email
export const sendEmail = async (messageData) => {
const csrfToken = getCsrfToken();
return fetch(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(messageData),
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -1,67 +1,251 @@
import {
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL,
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
BE_GESTIONMESSAGERIE_MESSAGES_URL,
BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL,
BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL,
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import logger from '@/utils/logger';
export const fetchConversations = (profileId) => {
return fetch(`${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${profileId}/`, {
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
// Helper pour construire les en-têtes avec CSRF
const buildHeaders = (csrfToken) => {
const headers = {
'Content-Type': 'application/json',
};
// Ajouter le token CSRF
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
return headers;
};
export const fetchMessages = (conversationId) => {
return fetch(
`${BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL}/${conversationId}/`,
{
headers: {
'Content-Type': 'application/json',
},
/**
* Récupère les conversations d'un utilisateur
*/
export const fetchConversations = async (userId, csrfToken) => {
try {
// Utiliser la nouvelle route avec user_id en paramètre d'URL
const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`;
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la récupération des conversations:', error);
return errorHandler(error);
}
};
/**
* Récupère les messages d'une conversation
*/
export const fetchMessages = async (
conversationId,
page = 1,
limit = 50,
csrfToken,
userId = null
) => {
try {
// Utiliser la nouvelle URL avec conversation_id en paramètre d'URL
let url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${conversationId}/messages/?page=${page}&limit=${limit}`;
// Ajouter user_id si fourni pour calculer correctement is_read
if (userId) {
url += `&user_id=${userId}`;
}
)
.then(requestResponseHandler)
.catch(errorHandler);
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la récupération des messages:', error);
return errorHandler(error);
}
};
export const sendMessage = (data) => {
return fetch(`${BE_GESTIONMESSAGERIE_MESSAGES_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(requestResponseHandler)
.catch(errorHandler);
/**
* Envoie un message dans une conversation
*/
export const sendMessage = async (messageData, csrfToken) => {
try {
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify(messageData),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error("Erreur lors de l'envoi du message:", error);
return errorHandler(error);
}
};
export const markAsRead = (conversationId, profileId) => {
return fetch(`${BE_GESTIONMESSAGERIE_MARK_AS_READ_URL}/${conversationId}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ profile_id: profileId }),
})
.then(requestResponseHandler)
.catch(errorHandler);
/**
* Crée une nouvelle conversation
*/
export const createConversation = async (participantIds, csrfToken) => {
try {
const requestBody = {
participant_ids: participantIds, // Le backend attend "participant_ids"
conversation_type: 'private', // Spécifier le type de conversation
name: '', // Le nom sera généré côté backend
};
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify(requestBody),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la création de la conversation:', error);
return errorHandler(error);
}
};
export const searchRecipients = (establishmentId, query) => {
const url = `${BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
/**
* Recherche des destinataires pour la messagerie
*/
export const searchMessagerieRecipients = async (
establishmentId,
query,
csrfToken
) => {
try {
const baseUrl = BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL.endsWith('/')
? BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL
: BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL + '/';
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la recherche des destinataires:', error);
return errorHandler(error);
}
};
/**
* Marque des messages comme lus
*/
export const markAsRead = async (conversationId, userId, csrfToken) => {
try {
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify({
conversation_id: conversationId,
user_id: userId,
}),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors du marquage des messages comme lus:', error);
return errorHandler(error);
}
};
/**
* Upload un fichier pour la messagerie
*/
export const uploadFile = async (
file,
conversationId,
senderId,
csrfToken,
onProgress = null
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('conversation_id', conversationId);
formData.append('sender_id', senderId);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
onProgress(percentComplete);
}
});
}
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (error) {
reject(new Error('Réponse invalide du serveur'));
}
} else {
try {
const errorResponse = JSON.parse(xhr.responseText);
reject(new Error(errorResponse.message || "Erreur lors de l'upload"));
} catch {
reject(new Error(`Erreur HTTP: ${xhr.status}`));
}
}
});
xhr.addEventListener('error', () => {
reject(new Error("Erreur réseau lors de l'upload"));
});
xhr.addEventListener('timeout', () => {
reject(new Error("Timeout lors de l'upload"));
});
xhr.open('POST', BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL);
xhr.withCredentials = true;
xhr.timeout = 30000;
// Ajouter le header CSRF pour XMLHttpRequest
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
};
/**
* Supprime une conversation
*/
export const deleteConversation = async (conversationId, csrfToken) => {
try {
const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`;
const response = await fetch(url, {
method: 'DELETE',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la suppression de la conversation:', error);
return errorHandler(error);
}
};

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { sendMessage, searchRecipients } from '@/app/actions/messagerieAction';
import { sendEmail, searchRecipients } from '@/app/actions/emailAction';
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
import { useNotification } from '@/context/NotificationContext';
import { useEstablishment } from '@/context/EstablishmentContext';
@ -56,7 +56,7 @@ export default function EmailSender({ csrfToken }) {
};
try {
await sendMessage(data);
await sendEmail(data);
showNotification('Email envoyé avec succès.', 'success', 'Succès');
// Réinitialiser les champs après succès
setRecipients([]);

View File

@ -1,88 +1,17 @@
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
import React, { useEffect, useState } from 'react';
import Chat from '@/components/Chat';
import InstantChat from '@/components/Chat/InstantChat';
import { useEstablishment } from '@/context/EstablishmentContext';
import {
fetchConversations,
sendMessage,
searchRecipients,
} from '@/app/actions/messagerieAction';
import RecipientInput from '@/components/RecipientInput';
import Modal from '@/components/Modal';
import logger from '@/utils/logger';
export default function InstantMessaging({ csrfToken }) {
const { user, selectedEstablishmentId } = useEstablishment();
const [discussions, setDiscussions] = useState([]);
const [recipients, setRecipients] = useState([]); // Liste des correspondants sélectionnés
const [showModal, setShowModal] = useState(false);
const [firstMessage, setFirstMessage] = useState('');
useEffect(() => {
if (user) {
fetchConversations(user.id).then(setDiscussions);
}
}, [user]);
// Fonction pour ajouter une nouvelle discussion avec plusieurs correspondants
const handleCreateDiscussion = async () => {
if (!user || recipients.length === 0 || !firstMessage.trim()) return;
for (const recipient of recipients) {
await sendMessage({
emetteur: user.id,
destinataire: recipient.id,
objet: '',
corpus: firstMessage,
conversation_id: undefined, // L'API générera un nouvel ID
});
}
setRecipients([]);
setFirstMessage('');
setShowModal(false);
fetchConversations(user.id).then(setDiscussions);
};
if (!user) return <div>Chargement...</div>;
return (
<div className="h-full w-full">
<Chat
userProfileId={user.id}
<div className="h-full flex flex-col">
<InstantChat
userProfileId={user.user_id}
establishmentId={selectedEstablishmentId}
discussions={discussions}
setDiscussions={setDiscussions}
onShowCreateDiscussion={() => setShowModal(true)}
/>
<Modal
isOpen={showModal}
setIsOpen={setShowModal}
title="Nouvelle discussion"
modalClassName="w-full max-w-xs sm:max-w-md"
>
<div className="p-2 sm:p-4">
<h3 className="text-lg font-bold mb-2">Nouvelle discussion</h3>
<RecipientInput
label="Rechercher un correspondant"
recipients={recipients}
setRecipients={setRecipients}
searchRecipients={searchRecipients}
establishmentId={selectedEstablishmentId}
required
/>
<input
type="text"
value={firstMessage}
onChange={(e) => setFirstMessage(e.target.value)}
placeholder="Premier message"
className="w-full p-2 mb-2 border rounded"
/>
<button
onClick={handleCreateDiscussion}
className="w-full p-2 bg-green-500 text-white rounded-lg"
>
Démarrer la discussion
</button>
</div>
</Modal>
</div>
);
}

View File

@ -1,175 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal, Plus } from 'lucide-react';
import Image from 'next/image';
import {
fetchConversations,
fetchMessages,
sendMessage,
markAsRead,
} from '@/app/actions/messagerieAction';
export default function Chat({
userProfileId,
establishmentId,
discussions: discussionsProp,
setDiscussions: setDiscussionsProp,
onCreateDiscussion,
onShowCreateDiscussion,
}) {
const [discussions, setDiscussions] = useState(discussionsProp || []);
const [selectedDiscussion, setSelectedDiscussion] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const messagesEndRef = useRef(null);
useEffect(() => {
if (userProfileId) {
fetchConversations(userProfileId).then(setDiscussions);
}
}, [userProfileId]);
useEffect(() => {
if (selectedDiscussion) {
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
// Marquer comme lu
markAsRead(selectedDiscussion.conversation_id, userProfileId);
}
}, [selectedDiscussion, userProfileId]);
const handleSendMessage = async () => {
if (newMessage.trim() && selectedDiscussion) {
await sendMessage({
conversation_id: selectedDiscussion.conversation_id,
emetteur: userProfileId,
destinataire: selectedDiscussion.interlocuteur.id,
corpus: newMessage,
objet: '',
});
setNewMessage('');
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
fetchConversations(userProfileId).then(setDiscussions);
}
};
const handleKeyPress = (event) => {
if (event.key === 'Enter') {
handleSendMessage();
}
};
return (
<div className="flex h-full w-full">
{/* Bandeau droit : Liste des discussions */}
<div className="w-1/4 min-w-[280px] bg-gray-100 border-r border-gray-300 p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Discussions</h2>
<button
className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow"
title="Nouvelle discussion"
onClick={onShowCreateDiscussion}
>
<Plus size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{discussions && discussions.length > 0 ? (
discussions.map((discussion) => (
<div
key={discussion.id}
className={`flex items-center p-2 mb-2 cursor-pointer rounded transition-colors ${
selectedDiscussion?.id === discussion.id
? 'bg-blue-100'
: 'hover:bg-gray-200'
}`}
onClick={() => setSelectedDiscussion(discussion)}
>
<Image
src={discussion.profilePic}
alt={`${discussion.name}'s profile`}
className="w-10 h-10 rounded-full mr-3"
width={40}
height={40}
/>
<div className="flex-1">
<p className="font-medium">{discussion.name}</p>
<p className="text-sm text-gray-500 truncate">
{discussion.lastMessage}
</p>
</div>
<span className="text-xs text-gray-400">
{discussion.lastMessageDate &&
new Date(discussion.lastMessageDate).toLocaleTimeString()}
</span>
</div>
))
) : (
<p className="text-gray-500">Aucune discussion disponible.</p>
)}
</div>
</div>
{/* Zone de chat */}
<div className="flex-1 flex flex-col bg-white">
{/* En-tête du chat */}
{selectedDiscussion && (
<div className="flex items-center p-4 border-b border-gray-300">
<Image
src={selectedDiscussion.profilePic}
alt={`${selectedDiscussion.name}'s profile`}
className="w-10 h-10 rounded-full mr-3"
width={40}
height={40}
/>
<h2 className="text-lg font-bold">{selectedDiscussion.name}</h2>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{selectedDiscussion &&
messages.map((message) => (
<div
key={message.id}
className={`flex mb-4 ${
message.isResponse ? 'justify-start' : 'justify-end'
}`}
>
<div
className={`p-3 rounded-lg max-w-xs ${
message.isResponse
? 'bg-gray-200 text-gray-800'
: 'bg-blue-500 text-white'
}`}
>
<p>{message.corpus}</p>
<span className="text-xs text-gray-500 block mt-1">
{message.date &&
new Date(message.date).toLocaleTimeString()}
</span>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Champ de saisie */}
{selectedDiscussion && (
<div className="p-4 border-t border-gray-300 flex items-center">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 p-2 border border-gray-300 rounded-lg mr-2"
placeholder="Écrire un message..."
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
/>
<button
onClick={handleSendMessage}
className="p-2 bg-blue-500 text-white rounded-lg"
>
<SendHorizontal />
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
import React from 'react';
import { Wifi, WifiOff, RotateCcw } from 'lucide-react';
const ConnectionStatus = ({ status, onReconnect }) => {
const getStatusInfo = () => {
switch (status) {
case 'connected':
return {
icon: <Wifi className="w-4 h-4" />,
text: 'Connecté',
className: 'text-green-600 bg-green-50 border-green-200',
};
case 'disconnected':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Déconnecté',
className: 'text-red-600 bg-red-50 border-red-200',
};
case 'reconnecting':
return {
icon: <RotateCcw className="w-4 h-4 animate-spin" />,
text: 'Reconnexion...',
className: 'text-yellow-600 bg-yellow-50 border-yellow-200',
};
case 'error':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Erreur de connexion',
className: 'text-red-600 bg-red-50 border-red-200',
};
case 'failed':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Connexion échouée',
className: 'text-red-600 bg-red-50 border-red-200',
};
default:
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Inconnu',
className: 'text-gray-600 bg-gray-50 border-gray-200',
};
}
};
if (status === 'connected') {
return (
<div className="flex items-center justify-between px-3 py-2 border rounded-lg text-green-600 bg-green-50 border-green-200">
<div className="flex items-center space-x-2">
<Wifi className="w-4 h-4" />
<span className="text-sm font-medium">Connecté</span>
</div>
</div>
);
}
const { icon, text, className } = getStatusInfo();
return (
<div
className={`flex items-center justify-between px-3 py-2 border rounded-lg ${className}`}
>
<div className="flex items-center space-x-2">
{icon}
<span className="text-sm font-medium">{text}</span>
</div>
{(status === 'failed' || status === 'error') && onReconnect && (
<button
onClick={onReconnect}
className="text-sm underline hover:no-underline"
>
Réessayer
</button>
)}
</div>
);
};
export default ConnectionStatus;

View File

@ -0,0 +1,196 @@
import React from 'react';
import { User, Trash2 } from 'lucide-react';
import { getGravatarUrl } from '@/utils/gravatar';
const ConversationItem = ({
conversation,
isSelected,
onClick,
onDelete, // Nouvelle prop pour la suppression
unreadCount = 0,
lastMessage,
isTyping = false,
userPresences = {}, // Nouveau prop pour les statuts de présence
}) => {
const formatTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now - date) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
});
} else {
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
});
}
};
const getInterlocutorName = () => {
if (conversation.interlocuteur) {
// Si nous avons le nom et prénom, les utiliser
if (
conversation.interlocuteur.first_name &&
conversation.interlocuteur.last_name
) {
return `${conversation.interlocuteur.first_name} ${conversation.interlocuteur.last_name}`;
}
// Sinon, utiliser l'email comme fallback
if (conversation.interlocuteur.email) {
return conversation.interlocuteur.email;
}
}
return conversation.name || 'Utilisateur inconnu';
};
const getLastMessageText = () => {
if (isTyping) {
return (
<span className="text-emerald-500 italic">Tape un message...</span>
);
}
if (lastMessage) {
return lastMessage.content || lastMessage.corpus || 'Message...';
}
if (conversation.last_message) {
return (
conversation.last_message.content ||
conversation.last_message.corpus ||
'Message...'
);
}
return 'Aucun message';
};
const getLastMessageTime = () => {
if (lastMessage) {
return formatTime(lastMessage.created_at || lastMessage.date_envoi);
}
if (conversation.last_message) {
return formatTime(
conversation.last_message.created_at ||
conversation.last_message.date_envoi
);
}
return '';
};
const getUserPresenceStatus = () => {
if (conversation.interlocuteur?.id) {
const presence = userPresences[conversation.interlocuteur.id];
return presence?.status || 'offline';
}
return 'offline';
};
const getPresenceColor = (status) => {
switch (status) {
case 'online':
return 'bg-emerald-400';
case 'away':
return 'bg-yellow-400';
case 'busy':
return 'bg-red-400';
case 'offline':
default:
return 'bg-gray-400';
}
};
const getPresenceLabel = (status) => {
switch (status) {
case 'online':
return 'En ligne';
case 'away':
return 'Absent';
case 'busy':
return 'Occupé';
case 'offline':
default:
return 'Hors ligne';
}
};
const presenceStatus = getUserPresenceStatus();
return (
<div
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
isSelected
? 'bg-emerald-50 border-l-4 border-emerald-500'
: 'hover:bg-gray-50'
}`}
onClick={onClick}
>
{/* Avatar */}
<div className="relative">
<img
src={getGravatarUrl(
conversation.interlocuteur?.email || 'default',
48
)}
alt={`Avatar de ${getInterlocutorName()}`}
className="w-12 h-12 rounded-full object-cover shadow-md"
/>
{/* Indicateur de statut en ligne */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-4 h-4 ${getPresenceColor(presenceStatus)} border-2 border-white rounded-full`}
title={getPresenceLabel(presenceStatus)}
></div>
</div>
{/* Contenu de la conversation */}
<div className="flex-1 ml-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3
className={`font-semibold truncate ${
isSelected ? 'text-emerald-700' : 'text-gray-900'
}`}
>
{getInterlocutorName()}
</h3>
<div className="flex items-center space-x-2">
{unreadCount > 0 && (
<span className="bg-red-500 text-white text-xs rounded-full w-4 h-4 text-center"></span>
)}
<span className="text-xs text-gray-500">
{getLastMessageTime()}
</span>
{/* Bouton de suppression */}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation(); // Empêcher la sélection de la conversation
onDelete();
}}
className="opacity-0 group-hover:opacity-100 hover:bg-red-100 p-1 rounded transition-all duration-200"
title="Supprimer la conversation"
>
<Trash2 className="w-4 h-4 text-red-500 hover:text-red-700" />
</button>
)}
</div>
</div>
<p
className={`text-sm truncate mt-1 ${isTyping ? '' : 'text-gray-600'}`}
>
{getLastMessageText()}
</p>
</div>
</div>
);
};
export default ConversationItem;

View File

@ -0,0 +1,115 @@
import React from 'react';
import {
Download,
FileText,
Image,
Film,
Music,
Archive,
AlertCircle,
} from 'lucide-react';
const FileAttachment = ({
fileName,
fileSize,
fileType,
fileUrl,
onDownload = null,
}) => {
// Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => {
if (type.startsWith('image/')) {
return <Image className="w-6 h-6 text-blue-500" />;
}
if (type.startsWith('video/')) {
return <Film className="w-6 h-6 text-purple-500" />;
}
if (type.startsWith('audio/')) {
return <Music className="w-6 h-6 text-green-500" />;
}
if (type.includes('pdf')) {
return <FileText className="w-6 h-6 text-red-500" />;
}
if (type.includes('zip') || type.includes('rar')) {
return <Archive className="w-6 h-6 text-yellow-500" />;
}
return <FileText className="w-6 h-6 text-gray-500" />;
};
// Formater la taille du fichier
const formatFileSize = (bytes) => {
if (!bytes) return '';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Gérer le téléchargement
const handleDownload = () => {
if (onDownload) {
onDownload();
} else if (fileUrl) {
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// Vérifier si c'est une image pour afficher un aperçu
const isImage = fileType && fileType.startsWith('image/');
return (
<div className="max-w-sm">
{isImage && fileUrl ? (
// Affichage pour les images
<div className="relative group">
<img
src={fileUrl}
alt={fileName}
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(fileUrl, '_blank')}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
<button
onClick={handleDownload}
className="opacity-0 group-hover:opacity-100 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 transition-all"
>
<Download className="w-4 h-4 text-gray-700" />
</button>
</div>
{fileName && (
<p className="mt-1 text-xs text-gray-500 truncate">{fileName}</p>
)}
</div>
) : (
// Affichage pour les autres fichiers
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg border hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0">{getFileIcon(fileType)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{fileName || 'Fichier sans nom'}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
<button
onClick={handleDownload}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Télécharger"
>
<Download className="w-4 h-4" />
</button>
</div>
)}
</div>
);
};
export default FileAttachment;

View File

@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { X, Upload, FileText, Image, AlertCircle } from 'lucide-react';
const FileUpload = ({
file,
onUpload,
onCancel,
conversationId,
senderId,
maxSize = 10 * 1024 * 1024, // 10MB par défaut
}) => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
// Vérifier le type de fichier et obtenir l'icône appropriée
const getFileIcon = (fileType) => {
if (fileType.startsWith('image/')) {
return <Image className="w-8 h-8 text-blue-500" alt="Icône image" />;
}
return <FileText className="w-8 h-8 text-gray-500" alt="Icône fichier" />;
};
// Formater la taille du fichier
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Vérifier si le fichier est valide
const isValidFile = () => {
const allowedTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
];
if (!allowedTypes.includes(file.type)) {
return false;
}
if (file.size > maxSize) {
return false;
}
return true;
};
// Gérer l'upload
const handleUpload = async () => {
if (!isValidFile()) {
setError('Type de fichier non autorisé ou fichier trop volumineux');
return;
}
setIsUploading(true);
setError(null);
try {
const result = await onUpload(
file,
conversationId,
senderId,
setUploadProgress
);
// L'upload s'est bien passé, le parent gère la suite
} catch (error) {
setError(error.message || "Erreur lors de l'upload");
setIsUploading(false);
}
};
if (!file) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-lg">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
{getFileIcon(file.type)}
<div>
<p className="font-medium text-gray-900 truncate max-w-xs">
{file.name}
</p>
<p className="text-sm text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={onCancel}
disabled={isUploading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Prévisualisation pour les images */}
{file.type.startsWith('image/') && (
<div className="mb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={URL.createObjectURL(file)}
alt="Aperçu du fichier sélectionné"
className="max-w-full h-32 object-cover rounded-lg"
/>
</div>
)}
{/* Validation du fichier */}
{!isValidFile() && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">
{file.size > maxSize
? `Fichier trop volumineux (max ${formatFileSize(maxSize)})`
: 'Type de fichier non autorisé'}
</span>
</div>
)}
{/* Erreur d'upload */}
{error && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">{error}</span>
</div>
)}
{/* Barre de progression */}
{isUploading && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-gray-600">Upload en cours...</span>
<span className="text-sm text-gray-600">
{Math.round(uploadProgress)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* Boutons d'action */}
<div className="flex justify-end space-x-2">
<button
onClick={onCancel}
disabled={isUploading}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleUpload}
disabled={isUploading || !isValidFile()}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
<span>{isUploading ? 'Upload...' : 'Envoyer'}</span>
</button>
</div>
</div>
);
};
export default FileUpload;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,133 @@
import React from 'react';
import { format, isToday, isYesterday } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Check, CheckCheck } from 'lucide-react';
import FileAttachment from './FileAttachment';
import { getGravatarUrl } from '@/utils/gravatar';
const MessageBubble = ({
message,
isOwnMessage,
showAvatar = true,
isRead = false,
senderName = '',
senderEmail = '', // Nouveau prop pour l'email du sender
isFirstInGroup = true,
isLastInGroup = true,
showTime = true,
}) => {
const formatMessageTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isToday(date)) {
return format(date, 'HH:mm', { locale: fr });
} else if (isYesterday(date)) {
return `Hier ${format(date, 'HH:mm', { locale: fr })}`;
} else {
return format(date, 'dd/MM HH:mm', { locale: fr });
}
};
const getMessageContent = () => {
return message.content || message.corpus || '';
};
const getMessageTime = () => {
return message.created_at || message.date_envoi;
};
const hasAttachment = () => {
return (
message.attachment &&
(message.attachment.fileName || message.attachment.fileUrl)
);
};
const isFileOnlyMessage = () => {
return hasAttachment() && !getMessageContent().trim();
};
return (
<div
className={`group hover:bg-gray-50 px-4 py-1 ${isFirstInGroup ? 'mt-4' : 'mt-0.5'} message-appear`}
onMouseEnter={() => {
/* Peut ajouter des actions au hover */
}}
>
<div className="flex">
{/* Avatar - affiché seulement pour le premier message du groupe */}
{showAvatar && isFirstInGroup && (
<img
src={getGravatarUrl(senderEmail || senderName, 40)}
alt={`Avatar de ${senderName || 'Utilisateur'}`}
className="w-10 h-10 rounded-full object-cover shadow-sm mr-3 flex-shrink-0 mt-0.5"
/>
)}
{/* Espace pour aligner avec l'avatar quand il n'est pas affiché */}
{(!showAvatar || !isFirstInGroup) && (
<div className="w-10 mr-3 flex-shrink-0"></div>
)}
{/* Contenu du message */}
<div className="flex-1 min-w-0">
{/* En-tête du message (nom + heure) - seulement pour le premier message du groupe */}
{isFirstInGroup && (
<div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-gray-900 text-sm">
{senderName || (isOwnMessage ? 'Moi' : 'Utilisateur')}
</span>
<span className="text-xs text-gray-500">
{formatMessageTime(getMessageTime())}
</span>
</div>
)}
{/* Fichier attaché */}
{hasAttachment() && (
<div className={`${getMessageContent().trim() ? 'mb-2' : ''}`}>
<FileAttachment
fileName={message.attachment.fileName}
fileSize={message.attachment.fileSize}
fileType={message.attachment.fileType}
fileUrl={message.attachment.fileUrl}
/>
</div>
)}
{/* Contenu du message */}
{getMessageContent().trim() && (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words text-gray-800">
{getMessageContent()}
</div>
)}
{/* Indicateurs de lecture et heure pour les messages non-groupés */}
<div className="flex items-center space-x-2 mt-1">
{/* Heure pour les messages qui ne sont pas le premier du groupe */}
{!isFirstInGroup && (
<span className="text-xs text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
{formatMessageTime(getMessageTime())}
</span>
)}
{/* Indicateurs de lecture (uniquement pour nos messages) */}
{isOwnMessage && (
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
{isRead ? (
<CheckCheck className="w-3 h-3 text-green-500" title="Lu" />
) : (
<Check className="w-3 h-3 text-gray-400" title="Envoyé" />
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default MessageBubble;

View File

@ -0,0 +1,233 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip } from 'lucide-react';
import FileUpload from './FileUpload';
import { uploadFile } from '@/app/actions/messagerieAction';
import logger from '@/utils/logger';
const MessageInput = ({
onSendMessage,
onTypingStart,
onTypingStop,
disabled = false,
placeholder = 'Tapez votre message...',
conversationId = null,
senderId = null,
}) => {
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [showFileUpload, setShowFileUpload] = useState(false);
const textareaRef = useRef(null);
const typingTimeoutRef = useRef(null);
// Ajuster la hauteur du textarea automatiquement
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [message]);
const handleInputChange = (e) => {
const value = e.target.value;
setMessage(value);
// Gestion du statut de frappe
if (value.trim() && !isTyping) {
setIsTyping(true);
onTypingStart?.();
}
// Réinitialiser le timeout de frappe
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (isTyping) {
setIsTyping(false);
onTypingStop?.();
}
}, 1000);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
const trimmedMessage = message.trim();
logger.debug('📝 MessageInput: handleSend appelé:', {
message,
trimmedMessage,
disabled,
});
if (!trimmedMessage || disabled) {
logger.debug('❌ MessageInput: Message vide ou désactivé');
return;
}
logger.debug(
'📤 MessageInput: Appel de onSendMessage avec:',
trimmedMessage
);
onSendMessage(trimmedMessage);
setMessage('');
// Arrêter le statut de frappe
if (isTyping) {
setIsTyping(false);
onTypingStop?.();
}
// Effacer le timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
setSelectedFile(file);
setShowFileUpload(true);
}
// Réinitialiser l'input file
e.target.value = '';
};
const handleFileUpload = async (
file,
conversationId,
senderId,
onProgress
) => {
try {
const result = await uploadFile(
file,
conversationId,
senderId,
onProgress
);
// Envoyer un message avec le fichier
onSendMessage('', {
type: 'file',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
fileUrl: result.fileUrl,
});
// Réinitialiser l'état
setSelectedFile(null);
setShowFileUpload(false);
return result;
} catch (error) {
throw error;
}
};
const handleCancelFileUpload = () => {
setSelectedFile(null);
setShowFileUpload(false);
};
// Nettoyage du timeout lors du démontage du composant
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return (
<div className="border-t border-gray-200 bg-white">
{/* Aperçu d'upload de fichier */}
{showFileUpload && selectedFile && (
<div className="p-4 border-b border-gray-200">
<FileUpload
file={selectedFile}
onUpload={handleFileUpload}
onCancel={handleCancelFileUpload}
conversationId={conversationId}
senderId={senderId}
/>
</div>
)}
{/* Zone de saisie */}
<div className="p-4">
<div className="flex items-end space-x-3">
{/* Zone de saisie */}
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
style={{ minHeight: '44px', maxHeight: '120px' }}
/>
</div>
{/* Boutons à droite (trombone au-dessus, envoi en dessous) */}
<div className="flex flex-col space-y-2 flex-shrink-0">
{/* Bouton d'ajout de fichier */}
<div className="relative">
<input
type="file"
id="file-upload"
className="hidden"
onChange={handleFileSelect}
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt"
/>
<label
htmlFor="file-upload"
className="flex items-center justify-center w-10 h-10 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full cursor-pointer transition-colors"
>
<Paperclip className="w-5 h-5" />
</label>
</div>
{/* Bouton d'envoi */}
<button
onClick={handleSend}
disabled={!message.trim() || disabled}
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all ${
message.trim() && !disabled
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-lg hover:shadow-xl'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
{/* Indicateur de limite de caractères (optionnel) */}
{message.length > 900 && (
<div className="mt-2 text-right">
<span
className={`text-xs ${
message.length > 1000 ? 'text-red-500' : 'text-yellow-500'
}`}
>
{message.length}/1000
</span>
</div>
)}
</div>
</div>
);
};
export default MessageInput;

View File

@ -0,0 +1,30 @@
import React from 'react';
const TypingIndicator = ({ typingUsers = [] }) => {
if (typingUsers.length === 0) return null;
const getTypingText = () => {
if (typingUsers.length === 1) {
return `${typingUsers[0]} tape un message...`;
} else if (typingUsers.length === 2) {
return `${typingUsers[0]} et ${typingUsers[1]} tapent un message...`;
} else {
return `${typingUsers[0]} et ${typingUsers.length - 1} autres tapent un message...`;
}
};
return (
<div className="flex items-center px-4 py-3 bg-gray-50 border-t border-gray-100 typing-indicator-enter">
<div className="flex space-x-1 mr-3">
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
</div>
<span className="text-sm text-gray-600 italic animate-pulse">
{getTypingText()}
</span>
</div>
);
};
export default TypingIndicator;

View File

@ -3,6 +3,7 @@ import { LogOut } from 'lucide-react';
import { disconnect } from '@/app/actions/authAction';
import { getGravatarUrl } from '@/utils/gravatar';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useChatConnection } from '@/context/ChatConnectionContext';
import DropdownMenu from '@/components/DropdownMenu';
import { usePopup } from '@/context/PopupContext';
import { getRightStr } from '@/utils/rights';
@ -20,6 +21,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity,
} = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false);
const { showPopup } = usePopup();
@ -60,6 +62,36 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
(est) => est.role_id === selectedRoleId
);
// Fonction pour obtenir la couleur de la bulle de statut
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected':
return 'bg-green-500';
case 'connecting':
return 'bg-yellow-500';
case 'error':
return 'bg-red-500';
case 'disconnected':
default:
return 'bg-gray-400';
}
};
// Fonction pour obtenir le titre de la bulle de statut
const getStatusTitle = () => {
switch (connectionStatus) {
case 'connected':
return 'Chat connecté';
case 'connecting':
return 'Connexion au chat...';
case 'error':
return 'Erreur de connexion au chat';
case 'disconnected':
default:
return 'Chat déconnecté';
}
};
// Suppression du tronquage JS, on utilise uniquement CSS
const isSingleRole = establishments && establishments.length === 1;
@ -68,13 +100,20 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
<DropdownMenu
buttonContent={
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white">
<Image
src={getGravatarUrl(user?.email)}
alt="Profile"
className="w-8 h-8 rounded-full mr-2"
width={32}
height={32}
/>
<div className="relative">
<Image
src={getGravatarUrl(user?.email)}
alt="Profile"
className="w-10 h-10 rounded-full object-cover shadow-md"
width={32}
height={32}
/>
{/* Bulle de statut de connexion au chat */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()}
/>
</div>
<div className="flex-1 min-w-0">
<div
className="font-bold text-left truncate max-w-full"

View File

@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { EstablishmentProvider } from '@/context/EstablishmentContext';
import { NotificationProvider } from '@/context/NotificationContext';
import { ClassesProvider } from '@/context/ClassesContext';
import { ChatConnectionProvider } from '@/context/ChatConnectionContext';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import logger from '@/utils/logger';
@ -23,11 +24,13 @@ export default function Providers({ children, messages, locale, session }) {
<CsrfProvider>
<EstablishmentProvider>
<ClassesProvider>
<PopupProvider>
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
</PopupProvider>
<ChatConnectionProvider>
<PopupProvider>
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
</PopupProvider>
</ChatConnectionProvider>
</ClassesProvider>
</EstablishmentProvider>
</CsrfProvider>

View File

@ -31,7 +31,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
</div>
{/* Tabs Content */}
<div className="flex-1 overflow-y-auto rounded-b-lg shadow-inner relative">
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
<AnimatePresence mode="wait">
{tabs.map(
(tab) =>
@ -42,7 +42,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
animate={{ opacity: 1, x: 0 }} // Animation visible
exit={{ opacity: 0, x: -50 }} // Animation de sortie
transition={{ duration: 0.3 }} // Durée des animations
className="absolute w-full h-full"
className="flex-1 flex flex-col h-full min-h-0"
>
{tab.content}
</motion.div>

View File

@ -0,0 +1,270 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
} from 'react';
import { useSession } from 'next-auth/react';
import logger from '@/utils/logger';
import { WS_CHAT_URL } from '@/utils/Url';
const ChatConnectionContext = createContext();
export const ChatConnectionProvider = ({ children }) => {
const { data: session, status } = useSession(); // Ajouter le hook useSession
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('disconnected'); // 'disconnected', 'connecting', 'connected', 'error'
const [userPresences, setUserPresences] = useState({}); // Nouvel état pour les présences
const websocketRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [currentUserId, setCurrentUserId] = useState(null);
const maxReconnectAttempts = 5;
// Système de callbacks pour les messages
const messageCallbacksRef = useRef(new Set());
// Fonctions pour gérer les callbacks de messages
const addMessageCallback = useCallback((callback) => {
messageCallbacksRef.current.add(callback);
return () => {
messageCallbacksRef.current.delete(callback);
};
}, []);
const notifyMessageCallbacks = useCallback((data) => {
messageCallbacksRef.current.forEach((callback) => {
try {
callback(data);
} catch (error) {
logger.error('ChatConnection: Error in message callback', error);
}
});
}, []);
// Gestion des présences utilisateur
const handlePresenceUpdate = useCallback((data) => {
const { user_id, status } = data;
setUserPresences((prev) => ({
...prev,
[user_id]: { status },
}));
}, []);
// Configuration WebSocket
const getWebSocketUrl = (userId) => {
if (!userId) {
logger.warn('ChatConnection: No user ID provided for WebSocket URL');
return null;
}
// Récupérer le token d'authentification depuis NextAuth session
const token = session?.user?.token;
if (!token) {
logger.warn(
'ChatConnection: No access token found for WebSocket connection'
);
return null;
}
// Construire l'URL WebSocket avec le token
const baseUrl = WS_CHAT_URL(userId);
const wsUrl = `${baseUrl}?token=${encodeURIComponent(token)}`;
return wsUrl;
};
// Connexion WebSocket
const connectToChat = (userId = null) => {
const userIdToUse = userId || currentUserId;
// Vérifier que la session est chargée
if (status === 'loading') {
setConnectionStatus('connecting');
return;
}
if (status === 'unauthenticated' || !session) {
logger.warn('ChatConnection: User not authenticated');
setConnectionStatus('error');
return;
}
if (!userIdToUse) {
logger.warn('ChatConnection: Cannot connect without user ID');
setConnectionStatus('error');
return;
}
if (websocketRef.current?.readyState === WebSocket.OPEN) {
return;
}
setCurrentUserId(userIdToUse);
setConnectionStatus('connecting');
try {
const wsUrl = getWebSocketUrl(userIdToUse);
if (!wsUrl) {
throw new Error(
'Cannot generate WebSocket URL - missing token or user ID'
);
}
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.onopen = () => {
logger.info(
'ChatConnection: Connected successfully for user:',
userIdToUse
);
setIsConnected(true);
setConnectionStatus('connected');
setReconnectAttempts(0);
};
websocketRef.current.onclose = (event) => {
setIsConnected(false);
setConnectionStatus('disconnected');
// Tentative de reconnexion automatique
if (reconnectAttempts < maxReconnectAttempts && !event.wasClean) {
const timeout = Math.min(
1000 * Math.pow(2, reconnectAttempts),
30000
);
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts((prev) => prev + 1);
connectToChat();
}, timeout);
}
};
websocketRef.current.onerror = (error) => {
logger.error('ChatConnection: WebSocket error', error);
setConnectionStatus('error');
setIsConnected(false);
};
websocketRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Gérer les messages de présence
if (data.type === 'presence_update') {
handlePresenceUpdate(data);
}
// Notifier tous les callbacks enregistrés
notifyMessageCallbacks(data);
} catch (error) {
logger.error('ChatConnection: Error parsing message', error);
}
};
} catch (error) {
logger.error('ChatConnection: Error creating WebSocket', error);
setConnectionStatus('error');
setIsConnected(false);
}
};
// Déconnexion WebSocket
const disconnectFromChat = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (websocketRef.current) {
websocketRef.current.close(1000, 'User disconnected');
websocketRef.current = null;
}
setIsConnected(false);
setConnectionStatus('disconnected');
setReconnectAttempts(0);
logger.info('ChatConnection: Disconnected by user');
};
// Envoi de message
const sendMessage = (message) => {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
const messageStr = JSON.stringify(message);
websocketRef.current.send(messageStr);
return true;
} else {
logger.warn('ChatConnection: Cannot send message - not connected');
return false;
}
};
// Obtenir la référence WebSocket pour les composants qui en ont besoin
const getWebSocket = () => websocketRef.current;
// Effet pour la gestion de la session et connexion automatique
useEffect(() => {
// Si la session change vers authenticated et qu'on a un user_id, essayer de se connecter
if (status === 'authenticated' && session?.user?.user_id && !isConnected) {
connectToChat(session.user.user_id);
}
// Si la session devient unauthenticated, déconnecter
if (status === 'unauthenticated' && isConnected) {
disconnectFromChat();
}
}, [
status,
session?.user?.user_id,
isConnected,
connectToChat,
disconnectFromChat,
]);
// Nettoyage à la destruction du composant
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocketRef.current) {
websocketRef.current.close();
}
};
}, []);
const value = {
isConnected,
connectionStatus,
userPresences, // Ajouter les présences utilisateur
connectToChat,
disconnectFromChat,
sendMessage,
getWebSocket,
reconnectAttempts,
maxReconnectAttempts,
addMessageCallback, // Ajouter cette fonction
};
return (
<ChatConnectionContext.Provider value={value}>
{children}
</ChatConnectionContext.Provider>
);
};
export const useChatConnection = () => {
const context = useContext(ChatConnectionContext);
if (!context) {
throw new Error(
'useChatConnection must be used within a ChatConnectionProvider'
);
}
return context;
};
export default ChatConnectionContext;

View File

@ -1,3 +1,90 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Animations pour le chat */
@keyframes typing-bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-8px);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.typing-dot {
animation: typing-bounce 1.4s ease-in-out infinite;
}
.typing-dot:nth-child(1) {
animation-delay: 0s;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
.typing-indicator-enter {
animation: fade-in 0.3s ease-out;
}
/* Améliorations visuelles pour les accusés de lecture */
.read-indicator {
transition: all 0.2s ease-in-out;
}
.read-indicator.read {
color: #60a5fa; /* Bleu plus visible pour les messages lus */
}
.read-indicator.sent {
color: #93c5fd; /* Bleu plus clair pour les messages envoyés */
}
/* Animation pour l'apparition des nouveaux messages */
.message-appear {
animation: fade-in 0.3s ease-out;
}
/* Styles Discord-like pour les messages */
.message-container:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.message-container:hover .message-timestamp {
opacity: 1;
}
.message-container:hover .message-actions {
opacity: 1;
}
.message-timestamp {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.message-actions {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}

View File

@ -0,0 +1,249 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { WS_CHAT_URL } from '@/utils/Url';
import logger from '@/utils/logger';
const useWebSocket = (userId, onMessage, onConnectionChange) => {
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false); // Empêcher les connexions multiples
const maxReconnectAttempts = 5;
// Récupération du token JWT
const { data: session } = useSession();
const authToken = session?.user?.token;
// Références stables pour les callbacks
const onMessageRef = useRef(onMessage);
const onConnectionChangeRef = useRef(onConnectionChange);
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
onConnectionChangeRef.current = onConnectionChange;
}, [onConnectionChange]);
const connect = useCallback(() => {
if (!userId || !authToken) {
logger.warn('WebSocket: userId ou token manquant');
return;
}
// Empêcher les connexions multiples simultanées
if (
isConnectingRef.current ||
(wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING)
) {
logger.debug('WebSocket: connexion déjà en cours');
return;
}
// Fermer la connexion existante si elle existe
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = true;
try {
// Ajouter le token à l'URL du WebSocket
const wsUrl = new URL(WS_CHAT_URL(userId));
wsUrl.searchParams.append('token', authToken);
wsRef.current = new WebSocket(wsUrl.toString());
wsRef.current.onopen = () => {
logger.debug('WebSocket connecté');
isConnectingRef.current = false;
setIsConnected(true);
setConnectionStatus('connected');
reconnectAttemptsRef.current = 0;
onConnectionChangeRef.current?.(true);
};
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessageRef.current?.(data);
} catch (error) {
logger.error('Erreur lors du parsing du message WebSocket:', error);
}
};
wsRef.current.onclose = (event) => {
logger.debug('WebSocket fermé:', event.code, event.reason);
isConnectingRef.current = false;
setIsConnected(false);
setConnectionStatus('disconnected');
onConnectionChangeRef.current?.(false);
// Tentative de reconnexion automatique seulement si la fermeture n'est pas intentionnelle
if (
event.code !== 1000 &&
reconnectAttemptsRef.current < maxReconnectAttempts
) {
reconnectAttemptsRef.current++;
setConnectionStatus('reconnecting');
const delay = Math.min(
1000 * Math.pow(2, reconnectAttemptsRef.current),
30000
);
reconnectTimeoutRef.current = setTimeout(() => {
logger.debug(
`Tentative de reconnexion ${reconnectAttemptsRef.current}/${maxReconnectAttempts}`
);
connect();
}, delay);
} else {
setConnectionStatus('failed');
}
};
wsRef.current.onerror = (error) => {
logger.error('Erreur WebSocket:', error);
isConnectingRef.current = false;
setConnectionStatus('error');
};
} catch (error) {
logger.error('Erreur lors de la création du WebSocket:', error);
isConnectingRef.current = false;
setConnectionStatus('error');
}
}, [userId, authToken]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
setConnectionStatus('disconnected');
}, []);
const sendMessage = useCallback(
(message) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
// Ajouter le token à chaque message
const messageWithAuth = {
...message,
token: authToken,
};
wsRef.current.send(JSON.stringify(messageWithAuth));
return true;
} else {
logger.warn("WebSocket non connecté, impossible d'envoyer le message");
return false;
}
},
[authToken]
);
const sendTypingStart = useCallback(
(conversationId) => {
sendMessage({
type: 'typing_start',
conversation_id: conversationId,
});
},
[sendMessage]
);
const sendTypingStop = useCallback(
(conversationId) => {
sendMessage({
type: 'typing_stop',
conversation_id: conversationId,
});
},
[sendMessage]
);
const markAsRead = useCallback(
(conversationId) => {
sendMessage({
type: 'mark_as_read',
conversation_id: conversationId,
});
},
[sendMessage]
);
const joinConversation = useCallback(
(conversationId) => {
sendMessage({
type: 'join_conversation',
conversation_id: conversationId,
});
},
[sendMessage]
);
const leaveConversation = useCallback(
(conversationId) => {
sendMessage({
type: 'leave_conversation',
conversation_id: conversationId,
});
},
[sendMessage]
);
const sendChatMessage = useCallback(
(conversationId, content, attachment = null) => {
const messageData = {
type: 'send_message',
conversation_id: conversationId,
content: content,
message_type: attachment ? 'file' : 'text',
};
// Ajouter les informations du fichier si présent
if (attachment) {
messageData.attachment = attachment;
}
return sendMessage(messageData);
},
[sendMessage]
);
useEffect(() => {
// Se connecter seulement si on a un userId et un token
if (userId && authToken) {
connect();
}
return () => {
disconnect();
};
}, [userId, authToken]); // Retirer connect et disconnect des dépendances
return {
isConnected,
connectionStatus,
sendMessage,
sendTypingStart,
sendTypingStop,
markAsRead,
joinConversation,
leaveConversation,
sendChatMessage,
reconnect: connect,
disconnect,
};
};
export default useWebSocket;

View File

@ -0,0 +1,63 @@
/* Animations pour le chat */
@keyframes typing-bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-8px);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.typing-dot {
animation: typing-bounce 1.4s ease-in-out infinite;
}
.typing-dot:nth-child(1) {
animation-delay: 0s;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
.typing-indicator-enter {
animation: fade-in 0.3s ease-out;
}
/* Améliorations visuelles pour les accusés de lecture */
.read-indicator {
transition: all 0.2s ease-in-out;
}
.read-indicator.read {
color: #60a5fa; /* Bleu plus visible pour les messages lus */
}
.read-indicator.sent {
color: #93c5fd; /* Bleu plus clair pour les messages envoyés */
}
/* Animation pour l'apparition des nouveaux messages */
.message-appear {
animation: fade-in 0.3s ease-out;
}

View File

@ -1,5 +1,6 @@
import { RIGHTS } from '@/utils/rights';
export const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export const WS_BASE_URL = process.env.NEXT_PUBLIC_WSAPI_URL;
//URL-Back-End
@ -53,13 +54,25 @@ export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishm
export const BE_PLANNING_PLANNINGS_URL = `${BASE_URL}/Planning/plannings`;
export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`;
// GESTION EMAIL
export const BE_GESTIONEMAIL_SEND_EMAIL_URL = `${BASE_URL}/GestionEmail/send-email/`;
export const BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionEmail/search-recipients`;
// GESTION MESSAGERIE
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
export const BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/conversations/messages`;
export const BE_GESTIONMESSAGERIE_MARK_AS_READ_URL = `${BASE_URL}/GestionMessagerie/conversations/mark-as-read`;
export const BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL = `${BASE_URL}/GestionMessagerie/conversations`;
export const BE_GESTIONMESSAGERIE_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/messages`;
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-email/`;
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-message`;
export const BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL = `${BASE_URL}/GestionMessagerie/create-conversation/`;
export const BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionMessagerie/search-recipients`;
export const BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL = `${BASE_URL}/GestionMessagerie/upload-file/`;
// WEBSOCKET MESSAGERIE
export const WS_CHAT_URL = (userId) => {
return `${WS_BASE_URL}/ws/chat/${userId}/`;
};
// SETTINGS
export const BE_SETTINGS_SMTP_URL = `${BASE_URL}/Settings/smtp-settings`;