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:
Mathis HERRIOT
2026-01-29 14:34:22 +01:00
parent 01117aad6d
commit fafdaee668
13 changed files with 995 additions and 0 deletions

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

View 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;
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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 {}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}`);
},
};

View 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;
},
};