diff --git a/app/protected/messages/page.tsx b/app/protected/messages/page.tsx new file mode 100644 index 00000000..3ddf9d8e --- /dev/null +++ b/app/protected/messages/page.tsx @@ -0,0 +1,119 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import { ConversationList } from '@/components/messages/ConversationList' +import { ConversationView } from '@/components/messages/ConversationView' +import { NewMessageDialog } from '@/components/messages/NewMessageDialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useConversations } from '@/hooks/useConversations' +import { Plus, Search, MessageSquare } from 'lucide-react' + +export default function MessagesPage() { + const searchParams = useSearchParams() + const { conversations, loading } = useConversations() + const [selectedConversationId, setSelectedConversationId] = useState(null) + const [showNewMessage, setShowNewMessage] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + // Get conversation from URL params + useEffect(() => { + const conversationId = searchParams.get('conversation') + if (conversationId) { + setSelectedConversationId(conversationId) + } + }, [searchParams]) + + // Filter conversations based on search + const filteredConversations = conversations.filter((conv) => { + if (!searchQuery.trim()) return true + + const query = searchQuery.toLowerCase() + const name = conv.is_group + ? conv.group_name || '' + : conv.other_user + ? `${conv.other_user.first_name || ''} ${conv.other_user.last_name || ''} ${conv.other_user.username || ''}` + : '' + + return name.toLowerCase().includes(query) || + conv.last_message_content?.toLowerCase().includes(query) + }) + + const selectedConversation = conversations.find(c => c.id === selectedConversationId) + const conversationName = selectedConversation?.is_group + ? selectedConversation.group_name || 'Group Chat' + : selectedConversation?.other_user + ? `${selectedConversation.other_user.first_name || ''} ${selectedConversation.other_user.last_name || ''}`.trim() || selectedConversation.other_user.username + : 'Unknown' + + return ( +
+ {/* Header */} +
+
+
+ +

Messages

+
+ +
+
+ + {/* Main Content */} +
+
+ {/* Sidebar - Conversation List */} +
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ + {/* Conversation List */} +
+ +
+
+ + {/* Main Area - Conversation View */} +
+ {selectedConversationId && ( +
+

{conversationName}

+
+ )} +
+ +
+
+
+
+ + {/* New Message Dialog */} + +
+ ) +} diff --git a/components/messages/ConversationList.tsx b/components/messages/ConversationList.tsx new file mode 100644 index 00000000..e1e3fa6b --- /dev/null +++ b/components/messages/ConversationList.tsx @@ -0,0 +1,117 @@ +'use client' + +import React from 'react' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { formatDistanceToNow } from 'date-fns' +import type { ConversationWithDetails } from '@/types/messaging' +import { cn } from '@/lib/utils' +import { MessageCircle } from 'lucide-react' + +interface ConversationListProps { + conversations: ConversationWithDetails[] + selectedId: string | null + onSelect: (id: string) => void + loading?: boolean +} + +export function ConversationList({ conversations, selectedId, onSelect, loading }: ConversationListProps) { + if (loading) { + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ +
+ + +
+
+ ))} +
+ ) + } + + if (conversations.length === 0) { + return ( +
+ +

No conversations yet

+

+ Start a new conversation to get started +

+
+ ) + } + + return ( +
+ {conversations.map((conversation) => { + const otherUser = conversation.other_user + const name = conversation.is_group + ? conversation.group_name || 'Group Chat' + : otherUser + ? `${otherUser.first_name || ''} ${otherUser.last_name || ''}`.trim() || otherUser.username + : 'Unknown User' + + const initials = conversation.is_group + ? 'GC' + : otherUser + ? `${otherUser.first_name?.[0] || ''}${otherUser.last_name?.[0] || ''}`.toUpperCase() || 'U' + : 'U' + + const avatarUrl = conversation.is_group + ? conversation.group_avatar_url + : otherUser?.avatar_url + + const isSelected = conversation.id === selectedId + + return ( + + ) + })} +
+ ) +} diff --git a/components/messages/ConversationView.tsx b/components/messages/ConversationView.tsx new file mode 100644 index 00000000..2dc2edb4 --- /dev/null +++ b/components/messages/ConversationView.tsx @@ -0,0 +1,92 @@ +'use client' + +import React, { useEffect, useRef } from 'react' +import { MessageBubble } from './MessageBubble' +import { MessageInput } from './MessageInput' +import { Skeleton } from '@/components/ui/skeleton' +import { useMessages } from '@/hooks/useMessages' +import { useAuth } from '@/lib/hooks/useAuth' +import { MessageSquare } from 'lucide-react' + +interface ConversationViewProps { + conversationId: string | null + conversationName?: string +} + +export function ConversationView({ conversationId }: ConversationViewProps) { + const { user } = useAuth() + const { messages, loading, sending, sendMessage } = useMessages(conversationId) + const messagesEndRef = useRef(null) + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + if (!conversationId) { + return ( +
+ +

Select a conversation

+

+ Choose a conversation from the list to start messaging +

+
+ ) + } + + if (loading) { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ) + } + + return ( +
+ {/* Messages Area */} +
+ {messages.length === 0 ? ( +
+ +

No messages yet

+

+ Send a message to start the conversation +

+
+ ) : ( + <> + {messages.map((message) => ( + + ))} +
+ + )} +
+ + {/* Message Input */} +
+ +
+
+ ) +} + +function cn(...classes: (string | boolean | undefined)[]) { + return classes.filter(Boolean).join(' ') +} diff --git a/components/messages/MessageBubble.tsx b/components/messages/MessageBubble.tsx new file mode 100644 index 00000000..3c9b159f --- /dev/null +++ b/components/messages/MessageBubble.tsx @@ -0,0 +1,68 @@ +'use client' + +import React from 'react' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { formatDistanceToNow } from 'date-fns' +import type { Message } from '@/types/messaging' +import { cn } from '@/lib/utils' + +interface MessageBubbleProps { + message: Message + isOwn: boolean +} + +export function MessageBubble({ message, isOwn }: MessageBubbleProps) { + const getInitials = () => { + if (!message.sender) return 'U' + const first = message.sender.first_name?.[0] || '' + const last = message.sender.last_name?.[0] || '' + return (first + last).toUpperCase() || 'U' + } + + const getName = () => { + if (!message.sender) return 'Unknown' + return `${message.sender.first_name || ''} ${message.sender.last_name || ''}`.trim() || message.sender.username || 'Unknown' + } + + return ( +
+ {!isOwn && ( + + {message.sender?.avatar_url && ( + + )} + + {getInitials()} + + + )} + +
+ {!isOwn && ( + + {getName()} + + )} + +
+

{message.content}

+ {message.is_edited && !message.is_deleted && ( + (edited) + )} +
+ + + {formatDistanceToNow(new Date(message.created_at), { addSuffix: true })} + +
+
+ ) +} diff --git a/components/messages/MessageInput.tsx b/components/messages/MessageInput.tsx new file mode 100644 index 00000000..e591e198 --- /dev/null +++ b/components/messages/MessageInput.tsx @@ -0,0 +1,86 @@ +'use client' + +import React, { useState, useRef, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Send, Loader2 } from 'lucide-react' + +interface MessageInputProps { + onSend: (content: string) => Promise + disabled?: boolean + placeholder?: string +} + +export function MessageInput({ onSend, disabled, placeholder = 'Type a message...' }: MessageInputProps) { + const [content, setContent] = useState('') + const [sending, setSending] = useState(false) + const textareaRef = useRef(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!content.trim() || sending || disabled) return + + try { + setSending(true) + await onSend(content) + setContent('') + + // Reset textarea height + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + } + } catch (error) { + console.error('Error sending message:', error) + } finally { + setSending(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit(e) + } + } + + // Auto-resize textarea + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px` + } + }, [content]) + + return ( +
+
+