From ad6ef4c9072d5b0f5f998abc3460182b46cea2c0 Mon Sep 17 00:00:00 2001 From: Avnyr Date: Fri, 16 May 2025 16:41:55 +0200 Subject: [PATCH] feat: add socket context and notifications listener for real-time event handling - Introduced `SocketProvider` to manage WebSocket connection and context across the app. - Added `NotificationsListener` component to handle real-time notifications and display feedback via `toast`. - Enabled event subscriptions for projects, groups, collaborators, and user actions. --- frontend/components/notifications.tsx | 63 +++++++++ frontend/lib/socket-context.tsx | 192 ++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 frontend/components/notifications.tsx create mode 100644 frontend/lib/socket-context.tsx diff --git a/frontend/components/notifications.tsx b/frontend/components/notifications.tsx new file mode 100644 index 0000000..99ae606 --- /dev/null +++ b/frontend/components/notifications.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; +import { useSocket } from "@/lib/socket-context"; +import { toast } from "sonner"; + +/** + * Notification component that listens for real-time notifications + * and displays them using toast notifications. + */ +export function NotificationsListener() { + const { onNotification, isConnected } = useSocket(); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (!isConnected) return; + + // Set up notification listener + const unsubscribe = onNotification((data) => { + // Display notification based on type + switch (data.type) { + case "project_invitation": + toast.info(data.message, { + description: `You've been invited to collaborate on ${data.projectName}`, + action: { + label: "View Project", + onClick: () => window.location.href = `/projects/${data.projectId}`, + }, + }); + break; + case "group_update": + toast.info(data.message, { + description: data.description, + action: data.projectId && { + label: "View Groups", + onClick: () => window.location.href = `/projects/${data.projectId}/groups`, + }, + }); + break; + case "person_added": + toast.success(data.message, { + description: data.description, + }); + break; + case "person_removed": + toast.info(data.message, { + description: data.description, + }); + break; + default: + toast.info(data.message); + } + }); + + setInitialized(true); + + // Clean up on unmount + return () => { + unsubscribe(); + }; + }, [isConnected, onNotification]); + + // This component doesn't render anything visible + return null; +} \ No newline at end of file diff --git a/frontend/lib/socket-context.tsx b/frontend/lib/socket-context.tsx new file mode 100644 index 0000000..1daa7da --- /dev/null +++ b/frontend/lib/socket-context.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; +import { io, Socket } from "socket.io-client"; +import { useAuth } from "./auth-context"; + +// Define the SocketContext type +interface SocketContextType { + socket: Socket | null; + isConnected: boolean; + joinProject: (projectId: string) => void; + leaveProject: (projectId: string) => void; + // Event listeners + onProjectUpdated: (callback: (data: any) => void) => () => void; + onCollaboratorAdded: (callback: (data: any) => void) => () => void; + onGroupCreated: (callback: (data: any) => void) => () => void; + onGroupUpdated: (callback: (data: any) => void) => () => void; + onPersonAddedToGroup: (callback: (data: any) => void) => () => void; + onPersonRemovedFromGroup: (callback: (data: any) => void) => () => void; + onNotification: (callback: (data: any) => void) => () => void; +} + +// Create the SocketContext +const SocketContext = createContext(undefined); + +// Create a provider component +export function SocketProvider({ children }: { children: ReactNode }) { + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const { user, isAuthenticated } = useAuth(); + + // Initialize socket connection when user is authenticated + useEffect(() => { + if (!isAuthenticated || !user) { + return; + } + + const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + + // Create socket connection + const socketInstance = io(API_URL, { + withCredentials: true, + query: { + userId: user.id, + }, + }); + + // Set up event listeners + socketInstance.on('connect', () => { + console.log('Socket connected'); + setIsConnected(true); + }); + + socketInstance.on('disconnect', () => { + console.log('Socket disconnected'); + setIsConnected(false); + }); + + socketInstance.on('connect_error', (error) => { + console.error('Socket connection error:', error); + setIsConnected(false); + }); + + // Save socket instance + setSocket(socketInstance); + + // Clean up on unmount + return () => { + socketInstance.disconnect(); + setSocket(null); + setIsConnected(false); + }; + }, [isAuthenticated, user]); + + // Join a project room + const joinProject = (projectId: string) => { + if (socket && isConnected) { + socket.emit('project:join', projectId); + } + }; + + // Leave a project room + const leaveProject = (projectId: string) => { + if (socket && isConnected) { + socket.emit('project:leave', projectId); + } + }; + + // Event listeners with cleanup + const onProjectUpdated = (callback: (data: any) => void) => { + if (socket) { + socket.on('project:updated', callback); + } + return () => { + if (socket) { + socket.off('project:updated', callback); + } + }; + }; + + const onCollaboratorAdded = (callback: (data: any) => void) => { + if (socket) { + socket.on('project:collaboratorAdded', callback); + } + return () => { + if (socket) { + socket.off('project:collaboratorAdded', callback); + } + }; + }; + + const onGroupCreated = (callback: (data: any) => void) => { + if (socket) { + socket.on('group:created', callback); + } + return () => { + if (socket) { + socket.off('group:created', callback); + } + }; + }; + + const onGroupUpdated = (callback: (data: any) => void) => { + if (socket) { + socket.on('group:updated', callback); + } + return () => { + if (socket) { + socket.off('group:updated', callback); + } + }; + }; + + const onPersonAddedToGroup = (callback: (data: any) => void) => { + if (socket) { + socket.on('group:personAdded', callback); + } + return () => { + if (socket) { + socket.off('group:personAdded', callback); + } + }; + }; + + const onPersonRemovedFromGroup = (callback: (data: any) => void) => { + if (socket) { + socket.on('group:personRemoved', callback); + } + return () => { + if (socket) { + socket.off('group:personRemoved', callback); + } + }; + }; + + const onNotification = (callback: (data: any) => void) => { + if (socket) { + socket.on('notification:new', callback); + } + return () => { + if (socket) { + socket.off('notification:new', callback); + } + }; + }; + + // Create the context value + const value = { + socket, + isConnected, + joinProject, + leaveProject, + onProjectUpdated, + onCollaboratorAdded, + onGroupCreated, + onGroupUpdated, + onPersonAddedToGroup, + onPersonRemovedFromGroup, + onNotification, + }; + + return {children}; +} + +// Create a hook to use the SocketContext +export function useSocket() { + const context = useContext(SocketContext); + if (context === undefined) { + throw new Error("useSocket must be used within a SocketProvider"); + } + return context; +} \ No newline at end of file