diff --git a/client/src/components/ConnectionStatus.tsx b/client/src/components/ConnectionStatus.tsx deleted file mode 100644 index cc99550..0000000 --- a/client/src/components/ConnectionStatus.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Wifi, WifiOff, AlertCircle } from 'lucide-react'; -import { useSocket } from '@/providers/SocketProvider'; -import { Alert, AlertDescription } from '@/components/ui/alert'; - -export function ConnectionStatus() { - const { connected, connectionError } = useSocket(); - - // Don't show anything if connected and no errors - if (connected && !connectionError) { - return null; - } - - return ( -
- {connectionError ? ( - - - - Connection Error: {connectionError} - - - ) : !connected ? ( - - - - Connecting to chat server... - - - ) : null} -
- ); -} - -// Inline connection indicator for chat components -export function InlineConnectionStatus() { - const { connected, connectionError } = useSocket(); - - if (connected && !connectionError) { - return ( -
- - Connected -
- ); - } - - if (connectionError) { - return ( -
- - Connection Error -
- ); - } - - return ( -
- - Connecting... -
- ); -} diff --git a/client/src/components/Navigation.tsx b/client/src/components/Navigation.tsx index 57307ef..64f3470 100644 --- a/client/src/components/Navigation.tsx +++ b/client/src/components/Navigation.tsx @@ -1,186 +1,40 @@ -import React from 'react' -import { Link, useLocation } from '@tanstack/react-router' +import { Link, useNavigate } from '@tanstack/react-router' import { Button } from '@/components/ui/button' -import { ArrowLeft, Home, User, LogOut } from 'lucide-react' +import { CheckCircle, LogOut, User } from 'lucide-react' import { useAuthStore } from '@/stores/authStore' -import { useLogout } from '@/hooks/useAuth' interface NavigationProps { - showBackButton?: boolean title?: string - transparent?: boolean } -export function Navigation({ showBackButton = false, title, transparent = false }: NavigationProps) { - const location = useLocation() - const { isAuthenticated, user } = useAuthStore() - const logoutMutation = useLogout() - - const isActive = (path: string) => { - return location.pathname === path - } +export function Navigation({ title = 'TanStack Todo' }: NavigationProps) { + const { user, logout } = useAuthStore() + const navigate = useNavigate() const handleLogout = () => { - logoutMutation.mutate() + logout() + navigate({ to: '/login' }) } return ( - ) 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 ( -
-
Loading room...
-
- ); - } - - if (!room) { - return ( -
-
Room not found
-
- ); - } - - 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 ( -
-
-
-

Loading rooms...

-
-
- ); - } - - 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 ( - - - - Create New Room - - Create a new chat room to start conversations with your team. - - - -
- - ( - - Room Name - - - - - Choose a descriptive name for your room. - - - - )} - /> - - ( - - Avatar URL (Optional) - - - - - Provide a URL for the room's avatar image. - - - - )} - /> - - - - - - - -
-
- ); -} 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 ( - - - {trigger || ( - - )} - - - - - - Edit Room Information - - - Update the room name and avatar URL. - - - -
-
- - handleInputChange('name', e.target.value)} - placeholder="Enter room name" - maxLength={100} - required - /> -

- {formData.name.length}/100 characters -

-
- -
- - handleInputChange('avatarUrl', e.target.value)} - placeholder="https://example.com/avatar.jpg" - /> -

- Provide a URL for the room's avatar image -

-
- - {/* Preview */} - {formData.avatarUrl && ( -
- -
- Room avatar preview { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> -
-

{formData.name}

-

Room preview

-
-
-
- )} - -
- - -
-
-
-
- ); -} 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 ( - - - - - - Invite Users to {roomName} - - - Search and select users to invite to this chat room. - - - -
- {/* Search Input */} -
- -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
-
- - {/* Selected Users Count */} - {selectedUserIds.length > 0 && ( -
- {selectedUserIds.length} user(s) selected -
- )} - - {/* Users List */} -
- -
- {isSearching ? ( -
- - Searching users... -
- ) : users.length === 0 ? ( -
- {searchQuery ? 'No users found' : 'No users available'} -
- ) : ( -
- {users.map((user) => ( -
- - handleUserToggle(user.id, checked as boolean) - } - /> - - - - {user.username.charAt(0).toUpperCase()} - - -
-

- {user.username} -

-

- {user.email} -

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