-
-
-
- Home
-
-
- Todo
-
-
- Chat
-
-
- Profile
-
+
-
- {/* Mobile menu */}
-
-
-
- Home
-
-
- Todo
-
-
- Chat
-
-
- Profile
-
-
+ )}
)
diff --git a/client/src/components/TokenMonitor.tsx b/client/src/components/TokenMonitor.tsx
deleted file mode 100644
index b1378f6..0000000
--- a/client/src/components/TokenMonitor.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-/**
- * Token Monitor Component
- *
- * This component provides automatic token expiration monitoring and user notifications.
- * It should be included at the root level of the application to ensure global monitoring.
- */
-
-import React, { useEffect } from 'react';
-import { useTokenMonitor } from '@/hooks/useTokenMonitor';
-import { useAuthStore } from '@/stores/authStore';
-import { TokenExpirationEvent } from '@/lib/token-utils';
-import { toast } from 'sonner';
-
-export interface TokenMonitorProps {
- /**
- * Whether to show detailed notifications for debugging
- * @default false
- */
- debug?: boolean;
-
- /**
- * Custom notification messages
- */
- messages?: {
- expired?: string;
- nearExpiry?: string;
- refreshed?: string;
- invalid?: string;
- };
-
- /**
- * Whether to automatically redirect to login on token expiration
- * @default true
- */
- autoRedirect?: boolean;
-}
-
-/**
- * Token Monitor Component
- *
- * Provides automatic token expiration monitoring with user notifications.
- * Should be included once at the root level of the application.
- */
-export function TokenMonitor({
- debug = false,
- messages = {},
- autoRedirect = true
-}: TokenMonitorProps) {
- const { isAuthenticated } = useAuthStore();
-
- const defaultMessages = {
- expired: 'Your session has expired. Please log in again.',
- nearExpiry: 'Your session will expire soon. Please save your work.',
- refreshed: 'Session extended successfully.',
- invalid: 'Your session is invalid. Please log in again.',
- };
-
- const finalMessages = { ...defaultMessages, ...messages };
-
- /**
- * Handle token expiration events with custom notifications
- */
- const handleTokenEvent = (event: TokenExpirationEvent, data?: any) => {
- if (debug) {
- console.log('TokenMonitor: Token event received:', event, data);
- }
-
- switch (event) {
- case TokenExpirationEvent.EXPIRED:
- toast.error(finalMessages.expired, {
- duration: 8000,
- action: autoRedirect ? {
- label: 'Login',
- onClick: () => {
- const currentPath = window.location.pathname + window.location.search;
- window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
- },
- } : undefined,
- });
- break;
-
- case TokenExpirationEvent.NEAR_EXPIRY:
- if (data?.timeLeftText) {
- toast.warning(`${finalMessages.nearExpiry} Time remaining: ${data.timeLeftText}`, {
- duration: 15000,
- action: {
- label: 'Extend Session',
- onClick: async () => {
- try {
- const { httpClient } = await import('@/lib/http-client');
- await httpClient.forceRefresh();
- toast.success('Session extended successfully');
- } catch (error) {
- toast.error('Failed to extend session. Please save your work and log in again.');
- }
- },
- },
- });
- } else {
- toast.warning(finalMessages.nearExpiry, {
- duration: 10000,
- });
- }
- break;
-
- case TokenExpirationEvent.REFRESHED:
- if (debug) {
- toast.success(finalMessages.refreshed, {
- duration: 3000,
- });
- }
- break;
-
- case TokenExpirationEvent.INVALID:
- toast.error(finalMessages.invalid, {
- duration: 8000,
- action: autoRedirect ? {
- label: 'Login',
- onClick: () => {
- window.location.href = '/login';
- },
- } : undefined,
- });
- break;
- }
- };
-
- // Initialize token monitoring with custom event handler
- const { isMonitoring, getTokenInfo } = useTokenMonitor({
- autoStart: true,
- showNotifications: false, // We handle notifications ourselves
- autoLogout: true,
- onTokenEvent: handleTokenEvent,
- checkInterval: 30000, // Check every 30 seconds
- warningThreshold: 5, // Warn 5 minutes before expiry
- autoRefreshThreshold: 2, // Auto-refresh 2 minutes before expiry
- });
-
- // Debug information
- useEffect(() => {
- if (debug && isAuthenticated) {
- const tokenInfo = getTokenInfo();
- if (tokenInfo) {
- console.log('TokenMonitor: Current token info:', {
- isMonitoring,
- expiresAt: tokenInfo.expiresAt,
- timeUntilExpiry: tokenInfo.timeUntilExpiry,
- isNearExpiry: tokenInfo.isNearExpiry,
- isExpired: tokenInfo.isExpired,
- });
- }
- }
- }, [debug, isAuthenticated, isMonitoring, getTokenInfo]);
-
- // Show debug toast when monitoring starts/stops
- useEffect(() => {
- if (debug) {
- if (isMonitoring && isAuthenticated) {
- toast.info('Token monitoring started', { duration: 2000 });
- } else if (!isMonitoring && !isAuthenticated) {
- toast.info('Token monitoring stopped', { duration: 2000 });
- }
- }
- }, [debug, isMonitoring, isAuthenticated]);
-
- // This component doesn't render anything visible
- return null;
-}
-
-/**
- * Token Status Display Component (for debugging)
- *
- * Shows current token status information. Useful for development and debugging.
- */
-export function TokenStatusDisplay() {
- const { isAuthenticated, tokens, getTokenInfo } = useAuthStore();
- const { isMonitoring, isTokenValid } = useTokenMonitor();
-
- if (!isAuthenticated || !tokens?.accessToken) {
- return (
-
-
Not Authenticated
-
No access token available
-
- );
- }
-
- const tokenInfo = getTokenInfo();
-
- if (!tokenInfo) {
- return (
-
-
Invalid Token
-
Unable to parse token information
-
- );
- }
-
- const getStatusColor = () => {
- if (tokenInfo.isExpired) return 'red';
- if (tokenInfo.isNearExpiry) return 'yellow';
- return 'green';
- };
-
- const statusColor = getStatusColor();
- const bgColor = `bg-${statusColor}-100`;
- const borderColor = `border-${statusColor}-300`;
- const textColor = `text-${statusColor}-800`;
- const subTextColor = `text-${statusColor}-600`;
-
- return (
-
-
Token Status
-
-
-
- Status: {
- tokenInfo.isExpired ? 'Expired' :
- tokenInfo.isNearExpiry ? 'Near Expiry' :
- 'Valid'
- }
-
-
-
- Monitoring: {isMonitoring ? 'Active' : 'Inactive'}
-
-
-
- Valid: {isTokenValid() ? 'Yes' : 'No'}
-
-
-
- Expires: {tokenInfo.expiresAt.toLocaleTimeString()}
-
-
- {!tokenInfo.isExpired && (
-
- Time left: {Math.floor(tokenInfo.timeUntilExpiry / 60000)}m
-
- )}
-
-
- );
-}
-
-export default TokenMonitor;
diff --git a/client/src/components/chat/ChatRoom.tsx b/client/src/components/chat/ChatRoom.tsx
deleted file mode 100644
index 79df4b7..0000000
--- a/client/src/components/chat/ChatRoom.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Users, Phone, Video, UserPlus } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-
-import { MessageList } from './MessageList';
-import { MessageInput } from './MessageInput';
-import { InviteUsersDialog } from './InviteUsersDialog';
-import { RoomSettings } from './RoomSettings';
-import { InlineConnectionStatus } from '@/components/ConnectionStatus';
-import { useChatStore } from '@/stores/chatStore';
-import { useRoom, useInfiniteMessages, useRealTimeMessages, useRealTimeTyping, useRealTimeUserStatus, useRoomDeletionRedirect, useRoomUpdateNotification, useUserRemovalRedirect, useRealTimeMemberRemoval, useSocketStatus } from '@/hooks/useChat';
-import { useAuthStore } from '@/stores/authStore';
-
-interface ChatRoomProps {
- roomId: string;
-}
-
-export function ChatRoom({ roomId }: ChatRoomProps) {
- const [showInviteDialog, setShowInviteDialog] = useState(false);
-
- const {
- setCurrentRoom,
- joinRoom,
- messagesByRoom,
- } = useChatStore();
-
- const { connected } = useSocketStatus();
- const { user } = useAuthStore();
-
- // Fetch room data
- const { data: room, isLoading: roomLoading, error: roomError } = useRoom(roomId);
-
- // Fetch messages with infinite scroll
- const {
- data: messagesData,
- isLoading: messagesLoading,
- hasNextPage,
- fetchNextPage,
- isFetchingNextPage,
- } = useInfiniteMessages(roomId);
-
- // Setup real-time message updates
- useRealTimeMessages(roomId);
-
- // Enable real-time typing indicators
- useRealTimeTyping(roomId);
-
- // Enable real-time user status updates
- useRealTimeUserStatus();
-
- // Handle room deletion redirect
- useRoomDeletionRedirect(roomId);
-
- // Handle room update notifications
- useRoomUpdateNotification(roomId);
-
- // Handle user removal from room (redirect)
- useUserRemovalRedirect();
-
- // Handle member removal notifications
- useRealTimeMemberRemoval();
-
- // Get messages from store (real-time updates)
- const storeMessages = messagesByRoom[roomId] || [];
-
- // Combine server messages with store messages, prioritizing store messages (real-time)
- const allMessages = messagesData?.pages.flatMap(page => page.messages) || [];
-
- // Create a Map to deduplicate by ID, prioritizing store messages
- const messageMap = new Map();
-
- // Add server messages first
- allMessages.forEach(msg => {
- messageMap.set(msg.id, msg);
- });
-
- // Add store messages (will overwrite server messages with same ID)
- storeMessages.forEach(msg => {
- messageMap.set(msg.id, msg);
- });
-
- const combinedMessages = Array.from(messageMap.values())
- .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); // ASC: oldest first, newest last
-
- // Set current room and join via socket
- useEffect(() => {
- setCurrentRoom(roomId);
-
- if (connected) {
- joinRoom(roomId);
- }
-
- return () => {
- setCurrentRoom(null);
- };
- }, [roomId, connected, setCurrentRoom, joinRoom]);
-
- if (roomError) {
- return (
-
-
-
Failed to load room
-
-
-
- );
- }
-
- if (roomLoading) {
- return (
-
- );
- }
-
- if (!room) {
- return (
-
- );
- }
-
- return (
-
- {/* Room header - Sticky */}
-
-
-
-
-
- {room.name.charAt(0).toUpperCase()}
-
-
-
-
-
{room.name}
-
-
- {room.participants.length} members
- •
-
-
-
-
-
-
- {/* Show invite button only for room author */}
- {user && room.authorId === user.id && (
-
- )}
-
-
-
-
-
-
-
- {/* Connection status - Also sticky */}
- {!connected && (
-
-
-
-
Reconnecting to chat...
-
-
- )}
-
- {/* Messages */}
-
fetchNextPage()}
- />
-
- {/* Message input */}
-
-
- {/* Invite Users Dialog */}
-
-
- );
-}
diff --git a/client/src/components/chat/ChatRoomList.tsx b/client/src/components/chat/ChatRoomList.tsx
deleted file mode 100644
index 06ae135..0000000
--- a/client/src/components/chat/ChatRoomList.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-import React, { useState } from 'react';
-import { formatDistanceToNow } from 'date-fns';
-import { Plus, Search, Users, Hash, RefreshCw } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { Badge } from '@/components/ui/badge';
-import { useChatStore, ChatRoom } from '@/stores/chatStore';
-import { useRooms, useCreateRoom, useJoinRoom, useRealTimeRoomDeletion, useRealTimeRoomUpdates } from '@/hooks/useChat';
-import { useAuthStore } from '@/stores/authStore';
-import { CreateRoomDialog } from './CreateRoomDialog';
-
-interface ChatRoomListProps {
- onRoomSelect: (roomId: string) => void;
- selectedRoomId?: string;
-}
-
-interface RoomItemProps {
- room: ChatRoom;
- isSelected: boolean;
- onClick: () => void;
-}
-
-function RoomItem({ room, isSelected, onClick }: RoomItemProps) {
- const joinRoomMutation = useJoinRoom();
- const { user } = useAuthStore();
-
- const handleJoinRoom = (e: React.MouseEvent) => {
- e.stopPropagation();
- joinRoomMutation.mutate(room.id);
- };
-
- return (
-
-
-
-
-
-
-
-
-
- {/* Online indicator (placeholder) */}
-
-
-
-
-
-
- {room.name}
-
-
-
- {room.unreadCount && room.unreadCount > 0 && (
-
- {room.unreadCount > 99 ? '99+' : room.unreadCount}
-
- )}
-
- {room.lastActivity && (
-
- {formatDistanceToNow(new Date(room.lastActivity), { addSuffix: true })}
-
- )}
-
-
-
-
-
-
- {room.participants.length} members
-
-
- {/* Join button for rooms user is not part of */}
- {user && !room.participants.includes(user.id) && (
-
- )}
-
-
-
- );
-}
-
-export function ChatRoomList({ onRoomSelect, selectedRoomId }: ChatRoomListProps) {
- const [searchQuery, setSearchQuery] = useState('');
- const [showCreateDialog, setShowCreateDialog] = useState(false);
-
- // Use API sorting instead of client-side sorting
- // Rooms are already sorted by updated_at DESC from the backend
- const { data: roomsData, isLoading, error, refetch } = useRooms(1, 10, 'updated_at', 'desc');
-
- // Enable real-time room deletion updates
- useRealTimeRoomDeletion();
-
- // Enable real-time room updates
- useRealTimeRoomUpdates();
-
- // Use rooms from API data instead of store
- const rooms = roomsData?.rooms || [];
-
- console.log('ChatRoomList Debug:', {
- isLoading,
- error,
- roomsData,
- rooms,
- roomsCount: rooms.length
- });
-
- // Filter rooms based on search query (only filtering, no sorting needed)
- const filteredRooms = rooms.filter(room =>
- room.name.toLowerCase().includes(searchQuery.toLowerCase())
- );
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
-
-
Failed to load rooms
-
-
-
- );
- }
-
- return (
-
- {/* Header */}
-
-
-
Chat Rooms ({rooms.length})
-
-
-
-
-
-
- {/* Search */}
-
-
- setSearchQuery(e.target.value)}
- className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
- />
-
-
-
- {/* Room list */}
-
- {filteredRooms.length === 0 ? (
-
- {searchQuery ? 'No rooms found' : 'No rooms available'}
- {!searchQuery && (
-
-
-
- )}
-
- ) : (
-
- {filteredRooms.map((room) => (
- onRoomSelect(room.id)}
- />
- ))}
-
- )}
-
-
- {/* Create room dialog */}
-
-
- );
-}
diff --git a/client/src/components/chat/CreateRoomDialog.tsx b/client/src/components/chat/CreateRoomDialog.tsx
deleted file mode 100644
index a30d417..0000000
--- a/client/src/components/chat/CreateRoomDialog.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import React, { useState } from 'react';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { z } from 'zod';
-import { Button } from '@/components/ui/button';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from '@/components/ui/form';
-import { Input } from '@/components/ui/input';
-import { useCreateRoom } from '@/hooks/useChat';
-
-const createRoomSchema = z.object({
- name: z
- .string()
- .min(1, 'Room name is required')
- .max(100, 'Room name must be less than 100 characters')
- .regex(/^[a-zA-Z0-9\s\-_]+$/, 'Room name can only contain letters, numbers, spaces, hyphens, and underscores'),
- avatarUrl: z
- .string()
- .url('Must be a valid URL')
- .optional()
- .or(z.literal('')),
-});
-
-type CreateRoomFormData = z.infer
;
-
-interface CreateRoomDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export function CreateRoomDialog({ open, onOpenChange }: CreateRoomDialogProps) {
- const createRoomMutation = useCreateRoom();
-
- const form = useForm({
- resolver: zodResolver(createRoomSchema),
- defaultValues: {
- name: '',
- avatarUrl: '',
- },
- });
-
- const onSubmit = async (data: CreateRoomFormData) => {
- try {
- await createRoomMutation.mutateAsync({
- name: data.name,
- avatarUrl: data.avatarUrl || undefined,
- });
-
- // Close dialog and reset form
- onOpenChange(false);
- form.reset();
- } catch (error) {
- // Error is handled by the mutation
- console.error('Failed to create room:', error);
- }
- };
-
- const handleClose = () => {
- if (!createRoomMutation.isPending) {
- onOpenChange(false);
- form.reset();
- }
- };
-
- return (
-
- );
-}
diff --git a/client/src/components/chat/DeleteRoomDialog.tsx b/client/src/components/chat/DeleteRoomDialog.tsx
deleted file mode 100644
index 974417c..0000000
--- a/client/src/components/chat/DeleteRoomDialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { useState } from 'react';
-import { Trash2, AlertTriangle } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from '@/components/ui/alert-dialog';
-import { useDeleteRoom } from '@/hooks/useChat';
-import { useNavigate } from '@tanstack/react-router';
-
-interface DeleteRoomDialogProps {
- roomId: string;
- roomName: string;
- trigger?: React.ReactNode;
-}
-
-export function DeleteRoomDialog({ roomId, roomName, trigger }: DeleteRoomDialogProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [confirmationText, setConfirmationText] = useState('');
- const navigate = useNavigate();
-
- const deleteRoomMutation = useDeleteRoom();
-
- const handleDelete = async () => {
- try {
- await deleteRoomMutation.mutateAsync(roomId);
- setIsOpen(false);
- // Navigate back to chat list
- navigate({ to: '/chat' });
- } catch (error) {
- console.error('Failed to delete room:', error);
- }
- };
-
- const isConfirmationValid = confirmationText === roomName;
-
- return (
-
-
- {trigger || (
-
- )}
-
-
-
-
-
- Delete Room
-
-
-
- This action cannot be undone. This will permanently delete the room
- "{roomName}" and remove all messages.
-
-
- All members will lose access to this room and its message history.
-
-
-
-
-
-
-
-
-
-
- Warning: This action is irreversible
-
-
- - • All messages will be permanently deleted
- - • All members will be removed from the room
- - • Room history cannot be recovered
-
-
-
-
-
-
-
- setConfirmationText(e.target.value)}
- placeholder={`Type "${roomName}" here`}
- className="font-mono"
- />
-
-
-
-
- {
- setConfirmationText('');
- setIsOpen(false);
- }}
- disabled={deleteRoomMutation.isPending}
- >
- Cancel
-
-
- {deleteRoomMutation.isPending ? 'Deleting...' : 'Delete Room'}
-
-
-
-
- );
-}
diff --git a/client/src/components/chat/EditRoomDialog.tsx b/client/src/components/chat/EditRoomDialog.tsx
deleted file mode 100644
index 4e51026..0000000
--- a/client/src/components/chat/EditRoomDialog.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { useState } from 'react';
-import { Edit3, Save, X } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '@/components/ui/dialog';
-import { useUpdateRoom } from '@/hooks/useChat';
-import { RoomData } from '@/services/socket.service';
-
-interface EditRoomDialogProps {
- room: RoomData;
- trigger?: React.ReactNode;
-}
-
-export function EditRoomDialog({ room, trigger }: EditRoomDialogProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [formData, setFormData] = useState({
- name: room.name,
- avatarUrl: room.avatarUrl || '',
- });
-
- const updateRoomMutation = useUpdateRoom(room.id);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
-
- try {
- await updateRoomMutation.mutateAsync({
- name: formData.name.trim(),
- avatarUrl: formData.avatarUrl.trim() || undefined,
- });
- setIsOpen(false);
- } catch (error) {
- console.error('Failed to update room:', error);
- }
- };
-
- const handleInputChange = (field: string, value: string) => {
- setFormData(prev => ({
- ...prev,
- [field]: value
- }));
- };
-
- const isFormValid = formData.name.trim().length > 0;
- const hasChanges = formData.name !== room.name || formData.avatarUrl !== (room.avatarUrl || '');
-
- return (
-
- );
-}
diff --git a/client/src/components/chat/EmojiPicker.tsx b/client/src/components/chat/EmojiPicker.tsx
deleted file mode 100644
index b24ff8c..0000000
--- a/client/src/components/chat/EmojiPicker.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { useState, useRef, useEffect } from 'react';
-import { Smile } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import data from '@emoji-mart/data';
-import Picker from '@emoji-mart/react';
-
-interface EmojiPickerProps {
- onEmojiSelect: (emoji: string) => void;
- disabled?: boolean;
-}
-
-export function EmojiPicker({ onEmojiSelect, disabled = false }: EmojiPickerProps) {
- const [showPicker, setShowPicker] = useState(false);
- const pickerRef = useRef(null);
- const buttonRef = useRef(null);
-
- // Close picker when clicking outside
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- pickerRef.current &&
- buttonRef.current &&
- !pickerRef.current.contains(event.target as Node) &&
- !buttonRef.current.contains(event.target as Node)
- ) {
- setShowPicker(false);
- }
- };
-
- if (showPicker) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }
- }, [showPicker]);
-
- const handleEmojiSelect = (emoji: any) => {
- onEmojiSelect(emoji.native);
- setShowPicker(false);
- };
-
- return (
-
-
-
- {showPicker && (
-
- )}
-
- );
-}
diff --git a/client/src/components/chat/InviteUsersDialog.tsx b/client/src/components/chat/InviteUsersDialog.tsx
deleted file mode 100644
index f05772f..0000000
--- a/client/src/components/chat/InviteUsersDialog.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-import { useState } from 'react';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-// import { ScrollArea } from '@/components/ui/scroll-area';
-import { useSearchUsers } from '@/hooks/useUsers';
-import { useInviteUsers } from '@/hooks/useChat';
-import { Search, UserPlus, Loader2 } from 'lucide-react';
-import { useDebounce } from '@/hooks/useDebounce';
-
-interface InviteUsersDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- roomId: string;
- roomName: string;
-}
-
-export function InviteUsersDialog({
- open,
- onOpenChange,
- roomId,
- roomName,
-}: InviteUsersDialogProps) {
- const [searchQuery, setSearchQuery] = useState('');
- const [selectedUserIds, setSelectedUserIds] = useState([]);
-
- // Debounce search query to avoid too many API calls
- const debouncedSearchQuery = useDebounce(searchQuery, 300);
-
- const { data: usersData, isLoading: isSearching } = useSearchUsers(
- debouncedSearchQuery,
- 1,
- 20,
- roomId
- );
-
- const inviteUsersMutation = useInviteUsers();
-
- const users = usersData?.users || [];
-
- // Backend already excludes room participants, so we just use the users directly
-
- const handleUserToggle = (userId: string, checked: boolean) => {
- if (checked) {
- setSelectedUserIds(prev => [...prev, userId]);
- } else {
- setSelectedUserIds(prev => prev.filter(id => id !== userId));
- }
- };
-
- const handleInvite = async () => {
- if (selectedUserIds.length === 0) return;
-
- try {
- await inviteUsersMutation.mutateAsync({
- roomId,
- userIds: selectedUserIds,
- });
-
- // Reset state and close dialog
- setSelectedUserIds([]);
- setSearchQuery('');
- onOpenChange(false);
- } catch (error) {
- console.error('Failed to invite users:', error);
- }
- };
-
- const handleClose = () => {
- setSelectedUserIds([]);
- setSearchQuery('');
- onOpenChange(false);
- };
-
- return (
-
- );
-}
diff --git a/client/src/components/chat/MessageActions.tsx b/client/src/components/chat/MessageActions.tsx
deleted file mode 100644
index 83b6aa7..0000000
--- a/client/src/components/chat/MessageActions.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { useState } from 'react';
-import { MoreHorizontal, Edit3, Trash2, Copy, Reply } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-import { Message } from '@/services/chat.service';
-
-interface MessageActionsProps {
- message: Message;
- isOwner: boolean;
- onEdit?: (messageId: string) => void;
- onDelete?: (messageId: string) => void;
- onReply?: (message: Message) => void;
-}
-
-export function MessageActions({
- message,
- isOwner,
- onEdit,
- onDelete,
- onReply
-}: MessageActionsProps) {
- const [showDeleteDialog, setShowDeleteDialog] = useState(false);
-
- const handleCopyMessage = async () => {
- try {
- await navigator.clipboard.writeText(message.content);
- // You could add a toast notification here
- } catch (error) {
- console.error('Failed to copy message:', error);
- }
- };
-
- const handleDeleteConfirm = () => {
- onDelete?.(message.id);
- setShowDeleteDialog(false);
- };
-
- return (
- <>
-
-
-
-
-
- {/* Reply option */}
- {onReply && (
- <>
- onReply(message)}>
-
- Reply
-
-
- >
- )}
-
- {/* Copy message */}
-
-
- Copy message
-
-
- {/* Owner-only actions */}
- {isOwner && (
- <>
-
-
- {/* Edit message */}
- {onEdit && (
- onEdit(message.id)}>
-
- Edit message
-
- )}
-
- {/* Delete message */}
- {onDelete && (
- setShowDeleteDialog(true)}
- className="text-red-600 focus:text-red-600"
- >
-
- Delete message
-
- )}
- >
- )}
-
-
-
- {/* Delete confirmation dialog */}
-
-
-
- Delete Message
-
- Are you sure you want to delete this message? This action cannot be undone.
-
-
-
- Cancel
-
- Delete
-
-
-
-
- >
- );
-}
diff --git a/client/src/components/chat/MessageInput.tsx b/client/src/components/chat/MessageInput.tsx
deleted file mode 100644
index 032fe85..0000000
--- a/client/src/components/chat/MessageInput.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-import React, { useState, useRef, useEffect, useCallback } from "react";
-import { Send } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { useChatStore } from "@/stores/chatStore";
-import { EmojiPicker } from "./EmojiPicker";
-
-interface MessageInputProps {
- roomId: string;
- disabled?: boolean;
- placeholder?: string;
-}
-
-export function MessageInput({
- roomId,
- disabled = false,
- placeholder = "Type a message...",
-}: MessageInputProps) {
- const [message, setMessage] = useState("");
- const [isTyping, setIsTyping] = useState(false);
- const textareaRef = useRef(null);
- const typingTimeoutRef = useRef(null);
- const lastTypingTimeRef = useRef(0);
-
- const { sendMessage, setTyping } = useChatStore();
-
- const handleEmojiSelect = (emoji: string) => {
- const textarea = textareaRef.current;
- if (!textarea) return;
-
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const newMessage = message.slice(0, start) + emoji + message.slice(end);
-
- setMessage(newMessage);
-
- // Trigger input change logic for typing indicator
- handleInputChange({
- target: { value: newMessage },
- } as React.ChangeEvent);
-
- // Focus back to textarea and set cursor position
- setTimeout(() => {
- textarea.focus();
- const newCursorPos = start + emoji.length;
- textarea.setSelectionRange(newCursorPos, newCursorPos);
- }, 0);
- };
-
- const handleSubmit = useCallback(
- (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!message.trim() || disabled) return;
-
- // Send message
- sendMessage(roomId, message.trim());
-
- // Clear input
- setMessage("");
-
- // Stop typing indicator
- if (isTyping) {
- setTyping(roomId, false);
- setIsTyping(false);
- }
-
- // Reset textarea height
- if (textareaRef.current) {
- textareaRef.current.style.height = "auto";
- }
- },
- [message, disabled, roomId, sendMessage, setTyping]
- );
-
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- e.preventDefault();
- handleSubmit(e);
- }
- },
- [handleSubmit]
- );
-
- const handleInputChange = useCallback(
- (e: React.ChangeEvent) => {
- const value = e.target.value;
- setMessage(value);
-
- // Auto-resize textarea
- if (textareaRef.current) {
- textareaRef.current.style.height = "auto";
- textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
- }
-
- // Handle typing indicator with debounce
- const now = Date.now();
-
- if (value.trim()) {
- // Start typing indicator if not already typing
- if (!isTyping) {
- setIsTyping(true);
- setTyping(roomId, true);
- lastTypingTimeRef.current = now;
- } else {
- // Debounce: only send typing update if it's been more than 1 second since last update
- if (now - lastTypingTimeRef.current > 1000) {
- setTyping(roomId, true);
- lastTypingTimeRef.current = now;
- }
- }
-
- // Clear existing timeout
- if (typingTimeoutRef.current) {
- clearTimeout(typingTimeoutRef.current);
- }
-
- // Set new timeout to stop typing after user stops typing
- typingTimeoutRef.current = window.setTimeout(() => {
- setIsTyping(false);
- setTyping(roomId, false);
- }, 3000); // ✅ Stop typing after 3 seconds of inactivity
- } else if (isTyping) {
- // Stop typing immediately if input is empty
- setIsTyping(false);
- setTyping(roomId, false);
- if (typingTimeoutRef.current) {
- clearTimeout(typingTimeoutRef.current);
- typingTimeoutRef.current = null;
- }
- }
- },
- [isTyping, roomId, setTyping]
- );
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- if (typingTimeoutRef.current) {
- clearTimeout(typingTimeoutRef.current);
- }
- if (isTyping) {
- setTyping(roomId, false);
- }
- };
- }, [roomId, isTyping, setTyping]);
-
-
- return (
-
-
-
-
-
- {/* Emoji picker */}
-
-
-
-
-
-
-
-
- {/* Character count (optional) */}
- {message.length > 1800 && (
-
- {message.length}/2000
-
- )}
-
- );
-}
diff --git a/client/src/components/chat/MessageList.tsx b/client/src/components/chat/MessageList.tsx
deleted file mode 100644
index 7539c56..0000000
--- a/client/src/components/chat/MessageList.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { formatDistanceToNow } from 'date-fns';
-import { ChevronUp } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { useChatStore, ChatMessage } from '@/stores/chatStore';
-import { useAuthStore } from '@/stores/authStore';
-import { useUpdateMessage, useDeleteMessage } from '@/hooks/useChat';
-import { MessageActions } from './MessageActions';
-
-interface MessageListProps {
- roomId: string;
- messages: ChatMessage[];
- isLoading?: boolean;
- hasNextPage?: boolean;
- onLoadMore?: () => void;
-}
-
-interface MessageItemProps {
- message: ChatMessage;
- isOwn: boolean;
- showAvatar: boolean;
- onEdit: (messageId: string, content: string) => void;
- onDelete: (messageId: string) => void;
-}
-
-function MessageItem({ message, isOwn, showAvatar, onEdit, onDelete }: MessageItemProps) {
- const [isEditing, setIsEditing] = useState(false);
- const [editContent, setEditContent] = useState(message.content);
-
- const handleEdit = () => {
- if (editContent.trim() && editContent !== message.content) {
- onEdit(message.id, editContent.trim());
- }
- setIsEditing(false);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleEdit();
- } else if (e.key === 'Escape') {
- setIsEditing(false);
- setEditContent(message.content);
- }
- };
-
- const handleEditMessage = (messageId: string) => {
- setIsEditing(true);
- };
-
- const handleDeleteMessage = (messageId: string) => {
- onDelete(messageId);
- };
-
- return (
-
- {showAvatar && (
-
-
-
- {message.author.username.charAt(0).toUpperCase()}
-
-
- )}
-
-
- {showAvatar && (
-
-
- {message.author.username}
-
-
- {formatDistanceToNow(new Date(message.createdAt), { addSuffix: true })}
-
-
- )}
-
-
- {isEditing ? (
-
- ) : (
-
- {message.content}
- {message.error && (
-
- Failed to send: {message.error}
-
- )}
-
- )}
-
- {/* Message actions */}
- {!isEditing && (
-
-
-
- )}
-
-
-
- );
-}
-
-export function MessageList({ roomId, messages, isLoading, hasNextPage, onLoadMore }: MessageListProps) {
- const messagesEndRef = useRef(null);
- const messagesContainerRef = useRef(null);
- const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
- const [showScrollToTop, setShowScrollToTop] = useState(false);
-
- const { user } = useAuthStore();
- const { typingUsersByRoom } = useChatStore();
-
- const updateMessageMutation = useUpdateMessage(roomId);
- const deleteMessageMutation = useDeleteMessage(roomId);
-
- const typingUsers = typingUsersByRoom[roomId] || [];
-
- // Handle scroll to detect if user is at bottom and show scroll to top button
- const handleScroll = useCallback(() => {
- if (!messagesContainerRef.current) return;
-
- const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
- const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
- setShouldAutoScroll(isAtBottom);
-
- // Show scroll to top button when scrolled down more than 300px
- const shouldShow = window.scrollY > 300;
- setShowScrollToTop(shouldShow);
-
- // Load more messages when scrolled to top
- if (scrollTop === 0 && hasNextPage && onLoadMore) {
- onLoadMore();
- }
- }, [hasNextPage, onLoadMore]);
-
- const handleEditMessage = (messageId: string, content: string) => {
- updateMessageMutation.mutate({ messageId, data: { content } });
- };
-
- const handleDeleteMessage = (messageId: string) => {
- deleteMessageMutation.mutate(messageId);
- };
-
- const scrollToTop = () => {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
-
- useEffect(() => {
- window.addEventListener('scroll', handleScroll);
- return () => {
- window.removeEventListener('scroll', handleScroll);
- };
- }, [])
-
- // Auto-scroll to bottom when new messages arrive
- useEffect(() => {
- if (shouldAutoScroll && messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
- }
- }, [messages, shouldAutoScroll]);
-
- if (isLoading && messages.length === 0) {
- return (
-
- );
- }
-
- return (
-
-
- {hasNextPage && (
-
-
-
- )}
-
-
- {messages.map((message, index) => {
- const prevMessage = messages[index - 1];
- const isOwn = message.author.id === user?.id;
- const showAvatar = !prevMessage ||
- prevMessage.author.id !== message.author.id ||
- new Date(message.createdAt).getTime() - new Date(prevMessage.createdAt).getTime() > 5 * 60 * 1000; // 5 minutes
-
- return (
-
- );
- })}
-
-
- {/* Typing indicators */}
- {typingUsers.length > 0 && (
-
- {typingUsers.length === 1 ? (
- {typingUsers[0].username} is typing...
- ) : (
- {typingUsers.map(u => u.username).join(', ')} are typing...
- )}
-
- )}
-
-
-
-
- {showScrollToTop && (
-
-
-
- )}
-
- );
-}
diff --git a/client/src/components/chat/RoomSettings.tsx b/client/src/components/chat/RoomSettings.tsx
deleted file mode 100644
index 2780cec..0000000
--- a/client/src/components/chat/RoomSettings.tsx
+++ /dev/null
@@ -1,348 +0,0 @@
-import { useState } from 'react';
-import {
- Settings,
- Users,
- LogOut,
- Crown,
- UserMinus,
- Edit3,
- Trash2,
- X,
- AlertTriangle,
- Copy,
- Check
-} from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { Badge } from '@/components/ui/badge';
-import { Separator } from '@/components/ui/separator';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '@/components/ui/dialog';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-import { useRoomMembers, useLeaveRoom, useRemoveMember } from '@/hooks/useChat';
-import { useAuthStore } from '@/stores/authStore';
-import { RoomMember } from '@/services/chat.service';
-import { useNavigate } from '@tanstack/react-router';
-import { EditRoomDialog } from './EditRoomDialog';
-import { DeleteRoomDialog } from './DeleteRoomDialog';
-
-interface RoomSettingsProps {
- roomId: string;
- roomName: string;
- isAuthor: boolean;
- room?: any; // Room data for edit dialog
-}
-
-export function RoomSettings({ roomId, roomName, isAuthor, room }: RoomSettingsProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [showLeaveDialog, setShowLeaveDialog] = useState(false);
- const [copiedName, setCopiedName] = useState(false);
- const [copiedId, setCopiedId] = useState(false);
- const { user } = useAuthStore();
- const navigate = useNavigate();
-
- const { data: membersData, isLoading, error } = useRoomMembers(roomId);
- const leaveRoomMutation = useLeaveRoom();
- const removeMemberMutation = useRemoveMember();
-
- const handleLeaveRoom = async () => {
- try {
- await leaveRoomMutation.mutateAsync(roomId);
- setShowLeaveDialog(false);
- setIsOpen(false);
- // Navigate back to chat list
- navigate({ to: '/chat' });
- } catch (error) {
- console.error('Failed to leave room:', error);
- }
- };
-
- const formatJoinDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric'
- });
- };
-
- const getOnlineStatus = (member: RoomMember) => {
- return member.isOnline ? 'Online' : 'Offline';
- };
-
- const handleRemoveMember = async (memberId: string, memberName: string) => {
- if (confirm(`Are you sure you want to remove ${memberName} from this room?`)) {
- try {
- await removeMemberMutation.mutateAsync({ roomId, memberId });
- } catch (error) {
- console.error('Failed to remove member:', error);
- }
- }
- };
-
- const handleCopyName = async () => {
- try {
- await navigator.clipboard.writeText(roomName);
- setCopiedName(true);
- setTimeout(() => setCopiedName(false), 2000);
- } catch (error) {
- console.error('Failed to copy room name:', error);
- }
- };
-
- const handleCopyId = async () => {
- try {
- await navigator.clipboard.writeText(roomId);
- setCopiedId(true);
- setTimeout(() => setCopiedId(false), 2000);
- } catch (error) {
- console.error('Failed to copy room ID:', error);
- }
- };
-
- return (
- <>
-
-
- {/* Leave Room Confirmation Dialog */}
-
-
-
-
-
- Leave Room
-
-
- Are you sure you want to leave "{roomName}"? You won't be able to see new messages
- unless someone invites you back.
-
-
-
- Cancel
-
- {leaveRoomMutation.isPending ? 'Leaving...' : 'Leave Room'}
-
-
-
-
- >
- );
-}
diff --git a/client/src/components/chat/__tests__/MessageInput.test.tsx b/client/src/components/chat/__tests__/MessageInput.test.tsx
deleted file mode 100644
index 0192746..0000000
--- a/client/src/components/chat/__tests__/MessageInput.test.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, fireEvent } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MessageInput } from '../MessageInput'
-import { useChatStore } from '@/stores/chatStore'
-
-// Mock the chat store
-vi.mock('@/stores/chatStore', () => ({
- useChatStore: vi.fn(),
-}))
-
-// Mock the EmojiPicker component
-vi.mock('../EmojiPicker', () => ({
- EmojiPicker: ({ onEmojiSelect, disabled }: { onEmojiSelect: (emoji: string) => void; disabled?: boolean }) => (
-
-
-
- ),
-}))
-
-describe('MessageInput', () => {
- const mockSendMessage = vi.fn()
- const mockSetTyping = vi.fn()
-
- const mockProps = {
- roomId: 'room-123',
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- vi.mocked(useChatStore).mockReturnValue({
- sendMessage: mockSendMessage,
- setTyping: mockSetTyping,
- } as any)
- })
-
- it('should render message input field', () => {
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
- expect(input).toBeInTheDocument()
- expect(input.tagName).toBe('TEXTAREA')
- })
-
- it('should render send button', () => {
- render()
-
- const sendButton = screen.getByRole('button', { name: /send/i })
- expect(sendButton).toBeInTheDocument()
- })
-
- it('should disable send button when input is empty', () => {
- render()
-
- const sendButton = screen.getByRole('button', { name: /send/i })
- expect(sendButton).toBeDisabled()
- })
-
- it('should enable send button when input has text', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
- const sendButton = screen.getByRole('button', { name: /send/i })
-
- await user.type(input, 'Hello world')
-
- expect(sendButton).not.toBeDisabled()
- })
-
- it('should send message when send button is clicked', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
- const sendButton = screen.getByRole('button', { name: /send/i })
-
- await user.type(input, 'Hello world')
- await user.click(sendButton)
-
- expect(mockSendMessage).toHaveBeenCalledWith('room-123', 'Hello world')
- expect(input).toHaveValue('')
- })
-
- it('should send message when Enter key is pressed', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
-
- await user.type(input, 'Hello world')
- await user.keyboard('{Enter}')
-
- expect(mockSendMessage).toHaveBeenCalledWith('room-123', 'Hello world')
- expect(input).toHaveValue('')
- })
-
- it('should not send message when Shift+Enter is pressed', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
-
- await user.type(input, 'Hello world')
- await user.keyboard('{Shift>}{Enter}{/Shift}')
-
- expect(mockSendMessage).not.toHaveBeenCalled()
- expect(input).toHaveValue('Hello world\n')
- })
-
- it('should handle typing indicator', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
-
- await user.type(input, 'H')
-
- expect(mockSetTyping).toHaveBeenCalledWith('room-123', true)
- })
-
- it('should handle disabled state', () => {
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i)
- const sendButton = screen.getByRole('button', { name: /send/i })
-
- expect(input).toBeDisabled()
- expect(sendButton).toBeDisabled()
- })
-
- it('should handle custom placeholder', () => {
- const customPlaceholder = 'Enter your message here...'
- render()
-
- const input = screen.getByPlaceholderText(customPlaceholder)
- expect(input).toBeInTheDocument()
- })
-
- it('should show character count when approaching limit', async () => {
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i) as HTMLTextAreaElement
- const longMessage = 'x'.repeat(1850) // Over 1800 chars to trigger count display
-
- // Use fireEvent.change for better performance with long strings
- fireEvent.change(input, { target: { value: longMessage } })
-
- expect(screen.getByText(/1850\/2000/)).toBeInTheDocument()
- })
-
- it('should handle emoji selection', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByPlaceholderText(/type a message/i) as HTMLTextAreaElement
- const emojiButton = screen.getByTitle(/add emoji/i)
-
- await user.type(input, 'Hello ')
-
- // Simulate emoji selection by directly calling the handleEmojiSelect function
- // Since the emoji picker library might not work in test environment
- fireEvent.change(input, { target: { value: 'Hello 😀' } })
-
- expect(input).toHaveValue('Hello 😀')
- })
-
-})
diff --git a/client/src/hooks/useChat.ts b/client/src/hooks/useChat.ts
deleted file mode 100644
index d21df15..0000000
--- a/client/src/hooks/useChat.ts
+++ /dev/null
@@ -1,537 +0,0 @@
-import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
-import { useNavigate } from '@tanstack/react-router';
-import { chatService, CreateRoomRequest, UpdateRoomRequest, UpdateMessageRequest } from '@/services/chat.service';
-import { socketService, MessageData, TypingData } from '@/services/socket.service';
-import { useChatStore } from '@/stores/chatStore';
-import { useEffect, useCallback, useState } from 'react';
-
-// Query keys
-export const chatKeys = {
- all: ['chat'] as const,
- rooms: () => [...chatKeys.all, 'rooms'] as const,
- room: (id: string) => [...chatKeys.all, 'room', id] as const,
- roomMembers: (roomId: string) => [...chatKeys.all, 'room', roomId, 'members'] as const,
- messages: (roomId: string) => [...chatKeys.all, 'messages', roomId] as const,
-};
-
-// Rooms hooks
-export function useRooms(
- page = 1,
- limit = 10,
- sortBy: 'name' | 'updated_at' | 'created_at' = 'updated_at',
- sortOrder: 'asc' | 'desc' = 'desc'
-) {
- return useQuery({
- queryKey: [...chatKeys.rooms(), page, limit, sortBy, sortOrder],
- queryFn: () => chatService.getRooms(page, limit, sortBy, sortOrder),
- staleTime: 5 * 60 * 1000, // 5 minutes
- });
-}
-
-export function useRoom(roomId: string) {
- return useQuery({
- queryKey: chatKeys.room(roomId),
- queryFn: () => chatService.getRoom(roomId),
- enabled: !!roomId,
- staleTime: 5 * 60 * 1000,
- });
-}
-
-export function useCreateRoom() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (data: CreateRoomRequest) => chatService.createRoom(data),
- onSuccess: () => {
- // Invalidate rooms list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
- },
- });
-}
-
-export function useUpdateRoom(roomId: string) {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (data: UpdateRoomRequest) => chatService.updateRoom(roomId, data),
- onSuccess: (updatedRoom) => {
- // Update room cache
- queryClient.setQueryData(chatKeys.room(roomId), updatedRoom);
- // Invalidate rooms list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
- },
- });
-}
-
-export function useDeleteRoom() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (roomId: string) => chatService.deleteRoom(roomId),
- onSuccess: (_, roomId) => {
- // Remove room from cache
- queryClient.removeQueries({ queryKey: chatKeys.room(roomId) });
- // Invalidate rooms list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
- },
- });
-}
-
-export function useJoinRoom() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (roomId: string) => chatService.joinRoom(roomId),
- onSuccess: (updatedRoom, roomId) => {
- // Update room cache
- queryClient.setQueryData(chatKeys.room(roomId), updatedRoom);
- // Invalidate rooms list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
-
- // Join room via socket
- socketService.joinRoom(roomId);
- },
- });
-}
-
-export function useLeaveRoom() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (roomId: string) => chatService.leaveRoom(roomId),
- onSuccess: (_, roomId) => {
- // Leave room via socket
- socketService.leaveRoom(roomId);
- // Invalidate rooms list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
- },
- });
-}
-
-export function useInviteUsers() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: ({ roomId, userIds }: { roomId: string; userIds: string[] }) =>
- chatService.inviteUsers(roomId, userIds),
- onSuccess: (data, variables) => {
- // Invalidate rooms list and specific room to refresh participant count
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
- queryClient.invalidateQueries({ queryKey: chatKeys.room(variables.roomId) });
-
- // Show success message
- console.log(`Successfully invited ${data.invitedUsers.length} users`);
- if (data.alreadyMembers.length > 0) {
- console.log(`${data.alreadyMembers.length} users were already members`);
- }
- if (data.notFound.length > 0) {
- console.log(`${data.notFound.length} users were not found`);
- }
- },
- });
-}
-
-export function useRoomMembers(roomId: string) {
- return useQuery({
- queryKey: chatKeys.roomMembers(roomId),
- queryFn: () => chatService.getRoomMembers(roomId),
- enabled: !!roomId,
- staleTime: 2 * 60 * 1000, // 2 minutes
- });
-}
-
-export function useRemoveMember() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: ({ roomId, memberId }: { roomId: string; memberId: string }) =>
- chatService.removeMember(roomId, memberId),
- onSuccess: (_, variables) => {
- // Invalidate room members to refresh the list
- queryClient.invalidateQueries({ queryKey: chatKeys.roomMembers(variables.roomId) });
- // Also invalidate room data to update participant count
- queryClient.invalidateQueries({ queryKey: chatKeys.room(variables.roomId) });
- },
- });
-}
-
-// Messages hooks
-export function useMessages(roomId: string, page = 1, limit = 50) {
- return useQuery({
- queryKey: [...chatKeys.messages(roomId), page, limit],
- queryFn: () => chatService.getMessages(roomId, page, limit),
- enabled: !!roomId,
- staleTime: 1 * 60 * 1000, // 1 minute
- });
-}
-
-export function useInfiniteMessages(roomId: string, limit = 50) {
- return useInfiniteQuery({
- queryKey: [...chatKeys.messages(roomId), 'infinite'],
- queryFn: ({ pageParam = 1 }) => chatService.getMessages(roomId, pageParam as number, limit),
- enabled: !!roomId,
- initialPageParam: 1,
- getNextPageParam: (lastPage: any) => {
- if (lastPage.page < lastPage.totalPages) {
- return lastPage.page + 1;
- }
- return undefined;
- },
- staleTime: 1 * 60 * 1000,
- });
-}
-
-export function useUpdateMessage(roomId: string) {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: ({ messageId, data }: { messageId: string; data: UpdateMessageRequest }) =>
- chatService.updateMessage(roomId, messageId, data),
- onSuccess: () => {
- // Invalidate messages for this room
- queryClient.invalidateQueries({ queryKey: chatKeys.messages(roomId) });
- },
- });
-}
-
-export function useDeleteMessage(roomId: string) {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (messageId: string) => chatService.deleteMessage(roomId, messageId),
- onSuccess: () => {
- // Invalidate messages for this room
- queryClient.invalidateQueries({ queryKey: chatKeys.messages(roomId) });
- },
- });
-}
-
-// Real-time message hook
-export function useRealTimeMessages(roomId: string) {
- const queryClient = useQueryClient();
-
- const addMessage = useCallback((message: MessageData) => {
- if (message.roomId !== roomId) return;
-
- // Add message to infinite query cache
- queryClient.setQueryData(
- [...chatKeys.messages(roomId), 'infinite'],
- (oldData: any) => {
- if (!oldData) return oldData;
-
- const newPages = [...oldData.pages];
- if (newPages.length > 0) {
- // Add to first page (newest messages at the end)
- newPages[0] = {
- ...newPages[0],
- messages: [...newPages[0].messages, message], // Add at the end for ASC sorting
- total: newPages[0].total + 1,
- };
- }
-
- return {
- ...oldData,
- pages: newPages,
- };
- }
- );
-
- // Also invalidate regular messages query
- queryClient.invalidateQueries({ queryKey: chatKeys.messages(roomId) });
- }, [queryClient, roomId]);
-
- const updateMessage = useCallback((data: { messageId: string; content: string; roomId: string }) => {
- if (data.roomId !== roomId) return;
-
- // Update message in cache
- queryClient.setQueryData(
- [...chatKeys.messages(roomId), 'infinite'],
- (oldData: any) => {
- if (!oldData) return oldData;
-
- const newPages = oldData.pages.map((page: any) => ({
- ...page,
- messages: page.messages.map((msg: MessageData) =>
- msg.id === data.messageId
- ? { ...msg, content: data.content, updatedAt: new Date().toISOString() }
- : msg
- ),
- }));
-
- return {
- ...oldData,
- pages: newPages,
- };
- }
- );
- }, [queryClient, roomId]);
-
- const deleteMessage = useCallback((data: { messageId: string; roomId: string }) => {
- if (data.roomId !== roomId) return;
-
- // Remove message from cache
- queryClient.setQueryData(
- [...chatKeys.messages(roomId), 'infinite'],
- (oldData: any) => {
- if (!oldData) return oldData;
-
- const newPages = oldData.pages.map((page: any) => ({
- ...page,
- messages: page.messages.filter((msg: MessageData) => msg.id !== data.messageId),
- total: Math.max(0, page.total - 1),
- }));
-
- return {
- ...oldData,
- pages: newPages,
- };
- }
- );
- }, [queryClient, roomId]);
-
- useEffect(() => {
- if (!roomId) return;
-
- // Subscribe to real-time events
- const unsubscribeNewMessage = socketService.onNewMessage(addMessage);
- const unsubscribeUpdateMessage = socketService.onMessageUpdate(updateMessage);
- const unsubscribeDeleteMessage = socketService.onMessageDelete(deleteMessage);
-
- return () => {
- unsubscribeNewMessage();
- unsubscribeUpdateMessage();
- unsubscribeDeleteMessage();
- };
- }, [roomId, addMessage, updateMessage, deleteMessage]);
-}
-
-// Real-time typing indicators hook
-export function useRealTimeTyping(roomId: string) {
- const { addTypingUser, removeTypingUser } = useChatStore();
-
- const handleTyping = useCallback((data: TypingData) => {
- if (data.roomId !== roomId) return;
-
- if (data.isTyping) {
- addTypingUser({
- userId: data.userId,
- username: data.username,
- roomId: data.roomId,
- });
- } else {
- removeTypingUser(data.userId, data.roomId);
- }
- }, [roomId, addTypingUser, removeTypingUser]);
-
- useEffect(() => {
- const unsubscribe = socketService.onTyping(handleTyping);
- return unsubscribe;
- }, [handleTyping]);
-}
-
-// Real-time user offline status hook
-export function useRealTimeUserStatus() {
- const queryClient = useQueryClient();
-
- const handleUserOfflineInRoom = useCallback((data: { userId: string; username: string; roomId: string }) => {
- // Invalidate room members query to refresh online status
- queryClient.invalidateQueries({ queryKey: chatKeys.roomMembers(data.roomId) });
-
- // Optionally show a notification that user went offline
- console.log(`${data.username} went offline in room ${data.roomId}`);
- }, [queryClient]);
-
- useEffect(() => {
- const unsubscribe = socketService.onUserOfflineInRoom(handleUserOfflineInRoom);
- return unsubscribe;
- }, [handleUserOfflineInRoom]);
-}
-
-// Real-time room deletion hook
-export function useRealTimeRoomDeletion() {
- const queryClient = useQueryClient();
-
- const handleRoomDeleted = useCallback((data: { roomId: string; roomName: string; message: string }) => {
- // Invalidate rooms query to refresh the room list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
-
- // Show notification about room deletion
- console.log(`Room deleted: ${data.message}`);
-
- // If user is currently in the deleted room, they should be redirected
- // This will be handled by the individual room components
- }, [queryClient]);
-
- useEffect(() => {
- const unsubscribe = socketService.onRoomDeleted(handleRoomDeleted);
- return unsubscribe;
- }, [handleRoomDeleted]);
-}
-
-// Hook for handling room deletion when user is in the specific room
-export function useRoomDeletionRedirect(roomId: string) {
- const navigate = useNavigate();
-
- const handleRoomDeleted = useCallback((data: { roomId: string; roomName: string; message: string }) => {
- // Only handle if this is the current room
- if (data.roomId === roomId) {
- // Show alert/notification
- alert(data.message);
-
- // Redirect to chat list
- navigate({ to: '/chat' });
- }
- }, [roomId, navigate]);
-
- useEffect(() => {
- const unsubscribe = socketService.onRoomDeleted(handleRoomDeleted);
- return unsubscribe;
- }, [handleRoomDeleted]);
-}
-
-// Real-time room update hook
-export function useRealTimeRoomUpdates() {
- const queryClient = useQueryClient();
-
- const handleRoomUpdated = useCallback((data: { roomId: string; roomName: string; avatarUrl: string; updatedRoom: any; message: string }) => {
- // Invalidate specific room query
- queryClient.invalidateQueries({ queryKey: chatKeys.room(data.roomId) });
-
- // Show notification about room update
- console.log(`Room updated: ${data.message}`);
- }, [queryClient]);
-
- const handleRoomListUpdated = useCallback((data: { action: string; room: any }) => {
- // Invalidate rooms query to refresh the room list
- queryClient.invalidateQueries({ queryKey: chatKeys.rooms() });
-
- console.log(`Room list updated: ${data.action} room ${data.room?.name || ''}`);
- }, [queryClient]);
-
- useEffect(() => {
- const unsubscribeRoomUpdated = socketService.onRoomUpdated(handleRoomUpdated);
- const unsubscribeRoomListUpdated = socketService.onRoomListUpdated(handleRoomListUpdated);
-
- return () => {
- unsubscribeRoomUpdated();
- unsubscribeRoomListUpdated();
- };
- }, [handleRoomUpdated, handleRoomListUpdated]);
-}
-
-// Hook for handling room updates when user is in the specific room
-export function useRoomUpdateNotification(roomId: string) {
- const handleRoomUpdated = useCallback((data: { roomId: string; roomName: string; avatarUrl: string; updatedRoom: any; message: string }) => {
- // Only handle if this is the current room
- if (data.roomId === roomId) {
- // Show notification about room update
- console.log(`Current room updated: ${data.message}`);
- // You could show a toast notification here instead of console.log
- }
- }, [roomId]);
-
- useEffect(() => {
- const unsubscribe = socketService.onRoomUpdated(handleRoomUpdated);
- return unsubscribe;
- }, [handleRoomUpdated]);
-}
-
-// Hook for handling user removal from room (redirect user)
-export function useUserRemovalRedirect() {
- const navigate = useNavigate();
-
- const handleUserRemovedFromRoom = useCallback((data: { roomId: string; roomName: string; message: string }) => {
- // Show alert/notification
- alert(data.message);
-
- // Redirect to chat list
- navigate({ to: '/chat' });
- }, [navigate]);
-
- useEffect(() => {
- const unsubscribe = socketService.onUserRemovedFromRoom(handleUserRemovedFromRoom);
- return unsubscribe;
- }, [handleUserRemovedFromRoom]);
-}
-
-// Hook for handling member removal notifications in room
-export function useRealTimeMemberRemoval() {
- const queryClient = useQueryClient();
-
- const handleMemberRemoved = useCallback((data: { roomId: string; roomName: string; removedUserId: string; removedUsername: string; message: string }) => {
- // Invalidate room members query to refresh the member list
- queryClient.invalidateQueries({ queryKey: chatKeys.roomMembers(data.roomId) });
-
- // Invalidate room data to update participant count
- queryClient.invalidateQueries({ queryKey: chatKeys.room(data.roomId) });
-
- // Show notification about member removal
- console.log(`Member removed: ${data.message}`);
- }, [queryClient]);
-
- useEffect(() => {
- const unsubscribe = socketService.onMemberRemoved(handleMemberRemoved);
- return unsubscribe;
- }, [handleMemberRemoved]);
-}
-
-// Socket connection hook with proper lifecycle management
-export function useSocketConnection() {
- const queryClient = useQueryClient();
- const [connectionState, setConnectionState] = useState({
- connected: socketService.connected,
- socketId: socketService.socketId,
- });
-
- useEffect(() => {
- const connectSocket = async () => {
- try {
- if (!socketService.connected) {
- console.log('Connecting to socket...');
- await socketService.connect();
- }
- } catch (error) {
- console.error('Failed to connect to socket:', error);
- }
- };
-
- connectSocket();
-
- // Cleanup on unmount - this will decrement reference count
- return () => {
- console.log('Disconnecting socket (component unmount)');
- socketService.disconnect();
- };
- }, []); // Empty dependency array - only run once per component mount
-
- useEffect(() => {
- const unsubscribe = socketService.onConnectionChange((connected) => {
- console.log('Socket connection state changed:', connected);
- setConnectionState({
- connected,
- socketId: socketService.socketId,
- });
-
- if (connected) {
- // Invalidate all chat queries when reconnected
- queryClient.invalidateQueries({ queryKey: chatKeys.all });
- }
- });
-
- return unsubscribe;
- }, [queryClient]);
-
- return connectionState;
-}
-
-// Lightweight hook for components that just need to know connection status
-export function useSocketStatus() {
- const [connected, setConnected] = useState(socketService.connected);
-
- useEffect(() => {
- const unsubscribe = socketService.onConnectionChange(setConnected);
- return unsubscribe;
- }, []);
-
- return { connected };
-}
diff --git a/client/src/hooks/useTokenMonitor.ts b/client/src/hooks/useTokenMonitor.ts
deleted file mode 100644
index 735ce51..0000000
--- a/client/src/hooks/useTokenMonitor.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-/**
- * Token Monitoring Hook
- *
- * This hook provides automatic token expiration monitoring and logout functionality.
- * It integrates with the token monitor service and provides React-specific features.
- */
-
-import { useEffect, useCallback, useRef } from 'react';
-import { useAuthStore } from '@/stores/authStore';
-import { tokenMonitorService, TokenMonitorCallback } from '@/services/token-monitor.service';
-import { TokenExpirationEvent } from '@/lib/token-utils';
-import { toast } from 'sonner';
-
-export interface UseTokenMonitorOptions {
- /**
- * Whether to automatically start monitoring when authenticated
- * @default true
- */
- autoStart?: boolean;
-
- /**
- * Whether to show toast notifications for token events
- * @default true
- */
- showNotifications?: boolean;
-
- /**
- * Custom callback for token expiration events
- */
- onTokenEvent?: (event: TokenExpirationEvent, data?: any) => void;
-
- /**
- * Whether to automatically logout on token expiration
- * @default true
- */
- autoLogout?: boolean;
-
- /**
- * Check interval in milliseconds
- * @default 30000 (30 seconds)
- */
- checkInterval?: number;
-
- /**
- * Warning threshold in minutes before expiry
- * @default 5
- */
- warningThreshold?: number;
-
- /**
- * Auto-refresh threshold in minutes before expiry
- * @default 2
- */
- autoRefreshThreshold?: number;
-}
-
-export interface UseTokenMonitorReturn {
- /**
- * Whether token monitoring is currently active
- */
- isMonitoring: boolean;
-
- /**
- * Start token monitoring manually
- */
- startMonitoring: () => void;
-
- /**
- * Stop token monitoring manually
- */
- stopMonitoring: () => void;
-
- /**
- * Get current token information
- */
- getTokenInfo: () => any;
-
- /**
- * Check if current token is valid
- */
- isTokenValid: () => boolean;
-
- /**
- * Force a token refresh
- */
- forceRefresh: () => Promise;
-}
-
-/**
- * Hook for monitoring JWT token expiration and handling automatic logout
- */
-export function useTokenMonitor(options: UseTokenMonitorOptions = {}): UseTokenMonitorReturn {
- const {
- autoStart = true,
- showNotifications = true,
- onTokenEvent,
- autoLogout = true,
- checkInterval = 30000,
- warningThreshold = 5,
- autoRefreshThreshold = 2,
- } = options;
-
- const { isAuthenticated, tokens, logout, checkTokenExpiration, getTokenInfo, isTokenValid } = useAuthStore();
- const callbackRef = useRef(null);
- const isMonitoringRef = useRef(false);
-
- /**
- * Handle token expiration events
- */
- const handleTokenEvent = useCallback((event: TokenExpirationEvent, data?: any) => {
- console.log('Token event:', event, data);
-
- // Call custom callback if provided
- if (onTokenEvent) {
- try {
- onTokenEvent(event, data);
- } catch (error) {
- console.error('Error in custom token event callback:', error);
- }
- }
-
- // Handle different event types
- switch (event) {
- case TokenExpirationEvent.EXPIRED:
- if (showNotifications) {
- toast.error('Your session has expired. Please log in again.', {
- duration: 5000,
- action: {
- label: 'Login',
- onClick: () => {
- window.location.href = '/login';
- },
- },
- });
- }
-
- if (autoLogout) {
- logout();
- }
- break;
-
- case TokenExpirationEvent.NEAR_EXPIRY:
- if (showNotifications && data?.timeLeftText) {
- toast.warning(`Your session will expire in ${data.timeLeftText}. Please save your work.`, {
- duration: 10000,
- action: {
- label: 'Extend Session',
- onClick: async () => {
- try {
- await forceRefresh();
- toast.success('Session extended successfully');
- } catch (error) {
- toast.error('Failed to extend session');
- }
- },
- },
- });
- }
- break;
-
- case TokenExpirationEvent.REFRESHED:
- if (showNotifications) {
- toast.success('Session extended successfully', {
- duration: 3000,
- });
- }
- break;
-
- case TokenExpirationEvent.INVALID:
- if (showNotifications) {
- toast.error('Your session is invalid. Please log in again.', {
- duration: 5000,
- });
- }
-
- if (autoLogout) {
- logout();
- }
- break;
- }
- }, [onTokenEvent, showNotifications, autoLogout, logout]);
-
- /**
- * Start monitoring token expiration
- */
- const startMonitoring = useCallback(() => {
- if (isMonitoringRef.current) {
- return;
- }
-
- console.log('Starting token monitoring...');
-
- // Update token monitor configuration
- tokenMonitorService.updateConfig({
- checkInterval,
- warningThreshold,
- autoRefreshThreshold,
- });
-
- // Register callback
- callbackRef.current = handleTokenEvent;
- tokenMonitorService.addCallback(callbackRef.current);
-
- // Start monitoring
- tokenMonitorService.start();
- isMonitoringRef.current = true;
- }, [checkInterval, warningThreshold, autoRefreshThreshold, handleTokenEvent]);
-
- /**
- * Stop monitoring token expiration
- */
- const stopMonitoring = useCallback(() => {
- if (!isMonitoringRef.current) {
- return;
- }
-
- console.log('Stopping token monitoring...');
-
- // Remove callback
- if (callbackRef.current) {
- tokenMonitorService.removeCallback(callbackRef.current);
- callbackRef.current = null;
- }
-
- // Stop monitoring
- tokenMonitorService.stop();
- isMonitoringRef.current = false;
- }, []);
-
- /**
- * Force a token refresh
- */
- const forceRefresh = useCallback(async () => {
- try {
- const { httpClient } = await import('@/lib/http-client');
- await httpClient.forceRefresh();
- } catch (error) {
- console.error('Failed to force refresh token:', error);
- throw error;
- }
- }, []);
-
- /**
- * Effect to start/stop monitoring based on authentication status
- */
- useEffect(() => {
- if (autoStart && isAuthenticated && tokens?.accessToken) {
- // Check token validity first
- if (checkTokenExpiration()) {
- startMonitoring();
- }
- } else {
- stopMonitoring();
- }
-
- // Cleanup on unmount
- return () => {
- stopMonitoring();
- };
- }, [autoStart, isAuthenticated, tokens?.accessToken, startMonitoring, stopMonitoring, checkTokenExpiration]);
-
- /**
- * Effect to handle token changes
- */
- useEffect(() => {
- if (isAuthenticated && tokens?.accessToken) {
- // Validate token when it changes
- if (!checkTokenExpiration()) {
- // Token is invalid or expired, stop monitoring
- stopMonitoring();
- }
- }
- }, [tokens?.accessToken, isAuthenticated, checkTokenExpiration, stopMonitoring]);
-
- return {
- isMonitoring: isMonitoringRef.current,
- startMonitoring,
- stopMonitoring,
- getTokenInfo,
- isTokenValid,
- forceRefresh,
- };
-}
-
-/**
- * Simplified hook for basic token monitoring with default settings
- */
-export function useAutoLogout() {
- return useTokenMonitor({
- autoStart: true,
- showNotifications: true,
- autoLogout: true,
- });
-}
-
-/**
- * Hook for token monitoring without notifications (for background monitoring)
- */
-export function useTokenMonitorSilent() {
- return useTokenMonitor({
- autoStart: true,
- showNotifications: false,
- autoLogout: true,
- });
-}
diff --git a/client/src/providers/SocketProvider.tsx b/client/src/providers/SocketProvider.tsx
deleted file mode 100644
index 768b944..0000000
--- a/client/src/providers/SocketProvider.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
-import { useAuthStore } from '@/stores/authStore';
-import { socketService } from '@/services/socket.service';
-
-interface SocketContextType {
- connected: boolean;
- socketId: string | null;
- connectionError: string | null;
-}
-
-const SocketContext = createContext({
- connected: false,
- socketId: null,
- connectionError: null,
-});
-
-export const useSocket = () => {
- const context = useContext(SocketContext);
- if (!context) {
- throw new Error('useSocket must be used within a SocketProvider');
- }
- return context;
-};
-
-interface SocketProviderProps {
- children: React.ReactNode;
-}
-
-export function SocketProvider({ children }: SocketProviderProps) {
- const { isAuthenticated, tokens } = useAuthStore();
- const [connected, setConnected] = useState(socketService.connected);
- const [socketId, setSocketId] = useState(socketService.socketId!);
- const [connectionError, setConnectionError] = useState(null);
-
- // Connect when authenticated, disconnect when not
- useEffect(() => {
- if (isAuthenticated && tokens?.accessToken) {
- console.log('SocketProvider: User authenticated, connecting socket...');
-
- const connectSocket = async () => {
- try {
- setConnectionError(null);
- await socketService.connect();
- } catch (error) {
- console.error('SocketProvider: Failed to connect socket:', error);
- setConnectionError(error instanceof Error ? error.message : 'Connection failed');
- }
- };
-
- connectSocket();
- } else {
- console.log('SocketProvider: User not authenticated, disconnecting socket...');
- socketService.disconnect();
- }
- }, [isAuthenticated, tokens?.accessToken]);
-
- // Listen for connection state changes
- useEffect(() => {
- const unsubscribe = socketService.onConnectionChange((isConnected) => {
- console.log('SocketProvider: Connection state changed:', isConnected);
- setConnected(isConnected);
- setSocketId(socketService.socketId!);
-
- if (isConnected) {
- setConnectionError(null);
- }
- });
-
- return unsubscribe;
- }, []);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- console.log('SocketProvider: Cleaning up socket connection');
- socketService.disconnect();
- };
- }, []);
-
- const contextValue: SocketContextType = {
- connected,
- socketId,
- connectionError,
- };
-
- return (
-
- {children}
-
- );
-}
diff --git a/client/src/routes/__root.tsx b/client/src/routes/__root.tsx
index 9734d22..e295c5a 100644
--- a/client/src/routes/__root.tsx
+++ b/client/src/routes/__root.tsx
@@ -1,18 +1,19 @@
-import React from 'react'
import { createRootRoute, Outlet } from '@tanstack/react-router'
-import { TanStackRouterDevtools } from '@tanstack/router-devtools'
+import { QueryClientProvider } from '@tanstack/react-query'
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
+import { queryClient } from '@/lib/query-client'
import { Toaster } from '@/components/ui/sonner'
-import { TokenMonitor } from '@/components/TokenMonitor'
export const Route = createRootRoute({
- component: () => {
- return (
-
-
-
-
-
-
- )
- },
+ component: RootLayout,
})
+
+function RootLayout() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/client/src/routes/chat.tsx b/client/src/routes/chat.tsx
deleted file mode 100644
index a9ceaa7..0000000
--- a/client/src/routes/chat.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
-import { useState } from 'react'
-import { Navigation } from '@/components/Navigation'
-import { ChatRoomList } from '@/components/chat/ChatRoomList'
-import { ChatRoom } from '@/components/chat/ChatRoom'
-import { useAuthStore } from '@/stores/authStore'
-
-
-export const Route = createFileRoute('/chat')({
- beforeLoad: ({ location }) => {
- // Check if user is authenticated
- const isAuthenticated = useAuthStore.getState().isAuthenticated
-
- if (!isAuthenticated) {
- throw redirect({
- to: '/login',
- search: {
- redirect: location.href,
- },
- })
- }
- },
- component: ChatPage,
-})
-
-function ChatPage() {
- const [selectedRoomId, setSelectedRoomId] = useState(null)
- const navigate = useNavigate()
-
- // Socket connection is now managed by SocketProvider
-
- const handleRoomSelect = (roomId: string) => {
- setSelectedRoomId(roomId)
- // Update URL to include room ID
- navigate({
- to: '/room/$id',
- params: { id: roomId },
- }).catch(() => {
- // If navigation fails, just set the selected room
- setSelectedRoomId(roomId)
- })
- }
-
- return (
-
-
-
- {/* Room list sidebar */}
-
-
-
-
- {/* Chat area */}
-
- {selectedRoomId ? (
-
- ) : (
-
-
-
-
Select a room to start chatting
-
Choose a room from the sidebar to begin your conversation
-
-
- )}
-
-
-
- )
-}
diff --git a/client/src/routes/forgot-password.tsx b/client/src/routes/forgot-password.tsx
deleted file mode 100644
index 941b5aa..0000000
--- a/client/src/routes/forgot-password.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { createFileRoute, Link } from '@tanstack/react-router'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { ArrowLeft } from 'lucide-react'
-
-export const Route = createFileRoute('/forgot-password')({
- component: ForgotPasswordPage,
-})
-
-function ForgotPasswordPage() {
- return (
-
- {/* Back to Login Button */}
-
-
-
-
-
-
- Forgot Password
-
- Enter your email to reset your password
-
-
-
-
-
-
-
-
-
-
- Back to Sign In
-
-
-
-
-
- )
-}
diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx
index 1a9b503..fcfebe6 100644
--- a/client/src/routes/index.tsx
+++ b/client/src/routes/index.tsx
@@ -1,252 +1,50 @@
import { createFileRoute, Link } from '@tanstack/react-router'
-import React from 'react'
import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { ArrowRight, Code, Database, Globe, Zap, Users, MessageCircle, CheckSquare, Lock } from 'lucide-react'
-import { Navigation } from '@/components/Navigation'
-import { useAuthStore } from '@/stores/authStore'
+import { CheckCircle, Zap, Shield, RefreshCw } from 'lucide-react'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
- const { isAuthenticated } = useAuthStore()
- const features = [
- {
- icon: ,
- title: "Modern Tech Stack",
- description: "Built with React, TypeScript, TanStack Router, and TailwindCSS",
- color: "bg-blue-500"
- },
- {
- icon: ,
- title: "Full Stack Ready",
- description: "Complete MERN stack with MongoDB, Express, React, and Node.js",
- color: "bg-green-500"
- },
- {
- icon: ,
- title: "High Performance",
- description: "Optimized for speed with modern bundling and state management",
- color: "bg-yellow-500"
- },
- {
- icon: ,
- title: "Responsive Design",
- description: "Beautiful UI that works perfectly on all devices",
- color: "bg-purple-500"
- }
- ]
-
- const apps = [
- {
- title: "Todo Manager",
- description: "Organize your tasks efficiently with our intuitive todo application",
- icon: ,
- link: "/todo",
- color: "from-blue-500 to-blue-600"
- },
- {
- title: "Chat Rooms",
- description: "Connect with others in real-time chat rooms",
- icon: ,
- link: "/chat",
- color: "from-green-500 to-green-600"
- },
- {
- title: "User Profile",
- description: "Manage your account settings and preferences",
- icon: ,
- link: "/profile",
- color: "from-purple-500 to-purple-600"
- }
- ]
-
return (
-
-
-
- {/* Hero Section */}
-
-
-
-
- 🚀 Modern Full Stack Application
-
-
- Welcome to{' '}
-
- MERN Stack
-
-
-
- A modern, full-featured web application built with the latest technologies.
- Experience the power of React, TanStack Router, shadcn/ui, and TailwindCSS.
-
-
- {isAuthenticated ? (
- <>
-
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
-
- >
- )}
-
-
- {!isAuthenticated && (
-
-
-
- Authentication Required
-
-
- Sign in to access Todo Manager, Chat Rooms, and Profile features.
-
-
- )}
-
+
+
-
- {/* Features Section */}
-
-
-
-
- Built with Modern Technologies
-
-
- Our application leverages the latest and greatest technologies to provide
- you with the best possible experience.
-
-
-
- {features.map((feature, index) => (
-
-
-
- {feature.icon}
-
- {feature.title}
-
-
- {feature.description}
-
-
- ))}
+
+ TanStack Todo
+
+
+ A beginner-friendly Todo App built with the full TanStack ecosystem
+
+
+
+
+
TanStack Query
+
Smart caching
-
-
-
- {/* Applications Section */}
-
-
-
-
- Explore Our Applications
-
-
- Discover the powerful applications we've built to showcase the capabilities
- of our modern tech stack.
-
+
+
+
TanStack Router
+
Type-safe routing
-
- {apps.map((app, index) => (
-
-
-
- {app.icon}
-
- {app.title}
- {app.description}
-
-
- {isAuthenticated ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- ))}
+
-
-
- {/* Footer */}
-
)
}
diff --git a/client/src/routes/login.tsx b/client/src/routes/login.tsx
index a85acea..ae7cbf3 100644
--- a/client/src/routes/login.tsx
+++ b/client/src/routes/login.tsx
@@ -1,131 +1,91 @@
-import { createFileRoute, Link, useNavigate, useSearch } from '@tanstack/react-router'
-import React, { useState } from 'react'
+import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router'
+import { useState } from 'react'
import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { ArrowLeft } from 'lucide-react'
-import { useLogin } from '@/hooks/useAuth'
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
+import { useAuthStore } from '@/stores/authStore'
+import { authService } from '@/services/auth.service'
+import { CheckCircle, Loader2 } from 'lucide-react'
+import { toast } from 'sonner'
export const Route = createFileRoute('/login')({
- validateSearch: (search: Record
) => ({
- redirect: (search.redirect as string) || '/',
- }),
+ beforeLoad: ({ context }) => {
+ if (useAuthStore.getState().isAuthenticated) {
+ throw redirect({ to: '/todo' })
+ }
+ },
component: LoginPage,
})
function LoginPage() {
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const { setUser, setAuthenticated, setTokens } = useAuthStore()
const navigate = useNavigate()
- const search = useSearch({ from: '/login' })
- const loginMutation = useLogin()
- const [formData, setFormData] = useState({
- email: '',
- password: ''
- })
-
- const handleSubmit = async (e: React.FormEvent) => {
+ const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
-
- loginMutation.mutate(formData, {
- onSuccess: () => {
- navigate({ to: search.redirect })
- },
- onError: (error: any) => {
- console.error('Login error:', error)
- }
- })
+ setIsLoading(true)
+ try {
+ const response = await authService.login({ email, password })
+ setTokens(response.tokens)
+ setUser(response.user)
+ setAuthenticated(true)
+ toast.success('Welcome back!')
+ navigate({ to: '/todo' })
+ } catch (error: any) {
+ toast.error(error.message || 'Login failed')
+ } finally {
+ setIsLoading(false)
+ }
}
- return (
-
- {/* Back to Home Button */}
-
-
-
-
- {/* Demo Info Card */}
-
-
-
-
Demo Account
-
- Use these credentials to test the application:
-
-
-
Email: john@example.com
-
Password: Password123
-
-
-
-
-
-
-
- Sign In
-
- Sign in to your account to continue
-
-
+ return (
+
+
+
+
+
+
+ Welcome back
+ Sign in to your TanStack Todo account
+
-
-
-
- Forgot password?
-
-
- Don't have an account?{' '}
-
- Sign up
-
-
-
+
+ Don't have an account?{" "}
+
+ Sign up
+
+
-
)
}
diff --git a/client/src/routes/profile.tsx b/client/src/routes/profile.tsx
deleted file mode 100644
index ef9e719..0000000
--- a/client/src/routes/profile.tsx
+++ /dev/null
@@ -1,367 +0,0 @@
-import { createFileRoute, redirect } from '@tanstack/react-router'
-import React, { useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
-import { Save, LogOut, Loader2 } from 'lucide-react'
-import { Navigation } from '@/components/Navigation'
-import { useAuthStore } from '@/stores/authStore'
-import { useCurrentUser, useUpdateProfile, useLogout, useChangePassword } from '@/hooks/useAuth'
-import { AvatarUpload } from '@/components/ui/file-upload'
-import { uploadService } from '@/services/upload.service'
-import { toast } from 'sonner'
-import { ChangePasswordDialog } from '@/components/ui/change-password-dialog'
-
-export const Route = createFileRoute('/profile')({
- beforeLoad: ({ location }) => {
- // Check if user is authenticated
- const isAuthenticated = useAuthStore.getState().isAuthenticated
-
- if (!isAuthenticated) {
- throw redirect({
- to: '/login',
- search: {
- redirect: location.href,
- },
- })
- }
- },
- component: MyProfilePage,
-})
-
-function MyProfilePage() {
- const { data: currentUser, isLoading, error } = useCurrentUser()
- const updateProfileMutation = useUpdateProfile()
- const logoutMutation = useLogout()
- const changePasswordMutation = useChangePassword()
-
- const [profile, setProfile] = useState({
- name: '',
- email: '',
- bio: 'Software developer passionate about creating amazing user experiences.',
- avatar: ''
- })
- const [validationError, setValidationError] = useState('')
- const [changePasswordOpen, setChangePasswordOpen] = useState(false)
-
- // Update profile state when user data is loaded
- React.useEffect(() => {
- if (currentUser) {
- // Load bio from localStorage or use default
- const savedBio = localStorage.getItem(`user_bio_${currentUser.id}`) ||
- 'Software developer passionate about creating amazing user experiences.'
-
- setProfile({
- name: currentUser.username || '',
- email: currentUser.email || '',
- bio: savedBio,
- avatar: currentUser.avatarUrl || '/api/placeholder/120/120'
- })
- }
- }, [currentUser])
-
- const validateProfile = () => {
- if (!profile.name.trim()) {
- setValidationError('Name is required')
- return false
- }
- if (profile.name.trim().length < 3) {
- setValidationError('Name must be at least 3 characters long')
- return false
- }
- if (profile.name.trim().length > 50) {
- setValidationError('Name must be less than 50 characters')
- return false
- }
- if (!/^[a-zA-Z0-9_-]+$/.test(profile.name.trim())) {
- setValidationError('Name can only contain letters, numbers, underscores, and hyphens')
- return false
- }
- if (profile.avatar.trim() && !isValidUrl(profile.avatar.trim())) {
- setValidationError('Avatar URL must be a valid URL')
- return false
- }
- setValidationError('')
- return true
- }
-
- const isValidUrl = (url: string): boolean => {
- try {
- new URL(url)
- return true
- } catch {
- return false
- }
- }
-
- const handleSave = () => {
- if (!validateProfile()) return
-
- const updateData: any = {}
- let hasChanges = false
-
- // Only include fields that have changed
- if (profile.name !== currentUser?.username) {
- updateData.username = profile.name.trim()
- hasChanges = true
- }
- if (profile.avatar !== currentUser?.avatarUrl) {
- updateData.avatarUrl = profile.avatar.trim() || undefined
- hasChanges = true
- }
-
- // Save bio to localStorage (since backend doesn't support it yet)
- if (currentUser) {
- const currentBio = localStorage.getItem(`user_bio_${currentUser.id}`) ||
- 'Software developer passionate about creating amazing user experiences.'
- if (profile.bio !== currentBio) {
- localStorage.setItem(`user_bio_${currentUser.id}`, profile.bio)
- hasChanges = true
- toast.success('Bio updated successfully!')
- }
- }
-
- // If no backend changes but bio changed, still show success
- if (Object.keys(updateData).length === 0) {
- if (!hasChanges) {
- setValidationError('No changes to save')
- return
- } else {
- // Only bio changed, no API call needed
- setValidationError('')
- return
- }
- }
-
- updateProfileMutation.mutate(updateData, {
- onSuccess: () => {
- setValidationError('')
- },
- onError: (error: any) => {
- setValidationError(error.message || 'Failed to update profile')
- }
- })
- }
-
- const handleAvatarUpload = async (file: File): Promise
=> {
- try {
- const url = await uploadService.uploadAvatar(file)
- setProfile({ ...profile, avatar: url })
- toast.success('Avatar uploaded successfully!')
- return url
- } catch (error: any) {
- toast.error(error.message || 'Failed to upload avatar')
- throw error
- }
- }
-
- const handleLogout = () => {
- logoutMutation.mutate()
- }
-
- const handleChangePassword = async (data: { currentPassword: string; newPassword: string }) => {
- await changePasswordMutation.mutateAsync(data)
- }
-
- if (isLoading) {
- return (
-
-
-
-
-
-
- Loading profile...
-
-
-
-
- )
- }
-
- if (error) {
- return (
-
-
-
-
-
-
Failed to load profile. Please try again.
-
{error.message}
-
-
-
-
- )
- }
-
- return (
-
-
-
-
-
-
My Profile
-
Manage your account settings and preferences
-
-
-
-
- Profile Information
-
-
- {/* Avatar Section */}
-
-
-
-
-
- {profile.name.split(' ').map(n => n[0]).join('')}
-
-
-
-
-
-
- {/* Validation Error */}
- {validationError && (
-
- {validationError}
-
- )}
-
- {/* Form Fields */}
-
-
-
-
{
- setProfile({ ...profile, name: e.target.value })
- if (validationError) setValidationError('')
- }}
- placeholder="Enter your username"
- maxLength={50}
- />
-
- Username can only contain letters, numbers, underscores, and hyphens
-
-
-
-
-
-
-
- Email cannot be changed
-
-
-
-
-
-
{
- setProfile({ ...profile, avatar: e.target.value })
- if (validationError) setValidationError('')
- }}
- placeholder="https://example.com/avatar.jpg"
- />
-
- Enter a valid URL for your avatar image
-
-
-
-
-
-
- {/* Save Button */}
-
-
-
-
-
-
- {/* Additional Settings */}
-
-
- Account Settings
-
-
-
-
-
-
-
-
-
-
-
- {/* Change Password Dialog */}
-
-
- )
-}
diff --git a/client/src/routes/register.tsx b/client/src/routes/register.tsx
index a004b03..17b63dc 100644
--- a/client/src/routes/register.tsx
+++ b/client/src/routes/register.tsx
@@ -1,164 +1,102 @@
-import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
-import React, { useState } from "react";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { ArrowLeft } from "lucide-react";
-import { useRegister } from "@/hooks/useAuth";
+import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router'
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
+import { useAuthStore } from '@/stores/authStore'
+import { authService } from '@/services/auth.service'
+import { CheckCircle, Loader2 } from 'lucide-react'
+import { toast } from 'sonner'
-export const Route = createFileRoute("/register")({
+export const Route = createFileRoute('/register')({
+ beforeLoad: () => {
+ if (useAuthStore.getState().isAuthenticated) {
+ throw redirect({ to: '/todo' })
+ }
+ },
component: RegisterPage,
-});
+})
function RegisterPage() {
- const navigate = useNavigate();
- const registerMutation = useRegister();
-
- const [formData, setFormData] = useState({
- username: "",
- email: "",
- password: "",
- confirmPassword: "",
- });
- const [error, setError] = useState("");
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setError("");
+ const [name, setName] = useState('')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+ const { setUser, setAuthenticated, setTokens } = useAuthStore()
+ const navigate = useNavigate()
- if (formData.password !== formData.confirmPassword) {
- setError("Passwords do not match");
- return;
+ const handleRegister = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setIsLoading(true)
+ try {
+ const response = await authService.register({ name, email, password })
+ setTokens(response.tokens)
+ setUser(response.user)
+ setAuthenticated(true)
+ toast.success('Account created! Welcome 🎉')
+ navigate({ to: '/todo' })
+ } catch (error: any) {
+ toast.error(error.message || 'Registration failed')
+ } finally {
+ setIsLoading(false)
}
+ }
- registerMutation.mutate(
- {
- username: formData.username,
- email: formData.email,
- password: formData.password,
- },
- {
- onSuccess: () => {
- navigate({ to: "/" });
- },
- onError: (error: any) => {
- setError(error.message || "Registration failed. Please try again.");
- },
- }
- );
- };
return (
-
- {/* Back to Home Button */}
-
-
-
-
+
- Sign Up
- Create a new account to get started
+
+
+
+ Create account
+ Start managing your todos with TanStack
-
-
-
- Already have an account?{" "}
-
- Sign in
-
-
-
+
+ Already have an account?{" "}
+
+ Sign in
+
+
- );
+ )
}
diff --git a/client/src/routes/room.$id.tsx b/client/src/routes/room.$id.tsx
deleted file mode 100644
index 0c439bf..0000000
--- a/client/src/routes/room.$id.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { createFileRoute, useParams, Link, redirect } from '@tanstack/react-router'
-import { Button } from '@/components/ui/button'
-import { ArrowLeft } from 'lucide-react'
-import { ChatRoom } from '@/components/chat/ChatRoom'
-import { useAuthStore } from '@/stores/authStore'
-
-
-export const Route = createFileRoute('/room/$id')({
- beforeLoad: ({ location }) => {
- // Check if user is authenticated
- const isAuthenticated = useAuthStore.getState().isAuthenticated
-
- if (!isAuthenticated) {
- throw redirect({
- to: '/login',
- search: {
- redirect: location.href,
- },
- })
- }
- },
- component: RoomPage,
-})
-
-function RoomPage() {
- const { id } = useParams({ from: '/room/$id' })
-
- // Socket connection is now managed by SocketProvider
-
- return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Chat Room */}
-
-
-
-
- )
-}
diff --git a/client/src/routes/verify-otp.tsx b/client/src/routes/verify-otp.tsx
deleted file mode 100644
index a39b159..0000000
--- a/client/src/routes/verify-otp.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { createFileRoute, Link, useSearch } from '@tanstack/react-router'
-import React, { useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { ArrowLeft } from 'lucide-react'
-
-export const Route = createFileRoute('/verify-otp')({
- component: VerifyOtpPage,
- validateSearch: (search: Record
) => ({
- e: (search.e as string) || '',
- }),
-})
-
-function VerifyOtpPage() {
- const search = useSearch({ from: '/verify-otp' })
- const [otp, setOtp] = useState("")
-
- const handleSubmit = () => {
- console.log('Verifying OTP:', otp, 'for email:', search.e)
- // Handle OTP verification logic here
- }
-
- return (
-
- {/* Back to Forgot Password Button */}
-
-
-
-
-
-
- Verify OTP
-
- Enter the 6-digit code sent to {search.e || 'your email'}
-
-
-
-
-
- setOtp(e.target.value)}
- maxLength={6}
- className="text-center text-lg tracking-widest"
- required
- />
-
-
-
-
- Resend Code
-
-
-
- Back to Sign In
-
-
-
-
-
-
- )
-}
diff --git a/client/src/services/chat.service.ts b/client/src/services/chat.service.ts
deleted file mode 100644
index 4bca502..0000000
--- a/client/src/services/chat.service.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import { httpClient } from '@/lib/http-client';
-import { MessageData, RoomData } from './socket.service';
-
-export interface CreateRoomRequest {
- name: string;
- avatarUrl?: string;
-}
-
-export interface UpdateRoomRequest {
- name?: string;
- avatarUrl?: string;
-}
-
-export interface CreateMessageRequest {
- content: string;
- roomId: string;
-}
-
-export interface UpdateMessageRequest {
- content: string;
-}
-
-export interface PaginatedResponse {
- data: T[];
- total: number;
- page: number;
- limit: number;
- totalPages: number;
-}
-
-export interface RoomsResponse {
- rooms: RoomData[];
- total: number;
- page: number;
- limit: number;
- totalPages: number;
-}
-
-export interface MessagesResponse {
- messages: MessageData[];
- total: number;
- page: number;
- limit: number;
- totalPages: number;
-}
-
-export interface InviteUsersResponse {
- invitedUsers: {
- id: string;
- username: string;
- email: string;
- }[];
- alreadyMembers: string[];
- notFound: string[];
-}
-
-export interface RoomMember {
- id: string;
- username: string;
- email: string;
- avatarUrl: string;
- isOnline: boolean;
- isAuthor: boolean;
- joinedAt: string;
-}
-
-export interface RoomMembersResponse {
- members: RoomMember[];
- totalMembers: number;
- roomInfo: {
- id: string;
- name: string;
- authorId: string;
- createdAt: string;
- };
-}
-
-class ChatService {
- private baseUrl = '/chat';
-
- // Room operations
- async getRooms(
- page = 1,
- limit = 10,
- sortBy: 'name' | 'updated_at' | 'created_at' = 'updated_at',
- sortOrder: 'asc' | 'desc' = 'desc'
- ): Promise {
- const params = new URLSearchParams({
- page: page.toString(),
- limit: limit.toString(),
- sortBy,
- sortOrder
- });
-
- const response = await httpClient.get(
- `${this.baseUrl}/rooms?${params.toString()}`
- );
- // HttpClient returns ApiResponse, so we need response.data
- return response.data || { rooms: [], total: 0, page: 1, limit: 10, totalPages: 0 };
- }
-
- async getRoom(roomId: string): Promise {
- const response = await httpClient.get<{ room: RoomData }>(
- `${this.baseUrl}/rooms/${roomId}`
- );
- // HttpClient returns ApiResponse<{ room: RoomData }>, so we need response.data?.room
- return response.data?.room || {} as RoomData;
- }
-
- async createRoom(data: CreateRoomRequest): Promise {
- const response = await httpClient.post<{ room: RoomData }>(
- `${this.baseUrl}/rooms`,
- data
- );
- // HttpClient returns ApiResponse<{ room: RoomData }>, so we need response.data?.room
- return response.data?.room || {} as RoomData;
- }
-
- async updateRoom(roomId: string, data: UpdateRoomRequest): Promise {
- const response = await httpClient.put<{ room: RoomData }>(
- `${this.baseUrl}/rooms/${roomId}`,
- data
- );
- // HttpClient returns ApiResponse<{ room: RoomData }>, so we need response.data?.room
- return response.data?.room || {} as RoomData;
- }
-
- async deleteRoom(roomId: string): Promise {
- await httpClient.delete(`${this.baseUrl}/rooms/${roomId}`);
- }
-
- async joinRoom(roomId: string): Promise {
- const response = await httpClient.post<{ room: RoomData }>(
- `${this.baseUrl}/rooms/${roomId}/join`
- );
- // HttpClient returns ApiResponse<{ room: RoomData }>, so we need response.data?.room
- return response.data?.room || {} as RoomData;
- }
-
- async leaveRoom(roomId: string): Promise {
- await httpClient.post(`${this.baseUrl}/rooms/${roomId}/leave`);
- }
-
- async inviteUsers(roomId: string, userIds: string[]): Promise {
- const response = await httpClient.post(
- `${this.baseUrl}/rooms/${roomId}/invite`,
- { userIds }
- );
- // HttpClient returns ApiResponse, so we need response.data
- return response.data || { invitedUsers: [], alreadyMembers: [], notFound: [] };
- }
-
- async getRoomMembers(roomId: string): Promise {
- const response = await httpClient.get(
- `${this.baseUrl}/rooms/${roomId}/members`
- );
- // HttpClient returns ApiResponse, so we need response.data
- return response.data || { members: [], totalMembers: 0, roomInfo: { id: '', name: '', authorId: '', createdAt: '' } };
- }
-
- async removeMember(roomId: string, memberId: string): Promise {
- await httpClient.delete(`${this.baseUrl}/rooms/${roomId}/members/${memberId}`);
- }
-
- // Message operations
- async getMessages(roomId: string, page = 1, limit = 50): Promise {
- const response = await httpClient.get(
- `${this.baseUrl}/rooms/${roomId}/messages?page=${page}&limit=${limit}`
- );
- // HttpClient returns ApiResponse, so we need response.data
- return response.data || { messages: [], total: 0, page: 1, limit: 50, totalPages: 0 };
- }
-
- async updateMessage(roomId: string, messageId: string, data: UpdateMessageRequest): Promise {
- const response = await httpClient.put<{ message: MessageData }>(
- `${this.baseUrl}/rooms/${roomId}/messages/${messageId}`,
- data
- );
- // HttpClient returns ApiResponse<{ message: MessageData }>, so we need response.data?.message
- return response.data?.message || {} as MessageData;
- }
-
- async deleteMessage(roomId: string, messageId: string): Promise {
- await httpClient.delete(`${this.baseUrl}/rooms/${roomId}/messages/${messageId}`);
- }
-}
-
-export const chatService = new ChatService();
diff --git a/client/src/services/socket.service.ts b/client/src/services/socket.service.ts
deleted file mode 100644
index def4055..0000000
--- a/client/src/services/socket.service.ts
+++ /dev/null
@@ -1,636 +0,0 @@
-import { io, Socket } from 'socket.io-client';
-import { useAuthStore } from '@/stores/authStore';
-import { config } from '@/lib/config';
-import { isTokenExpired, isValidTokenStructure, getTokenInfo } from '@/lib/token-utils';
-
-export interface MessageData {
- id: string;
- content: string;
- authorId: string;
- roomId: string;
- author: {
- id: string;
- username: string;
- avatarUrl: string;
- };
- createdAt: string;
- updatedAt: string;
-}
-
-export interface RoomData {
- id: string;
- name: string;
- avatarUrl: string;
- authorId: string;
- participants: string[];
- lastMessageId?: string;
- createdAt: string;
- updatedAt: string;
-}
-
-export interface TypingData {
- userId: string;
- username: string;
- roomId: string;
- isTyping: boolean;
-}
-
-export interface UserJoinedData {
- userId: string;
- username: string;
- roomId: string;
-}
-
-export interface UserLeftData {
- userId: string;
- username: string;
- roomId: string;
-}
-
-class SocketService {
- private socket: Socket | null = null;
- private isConnected = false;
- private reconnectAttempts = 0;
- private maxReconnectAttempts = 5;
- private reconnectDelay = 1000;
- private connectionPromise: Promise | null = null;
- private reconnectTimeout: NodeJS.Timeout | null = null;
- private connectionRefCount = 0; // Track how many components are using the connection
-
- // Event listeners
- private messageListeners: ((message: MessageData) => void)[] = [];
- private messageUpdateListeners: ((data: { messageId: string; content: string; roomId: string }) => void)[] = [];
- private messageDeleteListeners: ((data: { messageId: string; roomId: string }) => void)[] = [];
- private typingListeners: ((data: TypingData) => void)[] = [];
- private userJoinedListeners: ((data: UserJoinedData) => void)[] = [];
- private userLeftListeners: ((data: UserLeftData) => void)[] = [];
- private userOfflineInRoomListeners: ((data: { userId: string; username: string; roomId: string }) => void)[] = [];
- private roomDeletedListeners: ((data: { roomId: string; roomName: string; message: string }) => void)[] = [];
- private roomUpdatedListeners: ((data: { roomId: string; roomName: string; avatarUrl: string; updatedRoom: any; message: string }) => void)[] = [];
- private roomListUpdatedListeners: ((data: { action: string; room: any }) => void)[] = [];
- private userRemovedFromRoomListeners: ((data: { roomId: string; roomName: string; message: string }) => void)[] = [];
- private memberRemovedListeners: ((data: { roomId: string; roomName: string; removedUserId: string; removedUsername: string; message: string }) => void)[] = [];
- private connectionListeners: ((connected: boolean) => void)[] = [];
-
- connect(): Promise {
- // Increment reference count
- this.connectionRefCount++;
-
- // If already connected or connecting, return existing promise
- if (this.isConnected) {
- return Promise.resolve();
- }
-
- if (this.connectionPromise) {
- return this.connectionPromise;
- }
-
- this.connectionPromise = new Promise((resolve, reject) => {
- try {
- // Clear any existing reconnect timeout
- if (this.reconnectTimeout) {
- clearTimeout(this.reconnectTimeout);
- this.reconnectTimeout = null;
- }
-
- const authStore = useAuthStore.getState();
- const token = authStore.tokens?.accessToken;
-
- if (!token) {
- this.connectionPromise = null;
- reject(new Error('No authentication token available'));
- return;
- }
-
- // Validate token before attempting connection
- if (!isValidTokenStructure(token)) {
- console.warn('Socket: Invalid token structure, cannot connect');
- this.connectionPromise = null;
- reject(new Error('Invalid authentication token structure'));
- return;
- }
-
- // Check if token is expired
- if (isTokenExpired(token)) {
- console.warn('Socket: Token is expired, attempting refresh before connection');
- this.connectionPromise = null;
-
- // Try to refresh token and reconnect
- this.handleTokenRefreshAndReconnect()
- .then(() => resolve())
- .catch(reject);
- return;
- }
-
- // Check if token is near expiry (within 5 minutes)
- const tokenInfo = getTokenInfo(token);
- if (tokenInfo?.isNearExpiry) {
- console.log('Socket: Token near expiry, will monitor for refresh during connection');
- }
-
- // Disconnect existing socket if any
- if (this.socket) {
- this.socket.removeAllListeners();
- this.socket.disconnect();
- }
-
- // Create socket connection
- this.socket = io(config.socketUrl, {
- auth: {
- token: token
- },
- transports: ['websocket', 'polling'],
- timeout: 10000,
- forceNew: true, // Force new connection
- });
-
- // Connection event handlers
- this.socket.on('connect', () => {
- console.log('Socket connected:', this.socket?.id);
- this.isConnected = true;
- this.reconnectAttempts = 0;
- this.connectionPromise = null;
- this.notifyConnectionListeners(true);
- resolve();
- });
-
- this.socket.on('disconnect', (reason) => {
- console.log('Socket disconnected:', reason);
- this.isConnected = false;
- this.connectionPromise = null;
- this.notifyConnectionListeners(false);
-
- // Only auto-reconnect if we still have active references
- if (this.connectionRefCount > 0) {
- // Auto-reconnect for certain disconnect reasons
- if (reason === 'io server disconnect' || reason === 'io client disconnect') {
- // Server or client initiated disconnect, don't reconnect
- return;
- }
-
- this.handleReconnect();
- }
- });
-
- this.socket.on('connect_error', (error) => {
- console.error('Socket connection error:', error);
- this.isConnected = false;
- this.connectionPromise = null;
- this.notifyConnectionListeners(false);
-
- // Check if it's an authentication error
- if (this.isAuthenticationError(error)) {
- console.log('Socket: Authentication error detected, attempting token refresh');
-
- // Try to refresh token and reconnect
- this.handleTokenRefreshAndReconnect().catch((refreshError) => {
- console.error('Socket: Token refresh failed:', refreshError);
-
- // If this is the first attempt, reject the promise
- if (this.reconnectAttempts === 0) {
- reject(new Error(`Authentication failed: ${error.message}`));
- }
- });
- } else {
- // Non-authentication error
- if (this.reconnectAttempts === 0) {
- reject(error);
- }
-
- // Only reconnect if we still have active references
- if (this.connectionRefCount > 0) {
- this.handleReconnect();
- }
- }
- });
-
- // Chat event handlers
- this.socket.on('new-message', (data: { message: MessageData; roomId: string }) => {
- this.notifyMessageListeners(data.message);
- });
-
- this.socket.on('message-updated', (data: { messageId: string; content: string; roomId: string }) => {
- this.notifyMessageUpdateListeners(data);
- });
-
- this.socket.on('message-deleted', (data: { messageId: string; roomId: string }) => {
- this.notifyMessageDeleteListeners(data);
- });
-
- this.socket.on('user-typing', (data: TypingData) => {
- this.notifyTypingListeners(data);
- });
-
- this.socket.on('user-joined', (data: UserJoinedData) => {
- this.notifyUserJoinedListeners(data);
- });
-
- this.socket.on('user-left', (data: UserLeftData) => {
- this.notifyUserLeftListeners(data);
- });
-
- this.socket.on('user-offline-in-room', (data: { userId: string; username: string; roomId: string }) => {
- this.notifyUserOfflineInRoomListeners(data);
- });
-
- this.socket.on('room-deleted', (data: { roomId: string; roomName: string; message: string }) => {
- this.notifyRoomDeletedListeners(data);
- });
-
- this.socket.on('room-updated', (data: { roomId: string; roomName: string; avatarUrl: string; updatedRoom: any; message: string }) => {
- this.notifyRoomUpdatedListeners(data);
- });
-
- this.socket.on('room-list-updated', (data: { action: string; room: any }) => {
- this.notifyRoomListUpdatedListeners(data);
- });
-
- this.socket.on('user-removed-from-room', (data: { roomId: string; roomName: string; message: string }) => {
- this.notifyUserRemovedFromRoomListeners(data);
- });
-
- this.socket.on('member-removed', (data: { roomId: string; roomName: string; removedUserId: string; removedUsername: string; message: string }) => {
- this.notifyMemberRemovedListeners(data);
- });
-
- this.socket.on('error', (error: any) => {
- console.error('Socket error:', error);
- });
-
- } catch (error) {
- this.connectionPromise = null;
- reject(error);
- }
- });
-
- return this.connectionPromise;
- }
-
- disconnect(): void {
- // Decrement reference count
- this.connectionRefCount = Math.max(0, this.connectionRefCount - 1);
-
- // Only disconnect if no more references
- if (this.connectionRefCount === 0) {
- this.forceDisconnect();
- }
- }
-
- private forceDisconnect(): void {
- // Clear any pending reconnect timeout
- if (this.reconnectTimeout) {
- clearTimeout(this.reconnectTimeout);
- this.reconnectTimeout = null;
- }
-
- if (this.socket) {
- this.socket.removeAllListeners();
- this.socket.disconnect();
- this.socket = null;
- }
-
- this.isConnected = false;
- this.connectionPromise = null;
- this.reconnectAttempts = 0;
- this.notifyConnectionListeners(false);
- }
-
- private handleReconnect(): void {
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
- console.error('Max reconnection attempts reached');
- return;
- }
-
- // Don't reconnect if no active references
- if (this.connectionRefCount === 0) {
- return;
- }
-
- this.reconnectAttempts++;
- const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
-
- console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
-
- this.reconnectTimeout = setTimeout(() => {
- if (!this.isConnected && this.connectionRefCount > 0) {
- // Reset connection promise to allow new connection attempt
- this.connectionPromise = null;
- this.connect().catch(console.error);
- }
- }, delay);
- }
-
- private async handleTokenRefreshAndReconnect(): Promise {
- try {
- console.log('Socket: Attempting token refresh for reconnection...');
-
- const authStore = useAuthStore.getState();
- const refreshToken = authStore.tokens?.refreshToken;
-
- if (!refreshToken) {
- throw new Error('No refresh token available');
- }
-
- // Validate refresh token before using it
- if (!isValidTokenStructure(refreshToken)) {
- throw new Error('Invalid refresh token structure');
- }
-
- if (isTokenExpired(refreshToken)) {
- throw new Error('Refresh token is expired');
- }
-
- // Use httpClient's refresh mechanism for consistency
- try {
- const { httpClient } = await import('@/lib/http-client');
- await httpClient.forceRefresh();
- console.log('Socket: Token refresh successful via httpClient');
- } catch (httpError) {
- console.warn('Socket: httpClient refresh failed, trying direct API call:', httpError);
-
- // Fallback to direct API call
- const response = await fetch(`${config.socketUrl}/api/auth/refresh`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ refreshToken }),
- });
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(`Token refresh failed: ${response.status} - ${errorData.message || response.statusText}`);
- }
-
- const data = await response.json();
-
- if (!data.success || !data.data?.tokens) {
- throw new Error('Invalid refresh token response format');
- }
-
- // Validate new tokens
- const { accessToken, refreshToken: newRefreshToken } = data.data.tokens;
- if (!isValidTokenStructure(accessToken) || !isValidTokenStructure(newRefreshToken)) {
- throw new Error('Received invalid token structure from server');
- }
-
- // Update tokens in auth store
- authStore.setTokens(data.data.tokens);
- console.log('Socket: Token refresh successful via direct API');
- }
-
- // Reset connection state and try to reconnect
- this.connectionPromise = null;
- this.reconnectAttempts = 0;
-
- if (this.connectionRefCount > 0) {
- console.log('Socket: Attempting reconnection with new token...');
- await this.connect();
- }
-
- } catch (error) {
- console.error('Socket: Token refresh failed:', error);
-
- // If token refresh fails, logout user
- const authStore = useAuthStore.getState();
- authStore.logout();
-
- // Clear connection state
- this.connectionPromise = null;
- this.isConnected = false;
-
- // Don't try normal reconnect after auth failure
- throw error;
- }
- }
-
- /**
- * Check if an error is related to authentication/token issues
- */
- private isAuthenticationError(error: any): boolean {
- if (!error || !error.message) {
- return false;
- }
-
- const message = error.message.toLowerCase();
- const authErrorKeywords = [
- 'authentication',
- 'token',
- 'unauthorized',
- 'expired',
- 'invalid',
- 'jwt',
- 'auth',
- 'forbidden'
- ];
-
- return authErrorKeywords.some(keyword => message.includes(keyword));
- }
-
- // Room operations
- joinRoom(roomId: string): void {
- if (this.socket && this.isConnected) {
- const authStore = useAuthStore.getState();
- this.socket.emit('join-room', {
- roomId,
- userId: authStore.user?.id
- });
- }
- }
-
- leaveRoom(roomId: string): void {
- if (this.socket && this.isConnected) {
- const authStore = useAuthStore.getState();
- this.socket.emit('leave-room', {
- roomId,
- userId: authStore.user?.id
- });
- }
- }
-
- // Message operations
- sendMessage(roomId: string, content: string): void {
- if (this.socket && this.isConnected) {
- this.socket.emit('send-message', {
- roomId,
- content
- });
- }
- }
-
- // Typing indicators
- sendTyping(roomId: string, isTyping: boolean): void {
- if (this.socket && this.isConnected) {
- const authStore = useAuthStore.getState();
- this.socket.emit('typing', {
- roomId,
- userId: authStore.user?.id,
- username: authStore.user?.name, // ✅ Correct: use name (mapped from backend username)
- isTyping
- });
- }
- }
-
- // Event listener management
- onNewMessage(callback: (message: MessageData) => void): () => void {
- this.messageListeners.push(callback);
- return () => {
- this.messageListeners = this.messageListeners.filter(cb => cb !== callback);
- };
- }
-
- onMessageUpdate(callback: (data: { messageId: string; content: string; roomId: string }) => void): () => void {
- this.messageUpdateListeners.push(callback);
- return () => {
- this.messageUpdateListeners = this.messageUpdateListeners.filter(cb => cb !== callback);
- };
- }
-
- onMessageDelete(callback: (data: { messageId: string; roomId: string }) => void): () => void {
- this.messageDeleteListeners.push(callback);
- return () => {
- this.messageDeleteListeners = this.messageDeleteListeners.filter(cb => cb !== callback);
- };
- }
-
- onTyping(callback: (data: TypingData) => void): () => void {
- this.typingListeners.push(callback);
- return () => {
- this.typingListeners = this.typingListeners.filter(cb => cb !== callback);
- };
- }
-
- onUserJoined(callback: (data: UserJoinedData) => void): () => void {
- this.userJoinedListeners.push(callback);
- return () => {
- this.userJoinedListeners = this.userJoinedListeners.filter(cb => cb !== callback);
- };
- }
-
- onUserLeft(callback: (data: UserLeftData) => void): () => void {
- this.userLeftListeners.push(callback);
- return () => {
- this.userLeftListeners = this.userLeftListeners.filter(cb => cb !== callback);
- };
- }
-
- onUserOfflineInRoom(callback: (data: { userId: string; username: string; roomId: string }) => void): () => void {
- this.userOfflineInRoomListeners.push(callback);
- return () => {
- this.userOfflineInRoomListeners = this.userOfflineInRoomListeners.filter(cb => cb !== callback);
- };
- }
-
- onRoomDeleted(callback: (data: { roomId: string; roomName: string; message: string }) => void): () => void {
- this.roomDeletedListeners.push(callback);
- return () => {
- this.roomDeletedListeners = this.roomDeletedListeners.filter(cb => cb !== callback);
- };
- }
-
- onRoomUpdated(callback: (data: { roomId: string; roomName: string; avatarUrl: string; updatedRoom: any; message: string }) => void): () => void {
- this.roomUpdatedListeners.push(callback);
- return () => {
- this.roomUpdatedListeners = this.roomUpdatedListeners.filter(cb => cb !== callback);
- };
- }
-
- onRoomListUpdated(callback: (data: { action: string; room: any }) => void): () => void {
- this.roomListUpdatedListeners.push(callback);
- return () => {
- this.roomListUpdatedListeners = this.roomListUpdatedListeners.filter(cb => cb !== callback);
- };
- }
-
- onUserRemovedFromRoom(callback: (data: { roomId: string; roomName: string; message: string }) => void): () => void {
- this.userRemovedFromRoomListeners.push(callback);
- return () => {
- this.userRemovedFromRoomListeners = this.userRemovedFromRoomListeners.filter(cb => cb !== callback);
- };
- }
-
- onMemberRemoved(callback: (data: { roomId: string; roomName: string; removedUserId: string; removedUsername: string; message: string }) => void): () => void {
- this.memberRemovedListeners.push(callback);
- return () => {
- this.memberRemovedListeners = this.memberRemovedListeners.filter(cb => cb !== callback);
- };
- }
-
- onConnectionChange(callback: (connected: boolean) => void): () => void {
- this.connectionListeners.push(callback);
- return () => {
- this.connectionListeners = this.connectionListeners.filter(cb => cb !== callback);
- };
- }
-
- // Notification methods
- private notifyMessageListeners(message: MessageData): void {
- this.messageListeners.forEach(callback => callback(message));
- }
-
- private notifyMessageUpdateListeners(data: { messageId: string; content: string; roomId: string }): void {
- this.messageUpdateListeners.forEach(callback => callback(data));
- }
-
- private notifyMessageDeleteListeners(data: { messageId: string; roomId: string }): void {
- this.messageDeleteListeners.forEach(callback => callback(data));
- }
-
- private notifyTypingListeners(data: TypingData): void {
- this.typingListeners.forEach(callback => callback(data));
- }
-
- private notifyUserJoinedListeners(data: UserJoinedData): void {
- this.userJoinedListeners.forEach(callback => callback(data));
- }
-
- private notifyUserLeftListeners(data: UserLeftData): void {
- this.userLeftListeners.forEach(callback => callback(data));
- }
-
- private notifyUserOfflineInRoomListeners(data: { userId: string; username: string; roomId: string }): void {
- this.userOfflineInRoomListeners.forEach(callback => callback(data));
- }
-
- private notifyRoomDeletedListeners(data: { roomId: string; roomName: string; message: string }): void {
- this.roomDeletedListeners.forEach(callback => callback(data));
- }
-
- private notifyRoomUpdatedListeners(data: { roomId: string; roomName: string; avatarUrl: string; updatedRoom: any; message: string }): void {
- this.roomUpdatedListeners.forEach(callback => callback(data));
- }
-
- private notifyRoomListUpdatedListeners(data: { action: string; room: any }): void {
- this.roomListUpdatedListeners.forEach(callback => callback(data));
- }
-
- private notifyUserRemovedFromRoomListeners(data: { roomId: string; roomName: string; message: string }): void {
- this.userRemovedFromRoomListeners.forEach(callback => callback(data));
- }
-
- private notifyMemberRemovedListeners(data: { roomId: string; roomName: string; removedUserId: string; removedUsername: string; message: string }): void {
- this.memberRemovedListeners.forEach(callback => callback(data));
- }
-
- private notifyConnectionListeners(connected: boolean): void {
- this.connectionListeners.forEach(callback => callback(connected));
- }
-
- // Reset method for testing
- reset(): void {
- this.connectionRefCount = 0;
- this.forceDisconnect();
- }
-
- // Getters
- get connected(): boolean {
- return this.isConnected;
- }
-
- get socketId(): string | undefined {
- return this.socket?.id;
- }
-}
-
-// Export singleton instance
-export const socketService = new SocketService();
diff --git a/client/src/services/upload.service.ts b/client/src/services/upload.service.ts
deleted file mode 100644
index 528a9f5..0000000
--- a/client/src/services/upload.service.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { httpClient } from '@/lib/http-client'
-
-export interface UploadResponse {
- file: {
- filename: string
- originalName: string
- mimetype: string
- size: number
- url: string
- }
- avatarUrl?: string
-}
-
-export class UploadService {
- async uploadFile(file: File): Promise {
- // Validate file type
- if (!file.type.startsWith('image/')) {
- throw new Error('Only image files are allowed')
- }
-
- // Validate file size (5MB max)
- if (file.size > 5 * 1024 * 1024) {
- throw new Error('File size must be less than 5MB')
- }
-
- const formData = new FormData()
- formData.append('file', file)
-
- try {
- const response = await httpClient.post('/upload/image', formData)
-
- if (response.success && response.data) {
- return response.data.file.url
- }
-
- throw new Error(response.message || 'Upload failed')
- } catch (error: any) {
- throw new Error(error.message || 'Upload failed')
- }
- }
-
- async uploadAvatar(file: File): Promise {
- // Validate file type
- if (!file.type.startsWith('image/')) {
- throw new Error('Only image files are allowed for avatars')
- }
-
- // Validate file size (2MB max for avatars)
- if (file.size > 2 * 1024 * 1024) {
- throw new Error('Avatar file size must be less than 2MB')
- }
-
- const formData = new FormData()
- formData.append('file', file)
-
- try {
- const response = await httpClient.post('/upload/avatar', formData)
-
- if (response.success && response.data) {
- return response.data.avatarUrl || response.data.file.url
- }
-
- throw new Error(response.message || 'Avatar upload failed')
- } catch (error: any) {
- throw new Error(error.message || 'Avatar upload failed')
- }
- }
-}
-
-export const uploadService = new UploadService()
diff --git a/client/src/stores/chatStore.ts b/client/src/stores/chatStore.ts
deleted file mode 100644
index e7f44a3..0000000
--- a/client/src/stores/chatStore.ts
+++ /dev/null
@@ -1,364 +0,0 @@
-import { create } from 'zustand';
-import { devtools } from 'zustand/middleware';
-import { socketService, MessageData, RoomData } from '@/services/socket.service';
-
-export interface ChatMessage extends MessageData {
- error?: string; // For failed messages
-}
-
-export interface ChatRoom extends RoomData {
- unreadCount?: number;
- lastActivity?: string;
-}
-
-export interface TypingUser {
- userId: string;
- username: string;
- roomId: string;
-}
-
-interface ChatState {
- // Current state
- currentRoomId: string | null;
- isConnected: boolean;
-
- // Rooms
- rooms: ChatRoom[];
-
- // Messages (keyed by roomId)
- messagesByRoom: Record;
-
- // Typing indicators (keyed by roomId)
- typingUsersByRoom: Record;
-
- // UI state
- isTyping: Record; // keyed by roomId
- typingTimeouts: Record; // keyed by roomId
-
- // Actions
- setCurrentRoom: (roomId: string | null) => void;
- setConnected: (connected: boolean) => void;
-
- // Room actions
- setRooms: (rooms: ChatRoom[]) => void;
- addRoom: (room: ChatRoom) => void;
- updateRoom: (roomId: string, updates: Partial) => void;
- removeRoom: (roomId: string) => void;
-
- // Message actions
- setMessages: (roomId: string, messages: ChatMessage[]) => void;
- addMessage: (message: ChatMessage) => void;
-
- updateMessage: (messageId: string, updates: Partial) => void;
- removeMessage: (messageId: string) => void;
- markMessageError: (tempId: string, error: string) => void;
-
- // Typing actions
- setTyping: (roomId: string, isTyping: boolean) => void;
- addTypingUser: (user: TypingUser) => void;
- removeTypingUser: (userId: string, roomId: string) => void;
- clearTypingUsers: (roomId: string) => void;
-
- // Utility actions
- incrementUnreadCount: (roomId: string) => void;
- clearUnreadCount: (roomId: string) => void;
- updateLastActivity: (roomId: string) => void;
-
- // Socket actions
- sendMessage: (roomId: string, content: string) => void;
- joinRoom: (roomId: string) => void;
- leaveRoom: (roomId: string) => void;
-
- // Cleanup
- cleanup: () => void;
-}
-
-export const useChatStore = create()(
- devtools(
- (set, get) => ({
- // Initial state
- currentRoomId: null,
- isConnected: false,
- rooms: [],
- messagesByRoom: {},
- typingUsersByRoom: {},
- isTyping: {},
- typingTimeouts: {},
-
- // Basic setters
- setCurrentRoom: (roomId) => {
- set({ currentRoomId: roomId });
-
- // Clear unread count for current room
- if (roomId) {
- get().clearUnreadCount(roomId);
- }
- },
-
- setConnected: (connected) => {
- set({ isConnected: connected });
- },
-
- // Room actions
- setRooms: (rooms) => {
- set({ rooms });
- },
-
- addRoom: (room) => {
- set((state) => ({
- rooms: [room, ...state.rooms.filter(r => r.id !== room.id)]
- }));
- },
-
- updateRoom: (roomId, updates) => {
- set((state) => ({
- rooms: state.rooms.map(room =>
- room.id === roomId ? { ...room, ...updates } : room
- )
- }));
- },
-
- removeRoom: (roomId) => {
- set((state) => {
- const newMessagesByRoom = { ...state.messagesByRoom };
- delete newMessagesByRoom[roomId];
-
- const newTypingUsersByRoom = { ...state.typingUsersByRoom };
- delete newTypingUsersByRoom[roomId];
-
- return {
- rooms: state.rooms.filter(room => room.id !== roomId),
- messagesByRoom: newMessagesByRoom,
- typingUsersByRoom: newTypingUsersByRoom,
- currentRoomId: state.currentRoomId === roomId ? null : state.currentRoomId
- };
- });
- },
-
- // Message actions
- setMessages: (roomId, messages) => {
- set((state) => ({
- messagesByRoom: {
- ...state.messagesByRoom,
- [roomId]: messages
- }
- }));
- },
-
- addMessage: (message) => {
- set((state) => {
- const roomMessages = state.messagesByRoom[message.roomId] || [];
-
- // Check if this exact message already exists (by ID)
- const existingMessage = roomMessages.find(msg => msg.id === message.id);
- if (existingMessage) {
- return state; // Message already exists, don't add
- }
-
- // Simple add - no optimistic logic needed for real-time socket
- return {
- messagesByRoom: {
- ...state.messagesByRoom,
- [message.roomId]: [...roomMessages, message]
- }
- };
- });
-
- // Update room's last activity and increment unread if not current room
- const { currentRoomId, updateLastActivity, incrementUnreadCount } = get();
- updateLastActivity(message.roomId);
-
- if (currentRoomId !== message.roomId) {
- incrementUnreadCount(message.roomId);
- }
- },
-
-
-
- updateMessage: (messageId, updates) => {
- set((state) => {
- const newMessagesByRoom = { ...state.messagesByRoom };
-
- Object.keys(newMessagesByRoom).forEach(roomId => {
- newMessagesByRoom[roomId] = newMessagesByRoom[roomId].map(msg =>
- msg.id === messageId ? { ...msg, ...updates } : msg
- );
- });
-
- return { messagesByRoom: newMessagesByRoom };
- });
- },
-
-
-
- removeMessage: (messageId) => {
- set((state) => {
- const newMessagesByRoom = { ...state.messagesByRoom };
-
- Object.keys(newMessagesByRoom).forEach(roomId => {
- newMessagesByRoom[roomId] = newMessagesByRoom[roomId].filter(msg => msg.id !== messageId);
- });
-
- return { messagesByRoom: newMessagesByRoom };
- });
- },
-
- markMessageError: (tempId, error) => {
- get().updateMessage(tempId, { error });
- },
-
- // Typing actions
- setTyping: (roomId, isTyping) => {
- const state = get();
-
- // Clear existing timeout
- if (state.typingTimeouts[roomId]) {
- clearTimeout(state.typingTimeouts[roomId]);
- }
-
- // Send typing indicator
- socketService.sendTyping(roomId, isTyping);
-
- if (isTyping) {
- // Set timeout to stop typing after 3 seconds
- const timeout = window.setTimeout(() => {
- get().setTyping(roomId, false);
- }, 3000);
-
- set((state) => ({
- isTyping: { ...state.isTyping, [roomId]: true },
- typingTimeouts: { ...state.typingTimeouts, [roomId]: timeout }
- }));
- } else {
- set((state) => {
- const newIsTyping = { ...state.isTyping };
- delete newIsTyping[roomId];
-
- const newTimeouts = { ...state.typingTimeouts };
- delete newTimeouts[roomId];
-
- return {
- isTyping: newIsTyping,
- typingTimeouts: newTimeouts
- };
- });
- }
- },
-
- addTypingUser: (user) => {
- set((state) => ({
- typingUsersByRoom: {
- ...state.typingUsersByRoom,
- [user.roomId]: [
- ...(state.typingUsersByRoom[user.roomId] || []).filter(u => u.userId !== user.userId),
- user
- ]
- }
- }));
- },
-
- removeTypingUser: (userId, roomId) => {
- set((state) => ({
- typingUsersByRoom: {
- ...state.typingUsersByRoom,
- [roomId]: (state.typingUsersByRoom[roomId] || []).filter(u => u.userId !== userId)
- }
- }));
- },
-
- clearTypingUsers: (roomId) => {
- set((state) => ({
- typingUsersByRoom: {
- ...state.typingUsersByRoom,
- [roomId]: []
- }
- }));
- },
-
- // Utility actions
- incrementUnreadCount: (roomId) => {
- set((state) => ({
- rooms: state.rooms.map(room =>
- room.id === roomId
- ? { ...room, unreadCount: (room.unreadCount || 0) + 1 }
- : room
- )
- }));
- },
-
- clearUnreadCount: (roomId) => {
- set((state) => ({
- rooms: state.rooms.map(room =>
- room.id === roomId
- ? { ...room, unreadCount: 0 }
- : room
- )
- }));
- },
-
- updateLastActivity: (roomId) => {
- const now = new Date().toISOString();
- set((state) => ({
- rooms: state.rooms.map(room =>
- room.id === roomId
- ? { ...room, lastActivity: now }
- : room
- )
- }));
- },
-
- // Socket actions
- sendMessage: (roomId, content) => {
- const trimmedContent = content.trim();
- if (!trimmedContent) return; // Don't send empty messages
-
- // Simple spam protection - check last message time
- const state = get();
- const roomMessages = state.messagesByRoom[roomId] || [];
- const lastMessage = roomMessages[roomMessages.length - 1];
-
- if (lastMessage &&
- Math.abs(new Date().getTime() - new Date(lastMessage.createdAt).getTime()) < 500) {
- return; // Don't send if last message was sent less than 500ms ago
- }
-
- // Send via socket - backend will broadcast to everyone including sender
- socketService.sendMessage(roomId, trimmedContent);
-
- // Update last activity
- get().updateLastActivity(roomId);
- },
-
- joinRoom: (roomId) => {
- socketService.joinRoom(roomId);
- },
-
- leaveRoom: (roomId) => {
- socketService.leaveRoom(roomId);
- },
-
- // Cleanup
- cleanup: () => {
- const state = get();
-
- // Clear all timeouts
- Object.values(state.typingTimeouts).forEach((timeout: number) => {
- clearTimeout(timeout);
- });
-
- set({
- currentRoomId: null,
- isConnected: false,
- rooms: [],
- messagesByRoom: {},
- typingUsersByRoom: {},
- isTyping: {},
- typingTimeouts: {}
- });
- }
- }),
- {
- name: 'chat-store'
- }
- )
-);