![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/lavocat.ca/private_html/src/components/Chat/ |
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSession } from 'next-auth/react';
import { motion, AnimatePresence } from 'framer-motion';
import { format, formatDistanceToNow } from 'date-fns';
import { useToast } from '@/components/ui/use-toast';
import { toast as hotToast } from 'react-hot-toast';
import { useWebSocket } from '../../context/StableWebSocketContext';
import ParticipantActions from './ParticipantActions';
// import VideoCall from './VideoCall'; // â
DISABLED - Using global SimpleVideoCall
import DirectMessage from './DirectMessage';
import ConnectionStatus from '../ConnectionStatus';
import TypingIndicator from '../TypingIndicator';
import UserPresence from '../UserPresence';
import EmojiPicker, { saveRecentEmoji } from '../ui/EmojiPicker';
import MessageReactions from '../ui/MessageReactions';
import FileUpload from '../ui/FileUpload';
import UserAvatar from '../UserAvatar';
import ProfilePopover from '../ProfilePopover';
// Type Definitions
interface User {
id: string;
name: string;
email?: string;
role?: string;
profilePicture?: string;
title?: string;
specialization?: string;
availability?: string;
lastActive?: string;
bio?: string;
}
interface Message {
id: string;
content: string;
createdAt: string;
user: User;
chatRoomId: string;
isOptimistic?: boolean;
type?: 'USER' | 'SYSTEM' | 'ERROR' | 'ACTION'; // To distinguish between user messages and local system messages
isAction?: boolean;
// Modern features
fileUrl?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
isEdited?: boolean;
editedAt?: string;
replyToId?: string;
reactions?: Array<{
id: string;
emoji: string;
userId: string;
user: {
id: string;
name: string;
};
createdAt: string;
}>;
replyTo?: {
id: string;
content: string;
user: {
id: string;
name: string;
};
fileUrl?: string;
mimeType?: string;
fileName?: string;
};
}
interface Participant {
user: User;
role: string;
}
interface ChatRoom {
id: string;
name: string;
participants: Participant[];
lastMessage?: Message | null;
_count?: {
messages: number;
};
description?: string;
}
interface DirectMessageNotification {
senderName: string;
unreadCount: number;
lastMessage?: string;
}
// Helper Components
const SkeletonLoader = ({ count = 1, className = 'h-10 w-full' }) => (
<>
{[...Array(count)].map((_, i) => (
<div key={i} className={`bg-gray-200 dark:bg-gray-700 rounded animate-pulse ${className}`} />
))}
</>
);
const EmptyState = ({ title, message }: { title: string, message: string }) => (
<div className="flex flex-col items-center justify-center h-full text-center text-gray-500 p-8">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 5.523-4.477 10-10 10S1 17.523 1 12 5.477 2 11 2s10 4.477 10 10z" /></svg>
<h3 className="text-xl font-semibold text-gray-700 dark:text-gray-300">{title}</h3>
<p className="mt-1">{message}</p>
</div>
);
const GroupChat: React.FC = () => {
const { data: session } = useSession();
const { ws, connected, sendTyping, joinRoom, leaveRoom, connectionState, directMessageNotifications, getTotalUnreadDirectMessages, reconnect } = useWebSocket();
const { toast } = useToast();
// Debug WebSocket connection
useEffect(() => {
console.log(`[GroupChat] đ WebSocket status changed:`, {
hasWs: !!ws,
connected,
connectionState,
readyState: ws?.readyState,
sessionUser: session?.user?.name
});
// Auto-retry connection if it fails during chat usage
if (connectionState === 'disconnected' && session?.user?.id && !connected) {
const retryTimeout = setTimeout(() => {
console.log('[GroupChat] đ Auto-retrying WebSocket connection...');
reconnect();
}, 3000);
return () => clearTimeout(retryTimeout);
}
}, [ws, connected, connectionState, session?.user?.name, session?.user?.id, reconnect]);
// Request notification permission on component mount
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(permission => {
console.log('[GroupChat] Notification permission:', permission);
});
}
}, []);
// Video call event listener removed for now
const [chatRooms, setChatRooms] = useState<ChatRoom[]>([]);
const [selectedRoom, setSelectedRoom] = useState<ChatRoom | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
const [showParticipants, setShowParticipants] = useState(true);
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const [isTyping, setIsTyping] = useState(false);
const [showCreateRoom, setShowCreateRoom] = useState(false);
const [newRoomName, setNewRoomName] = useState('');
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
const [participants, setParticipants] = useState<Participant[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const selectedRoomRef = useRef(selectedRoom);
const joinedRoomsRef = useRef<Set<string>>(new Set());
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [showPrivateChat, setShowPrivateChat] = useState<{ open: boolean, registrationId: string | null }>({ open: false, registrationId: null });
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
// Video call state removed for now
const [welcomeMessage, setWelcomeMessage] = useState<string | null>(null);
const [directMessage, setDirectMessage] = useState<{ recipientId: string; recipientName: string } | null>(null);
// Modern chat features
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
// Profile popover state
const [profilePopover, setProfilePopover] = useState<{ userId: string; isOpen: boolean } | null>(null);
// Mobile responsive states
const [showLeftSidebar, setShowLeftSidebar] = useState(false);
const [showRightSidebar, setShowRightSidebar] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// Mobile detection and responsive handling
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
// Close sidebars when switching to desktop
if (!mobile) {
setShowLeftSidebar(false);
setShowRightSidebar(false);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
selectedRoomRef.current = selectedRoom;
}, [selectedRoom]);
// Fetch initial chat rooms once
useEffect(() => {
fetchChatRooms();
}, []);
// Fetch messages when a room is selected
useEffect(() => {
if (selectedRoom?.id) {
console.log(`[GroupChat] đ¯ Room selected: ${selectedRoom.name} (${selectedRoom.id})`);
fetchMessages(selectedRoom.id);
} else {
console.log(`[GroupChat] đ¯ No room selected, clearing messages`);
setMessages([]); // Clear messages when no room is selected
}
}, [selectedRoom?.id]);
// Check if user is at bottom of messages
const checkIfAtBottom = () => {
if (!messagesContainerRef.current) return true;
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
return scrollTop + clientHeight >= scrollHeight - 10; // 10px threshold
};
// Handle scroll events
const handleScroll = () => {
setShouldAutoScroll(checkIfAtBottom());
};
// Scroll to bottom of messages
useEffect(() => {
if (shouldAutoScroll && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' });
}
}, [messages, shouldAutoScroll]);
// Reset auto-scroll when changing rooms
useEffect(() => {
setShouldAutoScroll(true);
}, [selectedRoom]);
// Debug reply state changes
useEffect(() => {
if (replyingTo) {
console.log('đ Reply mode activated for message:', replyingTo.id, 'by user:', replyingTo.user.name);
} else {
console.log('đ Reply mode deactivated');
}
}, [replyingTo]);
// Add a new system message to the chat window
const addSystemMessage = (content: string, type: 'SYSTEM' | 'ERROR' = 'SYSTEM') => {
const systemMessage: Message = {
id: `system-${Date.now()}`,
content,
createdAt: new Date().toISOString(),
user: { id: 'system', name: 'System' },
chatRoomId: selectedRoom?.id || 'local',
type,
};
setMessages(prev => [...prev, systemMessage]);
};
const handleCommand = async (command: string) => {
if (!session) {
addSystemMessage('You must be logged in to use commands.', 'ERROR');
return;
}
const [cmd, ...args] = command.substring(1).split(' ');
const argString = args.join(' ');
const [targetUser, ...messageParts] = args;
const message = messageParts.join(' ');
switch (cmd.toLowerCase()) {
case 'help': {
addSystemMessage('--- Help ---');
addSystemMessage('/help - Shows this help message.');
addSystemMessage('/clear - Clears the current chat window.');
addSystemMessage('/switch <room_name> - Switches to a specified room.');
addSystemMessage('/part - Leaves the current room.');
addSystemMessage('/list - Lists all available rooms.');
addSystemMessage('/msg <user_name> <message> - Sends a private message.');
addSystemMessage('/me <action> - Performs an action message.');
if (session.user.role === 'ADMIN') {
addSystemMessage('--- Admin Commands ---');
addSystemMessage('/createroom <room_name> - Creates a new room.');
addSystemMessage('/kick <user_name> [reason] - Kicks a user from the room.');
}
break;
}
case 'clear': {
setMessages([]);
break;
}
case 'list': {
addSystemMessage('Available rooms:');
chatRooms.forEach(room => {
addSystemMessage(`#${room.name} - ${room.participants.length} users`);
});
break;
}
case 'createroom': {
if (session.user.role !== 'ADMIN') {
addSystemMessage('Error: You do not have permission to create rooms.', 'ERROR');
return;
}
if (!argString) {
addSystemMessage('Usage: /createroom <room_name>', 'ERROR');
return;
}
await handleCreateRoom(undefined, argString);
break;
}
case 'switch':
case 'join': {
if (!argString) {
addSystemMessage('Usage: /switch <room_name>', 'ERROR');
return;
}
const roomToSwitch = chatRooms.find(r => r.name.toLowerCase() === argString.toLowerCase());
if (roomToSwitch) {
setSelectedRoom(roomToSwitch);
addSystemMessage(`Switched to room: ${roomToSwitch.name}`);
} else {
addSystemMessage(`Room "${argString}" not found.`, 'ERROR');
}
break;
}
case 'part': {
if (selectedRoom) {
addSystemMessage(`You have left room: ${selectedRoom.name}`);
setSelectedRoom(null);
} else {
addSystemMessage('You are not in a room.', 'ERROR');
}
break;
}
case 'kick': {
if (session.user.role !== 'ADMIN') {
addSystemMessage('Error: You do not have permission to kick users.', 'ERROR');
return;
}
if (!selectedRoom) {
addSystemMessage('Error: You must be in a room to kick a user.', 'ERROR');
return;
}
if (!targetUser) {
addSystemMessage('Usage: /kick <user_name> [reason]', 'ERROR');
return;
}
const participantToKick = participants.find(p => p.user.name.toLowerCase() === targetUser.toLowerCase());
if (!participantToKick) {
addSystemMessage(`Error: User "${targetUser}" not found in this room.`, 'ERROR');
return;
}
try {
const response = await fetch(`/api/chat/rooms/${selectedRoom.id}/participants/${participantToKick.user.id}`, {
method: 'DELETE',
credentials: 'same-origin',
});
const resData = await response.json();
if (!response.ok) {
throw new Error(resData.error || 'Failed to kick user.');
}
// The system message will be sent via WebSocket, so we don't need to add it here.
} catch (err) {
handleError(err instanceof Error ? err.message : 'An unknown error occurred.');
}
break;
}
case 'msg':
case 'query': {
if (!targetUser || !message) {
addSystemMessage('Usage: /msg <user_name> <message>', 'ERROR');
return;
}
let recipient = null;
for (const room of chatRooms) {
const found = room.participants.find(p => p.user.name.toLowerCase() === targetUser.toLowerCase());
if (found) {
recipient = found.user;
break;
}
}
if (!recipient) {
addSystemMessage(`Error: User "${targetUser}" not found.`, 'ERROR');
return;
}
if (recipient.id === session.user.id) {
addSystemMessage('You cannot send a private message to yourself.', 'ERROR');
return;
}
try {
const response = await fetch(`/api/chat/direct/${recipient.id}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }),
});
if (!response.ok) throw new Error('Failed to send private message.');
addSystemMessage(`Message sent to ${recipient.name}: ${message}`);
} catch (err) {
handleError(err instanceof Error ? err.message : 'An unknown error occurred.');
}
break;
}
case 'me': {
if (!argString) {
addSystemMessage('Usage: /me <action>', 'ERROR');
return;
}
if (!selectedRoom) {
addSystemMessage('You must be in a room to perform an action.', 'ERROR');
return;
}
const optimisticId = `optimistic-${Date.now()}`;
const actionContent = `* ${session.user.name} ${argString}`;
const optimisticMessage: Message = {
id: optimisticId,
content: actionContent,
createdAt: new Date().toISOString(),
user: session.user as User,
chatRoomId: selectedRoom.id,
isOptimistic: true,
isAction: true,
};
setMessages(prev => [...prev, optimisticMessage]);
try {
await fetch('/api/chat/group/messages', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: actionContent,
chatRoomId: selectedRoom.id,
isAction: true
}),
});
} catch (err) {
handleError(err instanceof Error ? err.message : 'An unknown error occurred.');
setMessages(prev => prev.filter(m => m.id !== optimisticId));
}
break;
}
default: {
addSystemMessage(`Unknown command: /${cmd}. Type /help for a list of commands.`, 'ERROR');
break;
}
}
};
const handleError = useCallback((message: string, description?: string) => {
console.error(message, description);
toast({
title: message,
description: description,
variant: 'destructive',
});
}, [toast]);
// WebSocket message handling
useEffect(() => {
if (!ws) return;
const handleMessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
console.log(`[GroupChat] đĨ WebSocket message received:`, data.type, data);
switch (data.type) {
case 'CHAT_MESSAGE':
console.log(`[GroupChat] đŦ Processing chat message for room ${data.data.chatRoomId}, current room: ${selectedRoom?.id}`);
console.log(`[GroupChat] đ Message data:`, data.data);
// Show browser notification for new messages from other users
if (data.data.user.id !== session?.user?.id) {
// Only show notification if:
// 1. The message is NOT for the currently active room, OR
// 2. The tab is not visible/active
const isForCurrentRoom = data.data.chatRoomId === selectedRoom?.id;
const isTabVisible = !document.hidden;
const roomName = chatRooms.find(r => r.id === data.data.chatRoomId)?.name || 'Chat Room';
if (!isForCurrentRoom || !isTabVisible) {
// Show toast notification
hotToast((t) => (
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">
{data.data.user.name?.charAt(0) || 'U'}
</span>
</div>
</div>
<div className="flex-1">
<p className="font-medium text-gray-900">
đŦ {data.data.user.name} in #{roomName}
</p>
<p className="text-sm text-gray-600 truncate max-w-48">
{data.data.content}
</p>
</div>
<button
onClick={() => {
hotToast.dismiss(t.id);
const room = chatRooms.find(r => r.id === data.data.chatRoomId);
if (room) {
handleJoinRoom(room);
}
}}
className="flex-shrink-0 bg-green-500 text-white px-3 py-1 rounded text-sm hover:bg-green-600"
>
View
</button>
</div>
), {
duration: 5000,
position: 'top-right',
style: {
maxWidth: '400px',
},
});
// Show browser notification
if (Notification.permission === 'granted') {
new Notification(`New message in #${roomName}`, {
body: `${data.data.user.name}: ${data.data.content}`,
icon: '/icons/apple-touch-icon-180x180.png'
});
}
console.log(`[GroupChat] đ Notification shown for message from ${data.data.user.name} in room ${roomName}`);
}
}
// Update messages if it's for the current room
if (data.data.chatRoomId === selectedRoom?.id) {
console.log(`[GroupChat] â
Adding message to current room`);
setMessages(prev => {
// Check if this message already exists (avoid duplicates)
const existing = prev.find(m => m.id === data.data.id);
if (existing) {
console.log(`[GroupChat] đ Message already exists, skipping duplicate`);
return prev;
}
// Check if this is an update to an optimistic message
const optimistic = prev.find(m => m.isOptimistic && m.content === data.data.content && Math.abs(new Date(m.createdAt).getTime() - new Date(data.data.createdAt).getTime()) < 5000);
if (optimistic) {
console.log(`[GroupChat] đ Updating optimistic message`);
return prev.map(m => m.id === optimistic.id ? { ...data.data, isOptimistic: false } : m);
}
console.log(`[GroupChat] â Adding new message to chat`);
const newMessages = [...prev, data.data];
console.log(`[GroupChat] đ Total messages now: ${newMessages.length}`);
return newMessages;
});
} else {
console.log(`[GroupChat] âī¸ Message for different room (${data.data.chatRoomId}), skipping`);
}
// Always update room's last message and count
setChatRooms(prev => prev.map(room =>
room.id === data.data.chatRoomId ? {
...room,
lastMessage: data.data,
_count: {
...room._count,
messages: (room._count?.messages || 0) + 1
}
} : room
));
break;
case 'ROOM_CREATED':
const newRoom = data.room;
setChatRooms(prev => [newRoom, ...prev]);
try {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'JOIN_ROOM', data: { chatRoomId: newRoom.id } }));
}
} catch (error) {
console.error('Failed to send JOIN_ROOM for new room:', error);
}
toast({ title: "New Room Created", description: `You were added to "${newRoom.name}".` });
break;
case 'TYPING':
if (data.data.chatRoomId === selectedRoom?.id) {
setTypingUsers(prev => {
const newSet = new Set(prev);
if (data.data.isTyping) {
newSet.add(data.data.userName);
} else {
newSet.delete(data.data.userName);
}
return newSet;
});
}
break;
case 'USER_KICKED':
if (data.chatRoomId === selectedRoom?.id) {
if (data.userId === session?.user?.id) {
addSystemMessage(`You have been kicked from the room by ${data.kickedBy}.`, 'ERROR');
if(selectedRoom?.id === data.chatRoomId) {
setSelectedRoom(null);
}
}
setChatRooms(prev => prev.map(room => {
if (room.id === data.chatRoomId) {
return {
...room,
participants: room.participants.filter(p => p.user.id !== data.userId)
};
}
return room;
}));
}
break;
case 'PARTICIPANT_LIST_UPDATE':
if (data.data.chatRoomId === selectedRoom?.id) {
setParticipants(data.data.participants);
}
break;
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.addEventListener('message', handleMessage);
return () => ws.removeEventListener('message', handleMessage);
}, [ws, selectedRoom?.id, session?.user?.id, toast, addSystemMessage, chatRooms]);
// Clear joined rooms when WebSocket connection changes
useEffect(() => {
if (ws && connected) {
console.log('[GroupChat] WebSocket connected, clearing joined rooms ref to ensure re-joining');
joinedRoomsRef.current.clear();
}
}, [ws, connected]);
// Fetch initial data
const fetchChatRooms = async () => {
setIsLoadingRooms(true);
try {
console.log('[GroupChat] đ Fetching chat rooms...');
const response = await fetch('/api/chat/rooms', {
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401) {
addSystemMessage('Authentication required. Please log in again.', 'ERROR');
return;
}
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}: Failed to fetch chat rooms`);
}
const data: ChatRoom[] = await response.json();
console.log('[GroupChat] đ Fetched chat rooms:', data.map(r => ({ id: r.id, name: r.name, messageCount: r._count?.messages })));
if (!data || data.length === 0) {
console.log('[GroupChat] â ī¸ No chat rooms found');
addSystemMessage('No chat rooms available. Please contact an administrator.', 'ERROR');
setChatRooms([]);
return;
}
setChatRooms(data);
// â
PERFORMANCE FIX: Don't auto-join all rooms on connection
// Users will only join rooms when they actively select them
console.log('[GroupChat] đ Chat rooms loaded. Users will join rooms on selection for better performance.');
} catch (error) {
console.error('[GroupChat] â Error fetching chat rooms:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to load chat rooms';
addSystemMessage(`Error: ${errorMessage}`, 'ERROR');
setChatRooms([]);
} finally {
setIsLoadingRooms(false);
}
};
// Fetch messages for selected room with pagination
const fetchMessages = useCallback(async (roomId: string, cursor?: string, append = false) => {
try {
console.log(`[GroupChat] đ Fetching messages for room: ${roomId}${cursor ? ` (cursor: ${cursor})` : ''}`);
setIsLoadingMessages(true);
const url = new URL('/api/chat/group/messages', window.location.origin);
url.searchParams.set('chatRoomId', roomId);
url.searchParams.set('limit', '50');
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url.toString(), {
method: 'GET',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}: Failed to fetch messages`);
}
const data = await response.json();
console.log(`[GroupChat] đ Fetched ${data.messages?.length || 0} messages for room ${roomId} (hasMore: ${data.pagination?.hasMore})`);
if (append) {
// Append older messages to the beginning
setMessages(prev => [...(data.messages || []), ...prev]);
} else {
// Replace messages (initial load)
setMessages(data.messages || []);
}
// Store pagination info for potential "load more" functionality
if (data.pagination) {
console.log(`[GroupChat] đ Pagination info:`, data.pagination);
}
} catch (err) {
console.error(`[GroupChat] â Error fetching messages for room ${roomId}:`, err);
addSystemMessage(`Failed to load chat history: ${err instanceof Error ? err.message : 'Unknown error'}`, 'ERROR');
if (!append) setMessages([]); // Only clear messages on initial load error
} finally {
setIsLoadingMessages(false);
}
}, []);
// Fetch initial data
useEffect(() => {
if (session) {
fetchChatRooms();
}
}, [session]);
// Fetch messages for selected room
useEffect(() => {
if (!selectedRoom) return;
fetchMessages(selectedRoom.id);
}, [selectedRoom, fetchMessages]);
// Removed aggressive auto-refresh - using real-time WebSocket instead
// DEBUG: Temporarily disabled automatic room joining to isolate connection issues
// Will re-enable once basic WebSocket connection is stable
// Handle typing with enhanced WebSocket
const handleTyping = useCallback((typing: boolean) => {
if (!selectedRoom || !sendTyping) return;
setIsTyping(typing);
sendTyping(selectedRoom.id, typing);
}, [selectedRoom, sendTyping]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if ((!newMessage.trim() && !selectedFile) || !selectedRoom || !session) return;
console.log(`[GroupChat] đ¤ Sending message to room ${selectedRoom.id}:`, newMessage.trim());
// Command handling
if (newMessage.startsWith('/') && !selectedFile) {
await handleCommand(newMessage);
setNewMessage('');
return;
}
if (!selectedRoom) {
addSystemMessage('You must be in a room to send a message.', 'ERROR');
setNewMessage('');
return;
}
try {
let fileData: any = {};
// Handle file upload first
if (selectedFile) {
setUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
const uploadResponse = await fetch('/api/chat/upload', {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Failed to upload file');
}
fileData = await uploadResponse.json();
setSelectedFile(null);
setUploading(false);
}
const messageData = {
content: newMessage.trim() || fileData.fileName || 'File attachment',
chatRoomId: selectedRoom.id,
type: selectedFile ? (selectedFile.type.startsWith('image/') ? 'IMAGE' : 'FILE') : 'TEXT',
replyToId: replyingTo?.id,
...fileData
};
const optimisticId = `optimistic-${Date.now()}`;
const optimisticMessage: Message = {
id: optimisticId,
content: messageData.content,
createdAt: new Date().toISOString(),
user: session.user as User,
chatRoomId: selectedRoom.id,
isOptimistic: true,
fileUrl: fileData.fileUrl,
fileName: fileData.fileName,
fileSize: fileData.fileSize,
mimeType: fileData.mimeType,
replyToId: replyingTo?.id,
replyTo: replyingTo ? {
id: replyingTo.id,
content: replyingTo.content,
user: replyingTo.user
} : undefined
};
console.log(`[GroupChat] đ¯ Adding optimistic message:`, optimisticMessage);
setMessages(prev => [...prev, optimisticMessage]);
setNewMessage('');
setReplyingTo(null);
setShowEmojiPicker(false);
handleTyping(false);
console.log(`[GroupChat] đ Sending API request...`);
const response = await fetch('/api/chat/group/messages', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageData),
});
if (!response.ok) throw new Error('Message failed to send.');
const savedMessage = await response.json();
console.log('[GroupChat] â
Message saved to database:', savedMessage);
setMessages(prev => prev.map(m => m.id === optimisticId ? { ...savedMessage, isOptimistic: false } : m));
// WebSocket will handle broadcasting to other users
} catch (err) {
console.error(`[GroupChat] â Error sending message:`, err);
handleError(err instanceof Error ? err.message : 'An unknown error occurred.');
setUploading(false);
// Remove optimistic message on error
setMessages(prev => prev.filter(m => m.id && !m.id.startsWith('optimistic-')));
}
};
const handleCreateRoom = async (e?: React.FormEvent, roomName?: string) => {
if (e) e.preventDefault();
const name = roomName || newRoomName;
if (!name.trim() || !session?.user) return;
setIsCreatingRoom(true);
try {
const response = await fetch('/api/chat/rooms', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() })
});
if (!response.ok) throw new Error('Failed to create room');
const newRoom = await response.json();
// Add a system message locally
if (!e) { // Only show system message if called from a command
addSystemMessage(`Room "${newRoom.name}" has been created.`);
}
// The ROOM_CREATED websocket event will add the room to the list
setSelectedRoom(newRoom);
setNewRoomName('');
setShowCreateRoom(false);
toast({ title: "Room Created", description: `"${newRoom.name}" has been created successfully.` });
} catch (err) {
handleError(err instanceof Error ? err.message : 'Failed to create room');
} finally {
setIsCreatingRoom(false);
}
};
const handleDeleteRoom = async (roomId: string, roomName: string) => {
if (!window.confirm(`Are you sure you want to delete the room "${roomName}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/chat/rooms?roomId=${roomId}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete room');
}
toast({ title: "Room Deleted", description: `"${roomName}" has been permanently deleted.` });
setChatRooms(prev => prev.filter(room => room.id !== roomId));
if (selectedRoom?.id === roomId) {
setSelectedRoom(null);
}
} catch (err) {
handleError(err instanceof Error ? err.message : 'Failed to delete room');
}
};
const handleKickParticipant = async (userId: string) => {
if (!selectedRoom) return;
try {
const response = await fetch(`/api/admin/chat/participants?roomId=${selectedRoom.id}&userId=${userId}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to kick user');
}
// Optimistically remove participant from UI
setChatRooms(prev => prev.map(room => {
if (room.id === selectedRoom.id) {
return { ...room, participants: room.participants.filter(p => p.user.id !== userId) };
}
return room;
}));
toast({ title: "User Kicked", description: "The user has been removed from the room." });
} catch (err) {
handleError(err instanceof Error ? err.message : 'An unknown error occurred.');
}
};
const handleDoubleClickUser = (userId: string, userName: string) => {
if (userId === session?.user?.id) {
addSystemMessage('You cannot start a private chat with yourself.', 'ERROR');
return;
}
setDirectMessage({ recipientId: userId, recipientName: userName });
};
// Handle reaction actions
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
try {
console.log('Adding reaction:', { messageId, emoji });
const response = await fetch('/api/chat/group/reactions', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messageId, emoji }),
});
if (response.ok) {
const reaction = await response.json();
console.log('Reaction added successfully:', reaction);
// Optimistically update the UI
setMessages(prev => prev.map(msg =>
msg.id === messageId
? { ...msg, reactions: [...(msg.reactions || []), reaction] }
: msg
));
saveRecentEmoji(emoji);
} else {
const errorData = await response.text();
console.error('Failed to add reaction:', response.status, errorData);
toast({
title: "Failed to add reaction",
description: "Please try again",
variant: "destructive",
});
}
} catch (error) {
console.error('Failed to add reaction:', error);
toast({
title: "Failed to add reaction",
description: "Please try again",
variant: "destructive",
});
}
}, [toast]);
const handleRemoveReaction = useCallback(async (messageId: string, emoji: string) => {
try {
console.log('Removing reaction:', { messageId, emoji });
const response = await fetch(`/api/chat/group/reactions?messageId=${messageId}&emoji=${emoji}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (response.ok) {
console.log('Reaction removed successfully');
// Optimistically update the UI
setMessages(prev => prev.map(msg =>
msg.id === messageId
? {
...msg,
reactions: (msg.reactions || []).filter(r =>
!(r.emoji === emoji && r.userId === session?.user?.id)
)
}
: msg
));
} else {
const errorData = await response.text();
console.error('Failed to remove reaction:', response.status, errorData);
toast({
title: "Failed to remove reaction",
description: "Please try again",
variant: "destructive",
});
}
} catch (error) {
console.error('Failed to remove reaction:', error);
toast({
title: "Failed to remove reaction",
description: "Please try again",
variant: "destructive",
});
}
}, [toast]);
// Handle emoji selection
const handleEmojiSelect = (emoji: string) => {
setNewMessage(prev => prev + emoji);
// Don't auto-close picker for better UX
saveRecentEmoji(emoji);
};
// Handle quick reactions
const handleQuickReaction = (message: Message, emoji: string) => {
const hasReacted = message.reactions?.some(r =>
r.emoji === emoji && r.userId === session?.user?.id
);
if (hasReacted) {
handleRemoveReaction(message.id, emoji);
} else {
handleAddReaction(message.id, emoji);
}
};
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Memoized message component for better performance
const MessageComponent = React.memo(({ message }: { message: Message }) => {
const time = format(new Date(message.createdAt), 'HH:mm');
if (message.type === 'SYSTEM' || message.type === 'ERROR') {
return (
<div key={message.id} className={`text-sm py-1 px-4 ${message.type === 'ERROR' ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
<span className="text-gray-400 dark:text-gray-500 mr-2">[{time}]</span>
<span className="italic">-- {message.content}</span>
</div>
);
}
if (message.isAction) {
return (
<div key={message.id} className="text-sm py-1 px-4 text-purple-600 dark:text-purple-400 italic">
<span className="text-gray-400 dark:text-gray-500 mr-2">[{time}]</span>
<span>{message.content}</span>
</div>
);
}
const userName = message.user?.name || 'Unknown User';
const isOwn = message.user?.id === session?.user?.id;
const isImage = message.fileUrl && (
message.mimeType?.startsWith('image/') ||
/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(message.fileName || '')
);
return (
<div key={message.id} className="group text-sm py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-800/50">
{/* Reply context */}
{message.replyTo && (
<div className="mb-1 ml-16 p-2 bg-gray-100 dark:bg-gray-700 rounded border-l-4 border-blue-500 text-xs">
<div className="flex items-center gap-2">
{message.replyTo.fileUrl && message.replyTo.mimeType?.startsWith('image/') && (
<img
src={message.replyTo.fileUrl}
alt="Reply thumbnail"
className="w-8 h-8 object-cover rounded border flex-shrink-0"
/>
)}
<span className="text-gray-600 dark:text-gray-400 flex-1">
Replying to {message.replyTo.user.name}:{' '}
{message.replyTo.fileUrl ? (
message.replyTo.mimeType?.startsWith('image/') ? (
<span className="italic">đˇ Image</span>
) : (
<span className="italic">đ File attachment</span>
)
) : (
<>
{message.replyTo.content.substring(0, 100)}
{message.replyTo.content.length > 100 ? '...' : ''}
</>
)}
</span>
</div>
</div>
)}
<div className="flex items-start gap-3">
<div className="relative">
<UserAvatar
user={message.user}
size="sm"
showTooltip={true}
clickable={true}
onClick={() => setProfilePopover({
userId: message.user.id,
isOpen: profilePopover?.userId !== message.user.id || !profilePopover?.isOpen
})}
/>
{profilePopover?.userId === message.user.id && profilePopover?.isOpen && (
<ProfilePopover
userId={message.user.id}
isOpen={true}
onClose={() => setProfilePopover(null)}
position="right"
onStartDirectMessage={(userId, userName) => {
handleDoubleClickUser(userId, userName);
}}
/>
)}
</div>
<div className="flex-1">
<div className="flex items-baseline gap-2">
<span className="text-gray-400 dark:text-gray-500 text-xs">[{time}]</span>
<span className={`font-semibold text-sm ${
isOwn ? 'text-blue-600 dark:text-blue-400' :
message.user.role === 'ADMIN' ? 'text-purple-600 dark:text-purple-400' :
'text-green-600 dark:text-green-400'
}`}>
{message.user.role === 'ADMIN' ? 'âī¸ ' : ''}{userName}
</span>
{message.user.title && (
<span className="text-xs text-gray-500 dark:text-gray-400">
âĸ {message.user.title}
</span>
)}
{message.isEdited && (
<span className="text-xs text-gray-400 dark:text-gray-500">(edited)</span>
)}
</div>
{/* File attachment */}
{message.fileUrl && (
<div className="mt-1 mb-2">
{isImage ? (
<div className="relative group">
<img
src={message.fileUrl}
alt={message.fileName || 'Image'}
className="max-w-xs max-h-64 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:opacity-90 transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => {
// Create and show image modal
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4';
modal.style.zIndex = '9999';
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
// Create modal content
const modalContent = document.createElement('div');
modalContent.className = 'relative max-w-4xl max-h-full';
// Close button
const closeBtn = document.createElement('button');
closeBtn.className = 'absolute -top-10 right-0 text-white hover:text-gray-300 text-xl font-bold z-10';
closeBtn.innerHTML = 'â';
closeBtn.onclick = () => modal.remove();
// Image element
const img = document.createElement('img');
img.src = message.fileUrl || '';
img.alt = message.fileName || 'Image';
img.className = 'max-w-full max-h-full object-contain rounded-lg';
// Bottom overlay with download
const bottomOverlay = document.createElement('div');
bottomOverlay.className = 'absolute bottom-0 left-0 right-0 bg-black bg-opacity-80 text-white p-3 rounded-b-lg flex items-center justify-between';
// File info section
const fileInfo = document.createElement('div');
fileInfo.className = 'flex-1';
const fileTitle = document.createElement('div');
fileTitle.className = 'font-medium';
fileTitle.textContent = 'đˇ Image';
fileInfo.appendChild(fileTitle);
if (message.fileSize) {
const fileSize = document.createElement('div');
fileSize.className = 'text-sm text-gray-300';
fileSize.textContent = formatFileSize(message.fileSize);
fileInfo.appendChild(fileSize);
}
// Download button
const downloadBtn = document.createElement('a');
downloadBtn.href = message.fileUrl || '';
downloadBtn.download = message.fileName || 'image';
downloadBtn.className = 'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center gap-2 transition-colors';
downloadBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download
`;
// Assemble modal
bottomOverlay.appendChild(fileInfo);
bottomOverlay.appendChild(downloadBtn);
modalContent.appendChild(closeBtn);
modalContent.appendChild(img);
modalContent.appendChild(bottomOverlay);
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Handle escape key
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const errorDiv = target.nextElementSibling as HTMLElement;
if (errorDiv) errorDiv.style.display = 'block';
}}
loading="lazy"
/>
{/* Error fallback */}
<div
className="hidden max-w-xs p-4 bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 text-center"
>
<svg className="w-8 h-8 text-gray-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400">Image failed to load</p>
<a
href={message.fileUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-2 text-blue-500 hover:text-blue-600 text-xs"
>
Open in new tab
</a>
</div>
{/* Click hint overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded-lg flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity bg-black/70 text-white text-sm px-3 py-1 rounded-full pointer-events-none">
đ Click to view
</div>
</div>
</div>
) : (
<a
href={message.fileUrl || '#'}
download={message.fileName}
className="inline-flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors group cursor-pointer"
>
<div className="flex-shrink-0">
<svg className="w-8 h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100">
{message.mimeType?.includes('pdf') ? 'đ PDF Document' :
message.mimeType?.includes('doc') ? 'đ Document' :
message.mimeType?.includes('spreadsheet') || message.mimeType?.includes('excel') ? 'đ Spreadsheet' :
message.mimeType?.includes('zip') || message.mimeType?.includes('rar') ? 'đī¸ Archive' :
message.mimeType?.includes('video') ? 'đĨ Video' :
message.mimeType?.includes('audio') ? 'đĩ Audio' :
'đ File Attachment'}
</div>
{message.fileSize && (
<div className="text-sm text-gray-500 dark:text-gray-400">{formatFileSize(message.fileSize)}</div>
)}
</div>
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-gray-400 group-hover:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
</a>
)}
</div>
)}
{/* Message content */}
{message.content && (
<div className="text-gray-800 dark:text-gray-200 break-words whitespace-pre-wrap">
{message.content}
</div>
)}
{/* Quick reaction buttons (visible on hover) */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity mt-1 flex items-center gap-1">
{['đ', 'â¤ī¸', 'đ', 'đŽ'].map((emoji) => (
<button
key={emoji}
onClick={() => handleQuickReaction(message, emoji)}
className="text-sm hover:scale-110 transition-transform opacity-60 hover:opacity-100"
title={`React with ${emoji}`}
>
{emoji}
</button>
))}
<button
onClick={() => {
console.log('Reply button clicked for message:', message.id);
setReplyingTo(message);
}}
className="text-xs text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 ml-2 font-medium"
title="Reply to this message"
>
âŠī¸ Reply
</button>
</div>
{/* Reactions display */}
<MessageReactions
messageId={message.id}
reactions={message.reactions || []}
onAddReaction={handleAddReaction}
onRemoveReaction={handleRemoveReaction}
className="mt-1"
/>
</div>
</div>
</div>
);
});
const uniqueParticipants = participants;
const handleJoinRoom = async (room: ChatRoom) => {
if (selectedRoom?.id === room.id) {
console.log(`[GroupChat] đ Already in room: ${room.name}`);
return; // Already in this room
}
console.log(`[GroupChat] đĒ Switching to room: ${room.name} (${room.id})`);
// Leave the current room first (only if we're actually joined)
if (selectedRoom?.id && leaveRoom && joinedRoomsRef.current.has(selectedRoom.id)) {
try {
console.log(`[GroupChat] đ Leaving room: ${selectedRoom.name}`);
await leaveRoom(selectedRoom.id);
joinedRoomsRef.current.delete(selectedRoom.id);
console.log(`[GroupChat] â
Left room: ${selectedRoom.name}`);
} catch (error) {
console.error(`[GroupChat] â Failed to leave room ${selectedRoom.name}:`, error);
}
}
// Update UI immediately for better responsiveness
setSelectedRoom(room);
setMessages([]);
setWelcomeMessage(room.description || `Welcome to ${room.name}`);
// Join the new room via WebSocket
if (joinRoom && !joinedRoomsRef.current.has(room.id)) {
try {
console.log(`[GroupChat] đ Joining room via WebSocket: ${room.name}`);
await joinRoom(room.id);
joinedRoomsRef.current.add(room.id);
console.log(`[GroupChat] â
Successfully joined room: ${room.name}`);
} catch (error) {
console.error(`[GroupChat] â Failed to join room ${room.name}:`, error);
addSystemMessage(`Failed to join room: ${room.name}`, 'ERROR');
}
} else if (joinedRoomsRef.current.has(room.id)) {
console.log(`[GroupChat] đ Already joined room ${room.name}, just switching view`);
}
};
// Fetch initial participants when a room is selected, and on component mount
const fetchParticipants = async (roomId: string) => {
try {
const response = await fetch(`/api/chat/rooms/${roomId}/participants`, {
method: 'GET',
credentials: 'same-origin',
});
if (!response.ok) throw new Error('Failed to fetch participants.');
const data = await response.json();
setParticipants(data);
} catch (err) {
handleError(err instanceof Error ? err.message : 'An unknown error occurred.');
}
};
useEffect(() => {
if (selectedRoom?.id) {
fetchParticipants(selectedRoom.id);
}
}, [selectedRoom?.id]);
// Only join a room when explicitly selected by the user
// Removed automatic bulk room joining that was overwhelming the WebSocket connection
// â
PERFORMANCE: Memoize expensive operations
const filteredMessages = useMemo(() => {
return messages.filter(msg => !msg.isOptimistic || !messages.find(m => !m.isOptimistic && m.content === msg.content && Math.abs(new Date(m.createdAt).getTime() - new Date(msg.createdAt).getTime()) < 5000));
}, [messages]);
const sortedChatRooms = useMemo(() => {
return [...chatRooms].sort((a, b) => {
const aTime = a.lastMessage?.createdAt ? new Date(a.lastMessage.createdAt).getTime() : 0;
const bTime = b.lastMessage?.createdAt ? new Date(b.lastMessage.createdAt).getTime() : 0;
return bTime - aTime;
});
}, [chatRooms]);
const currentRoomParticipants = useMemo(() => {
return selectedRoom?.participants || [];
}, [selectedRoom?.participants]);
// Request notification permission
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().then(permission => {
console.log('[GroupChat] Notification permission:', permission);
});
}
}, []);
const isLawyer = session?.user?.role === 'LAWYER';
const isVerified = session?.user?.isVerifiedLawyer || session?.user?.verificationStatus === 'VERIFIED_BARREAU';
// UI Rendering...
return (
<div className="relative flex h-[26rem] md:h-[32rem] w-full font-mono antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-sm border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg overflow-hidden">
{/* Mobile Header */}
{isMobile && (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center justify-between p-3 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 h-14">
<button
onClick={() => setShowLeftSidebar(!showLeftSidebar)}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex-1 text-center">
<h2 className="font-bold text-base truncate">
{selectedRoom?.name || 'Group Chat'}
</h2>
</div>
<button
onClick={() => setShowRightSidebar(!showRightSidebar)}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</button>
</div>
)}
{/* Mobile Overlay */}
{isMobile && (showLeftSidebar || showRightSidebar) && (
<div
className="absolute inset-0 bg-black bg-opacity-50 z-20"
onClick={() => {
setShowLeftSidebar(false);
setShowRightSidebar(false);
}}
/>
)}
{/* Left Sidebar: Room List */}
<aside className={`
flex flex-col border-r border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900
${isMobile
? `absolute top-0 left-0 h-full w-80 max-w-[85vw] z-30 transform transition-transform duration-300 ${
showLeftSidebar ? 'translate-x-0' : '-translate-x-full'
}`
: 'w-64 relative'
}
`}>
<header className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-800 h-14 flex-shrink-0">
<div className="flex items-center gap-2">
<h1 className="text-base font-bold">Channels</h1>
{getTotalUnreadDirectMessages() > 0 && (
<div className="relative">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd" />
</svg>
<span className="absolute -top-1 -right-1 block h-4 w-4 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center border border-white dark:border-gray-900">
{getTotalUnreadDirectMessages() > 9 ? '9+' : getTotalUnreadDirectMessages()}
</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
{session?.user?.role === 'ADMIN' && (
<button
onClick={() => setShowCreateRoom(true)}
className="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
title="Create New Room"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
)}
<div title={connected ? 'Connected' : 'Disconnected'} className="relative w-2.5 h-2.5">
<div className={`w-full h-full rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}></div>
{connected && <div className={`absolute top-0 left-0 w-full h-full rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'} animate-ping`}></div>}
</div>
</div>
</header>
{/* Create Room Form */}
{showCreateRoom && (
<div className="p-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
<form onSubmit={handleCreateRoom} className="space-y-2">
<input
type="text"
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
placeholder="New channel name..."
className="w-full px-2 py-1 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-sm focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
disabled={isCreatingRoom}
/>
<div className="flex gap-2">
<button
type="submit"
disabled={!newRoomName.trim() || isCreatingRoom}
className="w-full px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50"
>
{isCreatingRoom ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={() => { setShowCreateRoom(false); setNewRoomName(''); }}
className="w-full px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 text-xs rounded hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="flex-1 overflow-y-auto">
{/* Direct Messages Section */}
{directMessageNotifications.size > 0 && (
<div className="p-2 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
Direct Messages ({getTotalUnreadDirectMessages()})
</h2>
<div className="space-y-1">
{Array.from(directMessageNotifications.entries()).map(([senderId, notification]) => (
<button
key={senderId}
onClick={() => handleDoubleClickUser(senderId, notification.senderName)}
className="w-full text-left flex items-center gap-2 p-1.5 rounded transition-colors duration-100 hover:bg-gray-100 dark:hover:bg-gray-800/50"
>
<div className="relative">
<div className="w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center text-xs font-bold">
{notification.senderName.charAt(0).toUpperCase()}
</div>
{notification.unreadCount > 0 && (
<span className="absolute -top-1 -right-1 block h-3 w-3 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center border border-white dark:border-gray-900">
{notification.unreadCount > 9 ? '9+' : notification.unreadCount}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold truncate ${
notification.unreadCount > 0
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}>
{notification.senderName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{notification.lastMessage}
</p>
</div>
{notification.unreadCount > 0 && (
<span className="text-xs px-1.5 py-0.5 bg-red-500 text-white rounded-full font-bold">
{notification.unreadCount}
</span>
)}
</button>
))}
</div>
</div>
)}
{isLoadingRooms ? (
<div className="p-2 space-y-2">
<SkeletonLoader count={8} className="h-8 w-full rounded" />
</div>
) : (
<div className="p-2 space-y-1">
{/* Channels Section Header */}
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 px-1">
Channels {chatRooms.length > 0 && `(${chatRooms.length})`}
</h2>
{chatRooms.length === 0 && !isLoadingRooms && (
<div className="text-center text-gray-500 py-4">
<p className="text-sm">No channels available</p>
{!connected && (
<button
onClick={reconnect}
className="mt-2 px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700"
>
Reconnect
</button>
)}
</div>
)}
{chatRooms.map(room => {
const isSelected = selectedRoom?.id === room.id;
const isJoined = joinedRoomsRef.current.has(room.id);
return (
<div key={room.id} className="flex items-center group">
<button
onClick={() => handleJoinRoom(room)}
className={`w-full text-left flex items-center gap-2 p-1.5 rounded transition-colors duration-100 ${
isSelected
? 'bg-blue-600 text-white font-semibold'
: 'hover:bg-gray-100 dark:hover:bg-gray-800/50'
}`}
>
<span className={`font-mono font-bold ${isSelected ? 'text-blue-200' : 'text-gray-400'}`}>#</span>
<span className="flex-1 truncate">{room.name}</span>
<div className="flex items-center gap-1">
{/* Connection indicator */}
{isJoined && (
<div
className={`w-2 h-2 rounded-full ${
isSelected ? 'bg-green-300' : 'bg-green-500'
}`}
title={`Connected to ${room.name}`}
/>
)}
{/* Message count */}
{room._count && room._count.messages > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
isSelected
? 'bg-white text-blue-600'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}>
{room._count.messages}
</span>
)}
</div>
</button>
{session?.user?.role === 'ADMIN' && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteRoom(room.id, room.name);
}}
className="p-1 ml-1 rounded text-gray-400 hover:text-red-600 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title={`Delete ${room.name}`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
);
})}
</div>
)}
</div>
</aside>
{/* Center Panel: Chat Area */}
<main className={`flex-1 flex flex-col bg-white dark:bg-gray-900 ${!isMobile ? 'border-r border-gray-200 dark:border-gray-800' : ''} ${isMobile ? 'pt-14' : ''}`}>
{!selectedRoom ? (
<div className="flex-1 flex items-center justify-center text-gray-500 p-4">
<div className="text-center max-w-sm">
{chatRooms.length === 0 ? (
<>
<p className="text-base">No chat rooms available.</p>
<p className="text-sm mt-2 text-gray-400">Please contact an administrator to create chat rooms.</p>
{!connected && (
<div className="mt-4 p-4 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
<p className="text-orange-600 dark:text-orange-400 text-sm">â ī¸ Connection issues detected</p>
<button
onClick={reconnect}
className="mt-2 px-4 py-2 bg-orange-600 text-white rounded text-sm hover:bg-orange-700 transition-colors"
>
Reconnect
</button>
</div>
)}
</>
) : (
<>
<p className="text-base">No channel selected.</p>
<p className="text-sm mt-2 text-gray-400">
{isMobile ? 'Tap the menu to select a channel' : 'Click on a channel to start chatting'}
</p>
</>
)}
</div>
</div>
) : (
<>
{/* Desktop Header */}
{!isMobile && (
<header className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-800 h-14 flex-shrink-0">
<div className="flex items-center space-x-2">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">#{selectedRoom.name}</h2>
<button
onClick={() => fetchMessages(selectedRoom.id)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm"
title="Refresh messages"
>
đ
</button>
<ConnectionStatus />
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowParticipants(!showParticipants)}
className="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
title="Toggle Members List"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
</button>
</div>
</header>
)}
<div
className={`overflow-y-auto p-2 ${isMobile ? 'h-[calc(100%-8rem)]' : 'h-[19.5rem]'} touch-pan-y`}
onScroll={handleScroll}
ref={messagesContainerRef}
>
{/* Welcome Message */}
{welcomeMessage && (
<div className="text-center p-3 my-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-300">{welcomeMessage}</p>
</div>
)}
{isLoadingMessages ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-gray-500 dark:text-gray-400 mt-3 text-xs">Loading Messages...</p>
</div>
) : (
<div>
{messages.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
<p>No messages yet. Be the first to say something!</p>
</div>
) : (
messages.map(message => (
<MessageComponent key={message.id} message={message} />
))
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Typing Indicator */}
<TypingIndicator roomId={selectedRoom.id} />
<footer className="p-2 border-t border-gray-200 dark:border-gray-800">
{/* Reply preview */}
<AnimatePresence>
{replyingTo && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-2 p-2 bg-gray-100 dark:bg-gray-700 rounded border-l-4 border-blue-500"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
{replyingTo.fileUrl && replyingTo.mimeType?.startsWith('image/') && (
<img
src={replyingTo.fileUrl}
alt="Reply thumbnail"
className="w-8 h-8 object-cover rounded border flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-600 dark:text-gray-400">
Replying to {replyingTo.user.name}
</p>
<p className="text-sm text-gray-800 dark:text-gray-200 truncate">
{replyingTo.fileUrl ? (
replyingTo.mimeType?.startsWith('image/') ? (
<span className="italic">đˇ Image</span>
) : (
<span className="italic">đ File attachment</span>
)
) : (
replyingTo.content
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!isMobile && (
<div className="relative">
<button
type="button"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xs"
title="Add emoji"
>
đ
</button>
<EmojiPicker
isOpen={showEmojiPicker}
onClose={() => setShowEmojiPicker(false)}
onEmojiSelect={handleEmojiSelect}
position="bottom"
/>
</div>
)}
<button
onClick={() => setReplyingTo(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xs p-1"
>
Ã
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<form onSubmit={handleSendMessage} className="relative">
<div className="flex items-end gap-2">
{/* File upload */}
<FileUpload
onFileSelect={setSelectedFile}
onRemoveFile={() => setSelectedFile(null)}
selectedFile={selectedFile}
uploading={uploading}
className="flex-shrink-0"
/>
{/* Message input container */}
<div className="flex-1 relative">
<input
type="text"
value={newMessage}
onChange={(e) => {
setNewMessage(e.target.value);
// Handle typing indicators
if (selectedRoom && sendTyping) {
if (e.target.value.length > 0 && !isTyping) {
setIsTyping(true);
sendTyping(selectedRoom.id, true);
} else if (e.target.value.length === 0 && isTyping) {
setIsTyping(false);
sendTyping(selectedRoom.id, false);
}
}
}}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
handleSendMessage(e);
}
}}
onBlur={() => {
if (isTyping && selectedRoom && sendTyping) {
setIsTyping(false);
sendTyping(selectedRoom.id, false);
}
}}
placeholder={
replyingTo
? `Reply to ${replyingTo.user.name}...`
: selectedFile
? 'Add a caption...'
: `Message #${selectedRoom.name}`
}
className={`w-full bg-gray-100 dark:bg-gray-800 border-transparent rounded pl-3 ${isMobile ? 'pr-12' : 'pr-20'} py-2 text-sm transition-all ${
replyingTo
? 'ring-2 ring-blue-500 border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'focus:ring-1 focus:ring-blue-500 focus:border-blue-500'
}`}
disabled={isLoadingMessages || uploading}
/>
{/* Emoji picker button - desktop only */}
{!isMobile && (
<div className="absolute right-10 top-1/2 transform -translate-y-1/2">
<button
type="button"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
title="Add emoji"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<EmojiPicker
isOpen={showEmojiPicker}
onClose={() => setShowEmojiPicker(false)}
onEmojiSelect={handleEmojiSelect}
position="top"
/>
</div>
)}
</div>
{/* Send button */}
<motion.button
type="submit"
disabled={(!newMessage.trim() && !selectedFile) || isLoadingMessages || uploading}
className="flex-shrink-0 p-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded transition-colors disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title="Send message"
>
{uploading ? (
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
</svg>
)}
</motion.button>
</div>
{/* Input help text */}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 text-center">
{isMobile ? 'Tap to send' : 'Press Enter to send'} âĸ {selectedFile ? `File: ${selectedFile.name}` : 'Drag & drop files or use đ'}
</p>
</form>
</footer>
</>
)}
</main>
{/* Right Sidebar: Participants List */}
<AnimatePresence>
{((showParticipants && !isMobile) || (showRightSidebar && isMobile)) && selectedRoom && (
<motion.aside
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ duration: 0.2 }}
className={`
border-l border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 flex flex-col
${isMobile
? 'absolute top-0 right-0 h-full w-80 max-w-[85vw] z-30'
: 'w-64 relative'
}
`}
>
<header className="p-3 border-b border-gray-200 dark:border-gray-800 h-14 flex-shrink-0 flex items-center justify-between">
<h3 className="text-base font-bold">Users ({uniqueParticipants.length})</h3>
<button
onClick={() => {
if (isMobile) {
setShowRightSidebar(false);
} else {
setShowParticipants(false);
}
}}
className="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</header>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{uniqueParticipants.filter(p => p && p.user).map(participant => {
const notification = directMessageNotifications.get(participant.user.id);
const hasUnread = notification && notification.unreadCount > 0;
return (
<div key={participant.user.id} className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800/50">
<div className="relative">
<UserAvatar
user={participant.user}
size="sm"
showStatus={true}
clickable={true}
onClick={() => setProfilePopover({
userId: participant.user.id,
isOpen: profilePopover?.userId !== participant.user.id || !profilePopover?.isOpen
})}
/>
{profilePopover?.userId === participant.user.id && profilePopover?.isOpen && (
<ProfilePopover
userId={participant.user.id}
isOpen={true}
onClose={() => setProfilePopover(null)}
position="left"
onStartDirectMessage={(userId, userName) => {
handleDoubleClickUser(userId, userName);
}}
/>
)}
{participant.role === 'ADMIN' && (
<span className="absolute -top-1 -right-1 block h-3 w-3 rounded-full bg-yellow-500 border border-white dark:border-gray-900" title="Admin"></span>
)}
{hasUnread && (
<span className="absolute -top-1 -left-1 block h-4 w-4 rounded-full bg-red-500 text-white text-xs font-bold flex items-center justify-center border border-white dark:border-gray-900" title={`${notification.unreadCount} unread message${notification.unreadCount > 1 ? 's' : ''}`}>
{notification.unreadCount > 9 ? '9+' : notification.unreadCount}
</span>
)}
</div>
<div className="flex-1">
<p
className={`text-sm font-semibold truncate cursor-pointer ${
hasUnread
? 'text-blue-600 dark:text-blue-400 font-bold'
: 'text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400'
}`}
onDoubleClick={() => {
handleDoubleClickUser(participant.user.id, participant.user.name);
// Close sidebar on mobile after starting DM
if (isMobile) {
setShowRightSidebar(false);
}
}}
title={`${isMobile ? 'Tap' : 'Double-click'} to start private chat with ${participant.user.name}${hasUnread ? ` (${notification.unreadCount} unread)` : ''}`}
>
{participant.user.name}
{hasUnread && (
<span className="ml-1 text-xs text-red-500 dark:text-red-400">
({notification.unreadCount})
</span>
)}
</p>
</div>
<div className="ml-auto">
<ParticipantActions
participant={participant}
roomId={selectedRoom.id}
onKick={handleKickParticipant}
/>
</div>
</div>
);
})}
</div>
</motion.aside>
)}
</AnimatePresence>
{/* â
OLD GROUP VIDEO CALL DISABLED - Using global SimpleVideoCall now */}
<AnimatePresence>
{false && (
<div>Old group video call disabled</div>
)}
</AnimatePresence>
<AnimatePresence>
{directMessage && (
<DirectMessage
chatId={directMessage.recipientId}
onClose={() => setDirectMessage(null)}
/>
)}
</AnimatePresence>
</div>
);
};
export default GroupChat;