mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 23:43:22 +00:00
feat: mise en place de la messagerie [#17]
This commit is contained in:
@ -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"
|
||||
340
Front-End/docs/api-messagerie-technique.md
Normal file
340
Front-End/docs/api-messagerie-technique.md
Normal 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
|
||||
126
Front-End/docs/messagerie-instantanee.md
Normal file
126
Front-End/docs/messagerie-instantanee.md
Normal 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
33
Front-End/jest.config.js
Normal 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
95
Front-End/jest.setup.js
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
|
||||
7445
Front-End/package-lock.json
generated
7445
Front-End/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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_
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
34
Front-End/src/app/actions/emailAction.js
Normal file
34
Front-End/src/app/actions/emailAction.js
Normal 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);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal 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;
|
||||
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
196
Front-End/src/components/Chat/ConversationItem.js
Normal 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;
|
||||
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
115
Front-End/src/components/Chat/FileAttachment.js
Normal 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;
|
||||
179
Front-End/src/components/Chat/FileUpload.js
Normal file
179
Front-End/src/components/Chat/FileUpload.js
Normal 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;
|
||||
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
File diff suppressed because it is too large
Load Diff
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
133
Front-End/src/components/Chat/MessageBubble.js
Normal 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;
|
||||
233
Front-End/src/components/Chat/MessageInput.js
Normal file
233
Front-End/src/components/Chat/MessageInput.js
Normal 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;
|
||||
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
30
Front-End/src/components/Chat/TypingIndicator.js
Normal 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;
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
270
Front-End/src/context/ChatConnectionContext.js
Normal file
270
Front-End/src/context/ChatConnectionContext.js
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
249
Front-End/src/hooks/useWebSocket.js
Normal file
249
Front-End/src/hooks/useWebSocket.js
Normal 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;
|
||||
63
Front-End/src/styles/chat-animations.css
Normal file
63
Front-End/src/styles/chat-animations.css
Normal 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;
|
||||
}
|
||||
@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user