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

@ -1,5 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_USE_FAKE_DATA='false'
AUTH_SECRET='false'
NEXTAUTH_URL=http://localhost:3000
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"

View File

@ -0,0 +1,340 @@
# API Messagerie Instantanée - Guide Développeur
## Vue d'ensemble
Cette documentation technique présente l'implémentation du système de messagerie instantanée, incluant les APIs WebSocket et REST, l'architecture des composants React et les fonctions utilitaires.
## API WebSocket
### Connexion
**URL de connexion :**
```javascript
// Développement
ws://localhost:8000/ws/chat/{userId}/
// Production
wss://[domaine]/ws/chat/{userId}/
```
### Messages WebSocket
#### Messages entrants (serveur → client)
```javascript
// Liste des conversations
{
"type": "conversations_list",
"conversations": [...]
}
// Nouveau message reçu
{
"type": "new_message",
"message": {
"id": 123,
"conversation_id": 456,
"sender_id": 789,
"content": "Contenu du message",
"timestamp": "2024-01-01T12:00:00Z"
}
}
// Utilisateur en train d'écrire
{
"type": "typing_start",
"conversation_id": 456,
"user_id": 789
}
// Utilisateur a arrêté d'écrire
{
"type": "typing_stop",
"conversation_id": 456,
"user_id": 789
}
```
#### Messages sortants (client → serveur)
```javascript
// Envoyer un message
{
"type": "chat_message",
"conversation_id": 456,
"message": "Contenu du message"
}
// Signaler début de frappe
{
"type": "typing_start",
"conversation_id": 456
}
// Signaler fin de frappe
{
"type": "typing_stop",
"conversation_id": 456
}
// Marquer comme lu
{
"type": "mark_as_read",
"conversation_id": 456
}
// Rejoindre une conversation
{
"type": "join_conversation",
"conversation_id": 456
}
```
## API REST
### Endpoints disponibles
```javascript
// Récupérer les conversations
GET /api/messagerie/conversations/{userId}/
Response: Array<Conversation>
// Récupérer les messages d'une conversation
GET /api/messagerie/messages/{conversationId}/
Response: Array<Message>
// Rechercher des destinataires
GET /api/messagerie/search/{establishmentId}/?q={query}
Response: Array<User>
// Créer une conversation
POST /api/messagerie/conversations/create/
Body: { "participants": [userId1, userId2] }
Response: Conversation
// Envoyer un email (séparé de la messagerie instantanée)
POST /api/email/send/
Body: { "recipients": [...], "subject": "...", "content": "..." }
```
## Composants React
### InstantChat
**Props :**
```javascript
{
userProfileId: number, // ID de l'utilisateur connecté
establishmentId: number // ID de l'établissement
}
```
**États principaux :**
- `conversations` : Liste des conversations
- `selectedConversation` : Conversation active
- `messages` : Messages de la conversation active
- `searchQuery` : Terme de recherche
- `searchResults` : Résultats de recherche de contacts
### useWebSocket Hook
**Paramètres :**
```javascript
useWebSocket(
userProfileId, // ID utilisateur
onMessage, // Callback pour messages reçus
onConnectionChange // Callback changement de connexion
);
```
**Valeurs retournées :**
```javascript
{
isConnected: boolean,
connectionStatus: string,
sendChatMessage: (conversationId, content) => boolean,
sendTypingStart: (conversationId) => void,
sendTypingStop: (conversationId) => void,
markAsRead: (conversationId) => void,
joinConversation: (conversationId) => void,
reconnect: () => void
}
```
## Actions Redux/State
### messagerieAction.js
```javascript
// Récupérer les conversations
fetchConversations(userId): Promise<Array<Conversation>>
// Récupérer les messages
fetchMessages(conversationId): Promise<Array<Message>>
// Rechercher des destinataires
searchMessagerieRecipients(establishmentId, query): Promise<Array<User>>
// Créer une conversation
createConversation(participants): Promise<Conversation>
```
### emailAction.js
```javascript
// Envoyer un email
sendEmail(recipients, subject, content, csrfToken): Promise<Response>
// Rechercher des destinataires email
searchEmailRecipients(establishmentId, query): Promise<Array<User>>
```
## Modèles de Données
### Conversation
```javascript
{
conversation_id: number,
participants: Array<User>,
last_message: Message,
created_at: string,
updated_at: string
}
```
### Message
```javascript
{
id: number,
conversation_id: number,
sender_id: number,
content: string,
timestamp: string,
is_read: boolean
}
```
### User
```javascript
{
id: number,
first_name: string,
last_name: string,
email: string,
role: string
}
```
## Gestion des Erreurs
### WebSocket
```javascript
// Reconnexion automatique
const reconnectWebSocket = () => {
setConnectionStatus('reconnecting');
// Logique de reconnexion avec backoff exponentiel
};
// Gestion des erreurs de connexion
wsRef.current.onerror = (error) => {
logger.error('Erreur WebSocket:', error);
setIsConnected(false);
};
```
### API REST
```javascript
// Wrapper avec gestion d'erreur
const apiCall = async (url, options) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Erreur ${response.status}`);
}
return await response.json();
} catch (error) {
logger.error('Erreur API:', error);
throw error;
}
};
```
## Configuration des Tests
### Jest Setup
```javascript
// jest.setup.js
global.WebSocket = class MockWebSocket {
// Mock complet du WebSocket pour les tests
};
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
```
### Tests des Composants
```javascript
// Exemple de test
test('renders InstantChat component', async () => {
await act(async () => {
render(<InstantChat userProfileId={1} establishmentId={123} />);
});
expect(screen.getByText('Messages')).toBeInTheDocument();
});
```
## Intégration Backend
### Consumer Django
```python
# consumers.py
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
# Logique de connexion
async def chat_message(self, event):
# Traitement des messages
```
### URLs Configuration
```python
# routing.py
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
]
```
## Optimisations
### Performance
- Pagination des messages anciens (load on scroll)
- Debounce pour la recherche de contacts (300ms)
- Memoization des composants avec React.memo
- Lazy loading des conversations
### UX
- Reconnexion automatique avec feedback visuel
- Sauvegarde locale des messages en cours de frappe
- Indicateurs de livraison des messages
- Scrolling automatique vers les nouveaux messages

View File

@ -0,0 +1,126 @@
# Système de Messagerie Instantanée
## Présentation
Le système de messagerie instantanée de N3WT-SCHOOL permet aux utilisateurs de l'établissement (administrateurs, professeurs, parents, étudiants) de communiquer en temps réel via une interface chat moderne et intuitive.
## Fonctionnalités
### Chat en Temps Réel
- Envoi et réception de messages instantanés
- Notification de statut de frappe (utilisateur en train d'écrire)
- Indicateur de statut de connexion WebSocket
- Reconnexion automatique en cas de perte de connexion
### Gestion des Conversations
- Liste des conversations existantes
- Création de nouvelles conversations
- Recherche de destinataires par nom ou email
- Compteur de messages non lus
### Interface Utilisateur
- Interface moderne en deux panneaux (conversations + chat)
- Bulles de messages différenciées (expéditeur/destinataire)
- Indicateurs visuels de statut de connexion
- Recherche temps réel de contacts
## Utilisation
### Accès au Chat
Le système de messagerie est accessible via les pages suivantes :
- **Parents** : `/[locale]/parents/messagerie`
- **Administrateurs** : Intégré dans le panneau d'administration
### Créer une Conversation
1. Cliquer sur le bouton "+" en haut à droite de la liste des conversations
2. Rechercher un contact en tapant son nom ou email
3. Sélectionner le destinataire dans les résultats
4. La conversation se crée automatiquement
### Envoyer un Message
1. Sélectionner une conversation dans la liste de gauche
2. Taper le message dans le champ de saisie en bas
3. Appuyer sur Entrée ou cliquer sur le bouton d'envoi
## Architecture Technique
### Frontend (React/Next.js)
**Composants principaux :**
- `InstantChat` : Composant principal du chat
- `ConnectionStatus` : Affichage du statut de connexion
- `ConversationItem` : Élément de liste de conversation
- `MessageBubble` : Bulle de message individuelle
- `MessageInput` : Zone de saisie de message
- `TypingIndicator` : Indicateur de frappe
**Hook personnalisé :**
- `useWebSocket` : Gestion de la connexion WebSocket et des événements
### Backend (Django)
**Module GestionMessagerie :**
- `consumers.py` : Consumer WebSocket pour la messagerie temps réel
- `routing.py` : Configuration des routes WebSocket
- `urls.py` : URLs API REST pour les conversations et messages
**Module GestionEmail :**
- `views.py` : Vues pour l'envoi d'emails classiques
- `urls.py` : URLs pour les fonctions email
### Communication
- **WebSocket** : Communication bidirectionnelle temps réel
- **REST API** : Chargement initial des données et recherche
- **Channels** : Gestion des groupes de conversation Django
## Configuration
### URLs WebSocket
Les URLs sont configurées automatiquement selon l'environnement :
- **Développement** : `ws://localhost:8000/ws/chat/`
- **Production** : `wss://[domaine]/ws/chat/`
### Variables d'Environnement
Le système utilise les configurations standard de l'application pour :
- Base de données (conversations, messages, utilisateurs)
- Authentification (sessions Django)
- Établissements (filtrage par établissement)
## Sécurité
- Authentification requise pour accéder au chat
- Filtrage des conversations par établissement
- Validation côté serveur de tous les messages
- Gestion des permissions selon le rôle utilisateur
## Tests
Le système dispose de tests unitaires Jest couvrant :
- Rendu des composants
- Gestion des connexions WebSocket
- Recherche de contacts
- Envoi de messages
- Indicateurs de frappe
Exécution des tests :
```bash
npm test
```

33
Front-End/jest.config.js Normal file
View File

@ -0,0 +1,33 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/**/*.stories.{js,jsx}',
'!src/pages/_app.js',
'!src/pages/_document.js',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

95
Front-End/jest.setup.js Normal file
View File

@ -0,0 +1,95 @@
import '@testing-library/jest-dom';
// Supprimer les avertissements React act() en environnement de test
global.IS_REACT_ACT_ENVIRONMENT = true;
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() {
return null;
}
disconnect() {
return null;
}
unobserve() {
return null;
}
};
// Mock WebSocket
global.WebSocket = class WebSocket {
constructor(url) {
this.url = url;
this.readyState = WebSocket.CONNECTING;
setTimeout(() => {
this.readyState = WebSocket.OPEN;
if (this.onopen) this.onopen();
}, 10);
}
send(data) {
// Mock send
}
close() {
this.readyState = WebSocket.CLOSED;
if (this.onclose) {
this.onclose({
code: 1000,
reason: 'Normal closure',
wasClean: true,
});
}
}
static get CONNECTING() {
return 0;
}
static get OPEN() {
return 1;
}
static get CLOSING() {
return 2;
}
static get CLOSED() {
return 3;
}
};
// Mock global pour fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
observe() {
return null;
}
disconnect() {
return null;
}
unobserve() {
return null;
}
};

View File

@ -24,6 +24,8 @@ const nextConfig = {
NEXT_PUBLIC_APP_VERSION: pkg.version,
NEXT_PUBLIC_API_URL:
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
NEXT_PUBLIC_WSAPI_URL:
process.env.NEXT_PUBLIC_WSAPI_URL || 'ws://localhost:8080',
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,10 @@
"start": "next start",
"lint": "next lint",
"lint-light": "next lint --quiet",
"check-strings": "node scripts/check-hardcoded-strings.js"
"check-strings": "node scripts/check-hardcoded-strings.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@docuseal/react": "^1.0.56",
@ -37,10 +40,15 @@
"react-tooltip": "^5.28.0"
},
"devDependencies": {
"@testing-library/react": "^13.4.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/user-event": "^14.4.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "14.2.11",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14"
}
}
}

View File

@ -1,4 +1,5 @@
NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_
NEXT_PUBLIC_WSAPI_URL=_NEXT_PUBLIC_WSAPI_URL_
NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_
AUTH_SECRET=_AUTH_SECRET_
NEXTAUTH_URL=_NEXTAUTH_URL_

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`;