feat: implement messaging functionality with real-time updates
- Introduced a messaging module on the backend using NestJS, including repository, service, controller, DTOs, and WebSocket Gateway. - Developed a frontend messaging page with conversation management, real-time message handling, and chat UI. - Implemented `MessageService` for API integrations and `SocketProvider` for real-time WebSocket updates. - Enhanced database schema to support conversations, participants, and messages with Drizzle ORM.
This commit is contained in:
66
backend/src/database/schemas/messages.ts
Normal file
66
backend/src/database/schemas/messages.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
index,
|
||||||
|
pgTable,
|
||||||
|
primaryKey,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const conversations = pgTable("conversations", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const conversationParticipants = pgTable(
|
||||||
|
"conversation_participants",
|
||||||
|
{
|
||||||
|
conversationId: uuid("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||||
|
joinedAt: timestamp("joined_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.conversationId, t.userId] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const messages = pgTable(
|
||||||
|
"messages",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
conversationId: uuid("conversation_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||||
|
senderId: uuid("sender_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.uuid, { onDelete: "cascade" }),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
readAt: timestamp("read_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
conversationIdIdx: index("messages_conversation_id_idx").on(
|
||||||
|
table.conversationId,
|
||||||
|
),
|
||||||
|
senderIdIdx: index("messages_sender_id_idx").on(table.senderId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationInDb = typeof conversations.$inferSelect;
|
||||||
|
export type NewConversationInDb = typeof conversations.$inferInsert;
|
||||||
|
export type MessageInDb = typeof messages.$inferSelect;
|
||||||
|
export type NewMessageInDb = typeof messages.$inferInsert;
|
||||||
11
backend/src/messages/dto/create-message.dto.ts
Normal file
11
backend/src/messages/dto/create-message.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsUUID, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateMessageDto {
|
||||||
|
@IsUUID()
|
||||||
|
recipientId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(2000)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
40
backend/src/messages/messages.controller.ts
Normal file
40
backend/src/messages/messages.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { CreateMessageDto } from "./dto/create-message.dto";
|
||||||
|
import { MessagesService } from "./messages.service";
|
||||||
|
|
||||||
|
@Controller("messages")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class MessagesController {
|
||||||
|
constructor(private readonly messagesService: MessagesService) {}
|
||||||
|
|
||||||
|
@Get("conversations")
|
||||||
|
getConversations(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.messagesService.getConversations(req.user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("conversations/:id")
|
||||||
|
getMessages(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param("id") conversationId: string,
|
||||||
|
) {
|
||||||
|
return this.messagesService.getMessages(req.user.sub, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
sendMessage(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() dto: CreateMessageDto,
|
||||||
|
) {
|
||||||
|
return this.messagesService.sendMessage(req.user.sub, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/messages/messages.module.ts
Normal file
14
backend/src/messages/messages.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { RealtimeModule } from "../realtime/realtime.module";
|
||||||
|
import { MessagesController } from "./messages.controller";
|
||||||
|
import { MessagesService } from "./messages.service";
|
||||||
|
import { MessagesRepository } from "./repositories/messages.repository";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, RealtimeModule],
|
||||||
|
controllers: [MessagesController],
|
||||||
|
providers: [MessagesService, MessagesRepository],
|
||||||
|
exports: [MessagesService],
|
||||||
|
})
|
||||||
|
export class MessagesModule {}
|
||||||
60
backend/src/messages/messages.service.ts
Normal file
60
backend/src/messages/messages.service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { EventsGateway } from "../realtime/events.gateway";
|
||||||
|
import type { CreateMessageDto } from "./dto/create-message.dto";
|
||||||
|
import { MessagesRepository } from "./repositories/messages.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagesService {
|
||||||
|
constructor(
|
||||||
|
private readonly messagesRepository: MessagesRepository,
|
||||||
|
private readonly eventsGateway: EventsGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendMessage(senderId: string, dto: CreateMessageDto) {
|
||||||
|
let conversation = await this.messagesRepository.findConversationBetweenUsers(
|
||||||
|
senderId,
|
||||||
|
dto.recipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
const newConv = await this.messagesRepository.createConversation();
|
||||||
|
await this.messagesRepository.addParticipant(newConv.id, senderId);
|
||||||
|
await this.messagesRepository.addParticipant(newConv.id, dto.recipientId);
|
||||||
|
conversation = newConv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await this.messagesRepository.createMessage({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderId,
|
||||||
|
text: dto.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify recipient via WebSocket
|
||||||
|
this.eventsGateway.sendToUser(dto.recipientId, "new_message", {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversations(userId: string) {
|
||||||
|
return this.messagesRepository.findAllConversations(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(userId: string, conversationId: string) {
|
||||||
|
const isParticipant = await this.messagesRepository.isParticipant(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException("You are not part of this conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.messagesRepository.findMessagesByConversationId(conversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/src/messages/repositories/messages.repository.ts
Normal file
141
backend/src/messages/repositories/messages.repository.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { DatabaseService } from "../../database/database.service";
|
||||||
|
import {
|
||||||
|
conversationParticipants,
|
||||||
|
conversations,
|
||||||
|
messages,
|
||||||
|
users,
|
||||||
|
} from "../../database/schemas";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagesRepository {
|
||||||
|
constructor(private readonly databaseService: DatabaseService) {}
|
||||||
|
|
||||||
|
async findConversationBetweenUsers(userId1: string, userId2: string) {
|
||||||
|
const results = await this.databaseService.db
|
||||||
|
.select({ id: conversations.id })
|
||||||
|
.from(conversations)
|
||||||
|
.innerJoin(
|
||||||
|
conversationParticipants,
|
||||||
|
eq(conversations.id, conversationParticipants.conversationId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(conversationParticipants.userId, [userId1, userId2])
|
||||||
|
)
|
||||||
|
.groupBy(conversations.id)
|
||||||
|
.having(sql`count(${conversations.id}) = 2`);
|
||||||
|
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConversation() {
|
||||||
|
const [conv] = await this.databaseService.db
|
||||||
|
.insert(conversations)
|
||||||
|
.values({})
|
||||||
|
.returning();
|
||||||
|
return conv;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addParticipant(conversationId: string, userId: string) {
|
||||||
|
await this.databaseService.db
|
||||||
|
.insert(conversationParticipants)
|
||||||
|
.values({ conversationId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMessage(data: {
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
const [msg] = await this.databaseService.db
|
||||||
|
.insert(messages)
|
||||||
|
.values(data)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update conversation updatedAt
|
||||||
|
await this.databaseService.db
|
||||||
|
.update(conversations)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(conversations.id, data.conversationId));
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllConversations(userId: string) {
|
||||||
|
// Sous-requête pour trouver les IDs des conversations de l'utilisateur
|
||||||
|
const userConvs = this.databaseService.db
|
||||||
|
.select({ id: conversationParticipants.conversationId })
|
||||||
|
.from(conversationParticipants)
|
||||||
|
.where(eq(conversationParticipants.userId, userId));
|
||||||
|
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({
|
||||||
|
id: conversations.id,
|
||||||
|
updatedAt: conversations.updatedAt,
|
||||||
|
lastMessage: {
|
||||||
|
text: messages.text,
|
||||||
|
createdAt: messages.createdAt,
|
||||||
|
},
|
||||||
|
recipient: {
|
||||||
|
uuid: users.uuid,
|
||||||
|
username: users.username,
|
||||||
|
displayName: users.displayName,
|
||||||
|
avatarUrl: users.avatarUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(conversations)
|
||||||
|
.innerJoin(
|
||||||
|
conversationParticipants,
|
||||||
|
eq(conversations.id, conversationParticipants.conversationId),
|
||||||
|
)
|
||||||
|
.innerJoin(users, eq(conversationParticipants.userId, users.uuid))
|
||||||
|
.leftJoin(
|
||||||
|
messages,
|
||||||
|
eq(conversations.id, messages.conversationId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(conversations.id, userConvs),
|
||||||
|
eq(conversationParticipants.userId, users.uuid),
|
||||||
|
sql`${users.uuid} != ${userId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(conversations.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMessagesByConversationId(conversationId: string, limit = 50) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({
|
||||||
|
id: messages.id,
|
||||||
|
text: messages.text,
|
||||||
|
createdAt: messages.createdAt,
|
||||||
|
senderId: messages.senderId,
|
||||||
|
readAt: messages.readAt,
|
||||||
|
})
|
||||||
|
.from(messages)
|
||||||
|
.where(eq(messages.conversationId, conversationId))
|
||||||
|
.orderBy(desc(messages.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isParticipant(conversationId: string, userId: string) {
|
||||||
|
const [participant] = await this.databaseService.db
|
||||||
|
.select()
|
||||||
|
.from(conversationParticipants)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(conversationParticipants.conversationId, conversationId),
|
||||||
|
eq(conversationParticipants.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return !!participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParticipants(conversationId: string) {
|
||||||
|
return this.databaseService.db
|
||||||
|
.select({ userId: conversationParticipants.userId })
|
||||||
|
.from(conversationParticipants)
|
||||||
|
.where(eq(conversationParticipants.conversationId, conversationId));
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/src/realtime/events.gateway.ts
Normal file
82
backend/src/realtime/events.gateway.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import {
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
OnGatewayInit,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import { getIronSession } from "iron-session";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
import { getSessionOptions, SessionData } from "../auth/session.config";
|
||||||
|
import { JwtService } from "../crypto/services/jwt.service";
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class EventsGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
{
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(EventsGateway.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit(_server: Server) {
|
||||||
|
this.logger.log("WebSocket Gateway initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
try {
|
||||||
|
// Simuler un objet Request/Response pour iron-session
|
||||||
|
const req: any = {
|
||||||
|
headers: client.handshake.headers,
|
||||||
|
};
|
||||||
|
const res: any = {
|
||||||
|
setHeader: () => {},
|
||||||
|
getHeader: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = await getIronSession<SessionData>(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session.accessToken) {
|
||||||
|
this.logger.warn(`Client ${client.id} unauthorized connection`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await this.jwtService.verifyJwt(session.accessToken);
|
||||||
|
client.data.user = payload;
|
||||||
|
|
||||||
|
// Rejoindre une room personnelle pour les notifications
|
||||||
|
client.join(`user:${payload.sub}`);
|
||||||
|
|
||||||
|
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Connection error for client ${client.id}: ${error}`);
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
this.logger.log(`Client disconnected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode utilitaire pour envoyer des messages à un utilisateur spécifique
|
||||||
|
sendToUser(userId: string, event: string, data: any) {
|
||||||
|
this.server.to(`user:${userId}`).emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/realtime/realtime.module.ts
Normal file
10
backend/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { EventsGateway } from "./events.gateway";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CryptoModule],
|
||||||
|
providers: [EventsGateway],
|
||||||
|
exports: [EventsGateway],
|
||||||
|
})
|
||||||
|
export class RealtimeModule {}
|
||||||
277
frontend/src/app/(dashboard)/messages/page.tsx
Normal file
277
frontend/src/app/(dashboard)/messages/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Search, Send } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useSocket } from "@/providers/socket-provider";
|
||||||
|
import {
|
||||||
|
MessageService,
|
||||||
|
type Conversation,
|
||||||
|
type Message,
|
||||||
|
} from "@/services/message.service";
|
||||||
|
|
||||||
|
export default function MessagesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const [conversations, setConversations] = React.useState<Conversation[]>([]);
|
||||||
|
const [activeConv, setActiveConv] = React.useState<Conversation | null>(null);
|
||||||
|
const [messages, setMessages] = React.useState<Message[]>([]);
|
||||||
|
const [newMessage, setNewMessage] = React.useState("");
|
||||||
|
const [isLoadingConvs, setIsLoadingConvs] = React.useState(true);
|
||||||
|
const [isLoadingMsgs, setIsLoadingMsgs] = React.useState(false);
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchConvs = async () => {
|
||||||
|
try {
|
||||||
|
const data = await MessageService.getConversations();
|
||||||
|
setConversations(data);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Erreur lors du chargement des conversations");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingConvs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchConvs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeConv) {
|
||||||
|
const fetchMsgs = async () => {
|
||||||
|
setIsLoadingMsgs(true);
|
||||||
|
try {
|
||||||
|
const data = await MessageService.getMessages(activeConv.id);
|
||||||
|
setMessages(data.reverse()); // Plus ancien au plus récent
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Erreur lors du chargement des messages");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMsgs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchMsgs();
|
||||||
|
}
|
||||||
|
}, [activeConv]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.on("new_message", (data: { conversationId: string; message: Message }) => {
|
||||||
|
if (activeConv?.id === data.conversationId) {
|
||||||
|
setMessages((prev) => [...prev, data.message]);
|
||||||
|
}
|
||||||
|
// Mettre à jour la liste des conversations
|
||||||
|
setConversations((prev) => {
|
||||||
|
const index = prev.findIndex((c) => c.id === data.conversationId);
|
||||||
|
if (index !== -1) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[index] = {
|
||||||
|
...updated[index],
|
||||||
|
lastMessage: {
|
||||||
|
text: data.message.text,
|
||||||
|
createdAt: data.message.createdAt,
|
||||||
|
},
|
||||||
|
updatedAt: data.message.createdAt,
|
||||||
|
};
|
||||||
|
return updated.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("new_message");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [socket, activeConv]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSendMessage = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newMessage.trim() || !activeConv) return;
|
||||||
|
|
||||||
|
const text = newMessage.trim();
|
||||||
|
setNewMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = await MessageService.sendMessage(activeConv.recipient.uuid, text);
|
||||||
|
setMessages((prev) => [...prev, msg]);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Erreur lors de l'envoi");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
|
||||||
|
{/* Sidebar - Liste des conversations */}
|
||||||
|
<div className="w-80 border-r flex flex-col">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Messages</h2>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Rechercher..." className="pl-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{isLoadingConvs ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Aucune conversation.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
conversations.map((conv) => (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => setActiveConv(conv)}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 rounded-xl transition-colors ${
|
||||||
|
activeConv?.id === conv.id
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-zinc-100 dark:hover:bg-zinc-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={conv.recipient.avatarUrl} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{conv.recipient.username[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 text-left overflow-hidden">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<span className="font-bold truncate">
|
||||||
|
{conv.recipient.displayName || conv.recipient.username}
|
||||||
|
</span>
|
||||||
|
{conv.lastMessage && (
|
||||||
|
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDistanceToNow(new Date(conv.lastMessage.createdAt), {
|
||||||
|
locale: fr,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{conv.lastMessage?.text || "Démarrer une conversation"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone de chat */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{activeConv ? (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b flex items-center gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={activeConv.recipient.avatarUrl} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{activeConv.recipient.username[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold leading-none">
|
||||||
|
{activeConv.recipient.displayName || activeConv.recipient.username}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-green-500 font-medium">En ligne</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<ScrollArea className="flex-1 p-4" viewportRef={scrollRef}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoadingMsgs ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex ${
|
||||||
|
msg.senderId === user?.uuid ? "justify-end" : "justify-start"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] p-3 rounded-2xl text-sm ${
|
||||||
|
msg.senderId === user?.uuid
|
||||||
|
? "bg-primary text-primary-foreground rounded-br-none"
|
||||||
|
: "bg-zinc-100 dark:bg-zinc-800 rounded-bl-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap">{msg.text}</p>
|
||||||
|
<p
|
||||||
|
className={`text-[10px] mt-1 ${
|
||||||
|
msg.senderId === user?.uuid
|
||||||
|
? "text-primary-foreground/70"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{new Date(msg.createdAt).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<form onSubmit={handleSendMessage} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Écrivez un message..."
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
className="rounded-full px-4"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full shrink-0"
|
||||||
|
disabled={!newMessage.trim()}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
||||||
|
<div className="bg-primary/10 p-6 rounded-full mb-4">
|
||||||
|
<Send className="h-12 w-12 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Vos messages</h2>
|
||||||
|
<p className="text-muted-foreground max-w-sm">
|
||||||
|
Sélectionnez une conversation ou démarrez-en une nouvelle pour commencer à
|
||||||
|
discuter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
frontend/src/components/comment-section.tsx
Normal file
162
frontend/src/components/comment-section.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { MoreHorizontal, Send, Trash2 } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { CommentService, type Comment } from "@/services/comment.service";
|
||||||
|
|
||||||
|
interface CommentSectionProps {
|
||||||
|
contentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentSection({ contentId }: CommentSectionProps) {
|
||||||
|
const { user, isAuthenticated } = useAuth();
|
||||||
|
const [comments, setComments] = React.useState<Comment[]>([]);
|
||||||
|
const [newComment, setNewComment] = React.useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
|
const fetchComments = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await CommentService.getByContentId(contentId);
|
||||||
|
setComments(data);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Impossible de charger les commentaires");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [contentId]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchComments();
|
||||||
|
}, [fetchComments]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newComment.trim() || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const comment = await CommentService.create(contentId, newComment.trim());
|
||||||
|
setComments((prev) => [comment, ...prev]);
|
||||||
|
setNewComment("");
|
||||||
|
toast.success("Commentaire publié !");
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Erreur lors de la publication du commentaire");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: string) => {
|
||||||
|
try {
|
||||||
|
await CommentService.remove(commentId);
|
||||||
|
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||||
|
toast.success("Commentaire supprimé");
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 mt-8">
|
||||||
|
<h3 className="font-bold text-lg">Commentaires ({comments.length})</h3>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={user?.avatarUrl} />
|
||||||
|
<AvatarFallback>{user?.username[0].toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Ajouter un commentaire..."
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
className="min-h-[80px] resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" size="sm" disabled={!newComment.trim() || isSubmitting}>
|
||||||
|
{isSubmitting ? "Envoi..." : "Publier"}
|
||||||
|
<Send className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-xl text-center text-sm">
|
||||||
|
Connectez-vous pour laisser un commentaire.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-4">Chargement...</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-4">
|
||||||
|
Aucun commentaire pour le moment. Soyez le premier !
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="flex gap-3">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={comment.user.avatarUrl} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{comment.user.username[0].toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
{comment.user.displayName || comment.user.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(comment.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: fr,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(user?.uuid === comment.user.uuid || user?.role === "admin" || user?.role === "moderator") && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(comment.id)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{comment.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/providers/socket-provider.tsx
Normal file
56
frontend/src/providers/socket-provider.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import { useAuth } from "./auth-provider";
|
||||||
|
|
||||||
|
interface SocketContextType {
|
||||||
|
socket: Socket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = React.createContext<SocketContextType>({
|
||||||
|
socket: null,
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSocket = () => React.useContext(SocketContext);
|
||||||
|
|
||||||
|
export function SocketProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [socket, setSocket] = React.useState<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
|
||||||
|
const socketInstance = io(apiUrl, {
|
||||||
|
withCredentials: true,
|
||||||
|
transports: ["websocket"],
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on("connect", () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketInstance.on("disconnect", () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(socketInstance);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketInstance.disconnect();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
setSocket(null);
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/services/comment.service.ts
Normal file
32
frontend/src/services/comment.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user: {
|
||||||
|
uuid: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommentService = {
|
||||||
|
async getByContentId(contentId: string): Promise<Comment[]> {
|
||||||
|
const { data } = await api.get<Comment[]>(`/contents/${contentId}/comments`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(contentId: string, text: string): Promise<Comment> {
|
||||||
|
const { data } = await api.post<Comment>(`/contents/${contentId}/comments`, {
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(commentId: string): Promise<void> {
|
||||||
|
await api.delete(`/comments/${commentId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
44
frontend/src/services/message.service.ts
Normal file
44
frontend/src/services/message.service.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
updatedAt: string;
|
||||||
|
lastMessage?: {
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
recipient: {
|
||||||
|
uuid: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
senderId: string;
|
||||||
|
readAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageService = {
|
||||||
|
async getConversations(): Promise<Conversation[]> {
|
||||||
|
const { data } = await api.get<Conversation[]>("/messages/conversations");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessages(conversationId: string): Promise<Message[]> {
|
||||||
|
const { data } = await api.get<Message[]>(`/messages/conversations/${conversationId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage(recipientId: string, text: string): Promise<Message> {
|
||||||
|
const { data } = await api.post<Message>("/messages", {
|
||||||
|
recipientId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user