Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions app/protected/messages/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex flex-col h-[calc(100vh-4rem)]">
{/* Header */}
<div className="border-b bg-background p-4">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<MessageSquare className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">Messages</h1>
</div>
<Button onClick={() => setShowNewMessage(true)} className="gap-2">
<Plus className="h-4 w-4" />
New Message
</Button>
</div>
</div>

{/* Main Content */}
<div className="flex-1 overflow-hidden">
<div className="max-w-7xl mx-auto h-full flex">
{/* Sidebar - Conversation List */}
<div className="w-80 border-r flex flex-col bg-background">
{/* Search */}
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
</div>

{/* Conversation List */}
<div className="flex-1 overflow-y-auto">
<ConversationList
conversations={filteredConversations}
selectedId={selectedConversationId}
onSelect={setSelectedConversationId}
loading={loading}
/>
</div>
</div>

{/* Main Area - Conversation View */}
<div className="flex-1 bg-background flex flex-col">
{selectedConversationId && (
<div className="border-b p-4 bg-muted/50 flex-shrink-0">
<h2 className="font-semibold">{conversationName}</h2>
</div>
)}
<div className="flex-1 min-h-0">
<ConversationView
conversationId={selectedConversationId}
conversationName={conversationName}
/>
</div>
</div>
</div>
</div>

{/* New Message Dialog */}
<NewMessageDialog
open={showNewMessage}
onOpenChange={setShowNewMessage}
/>
</div>
)
}
117 changes: 117 additions & 0 deletions components/messages/ConversationList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2 p-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-3 p-3">
<Skeleton className="w-12 h-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
))}
</div>
)
}

if (conversations.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<MessageCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No conversations yet</h3>
<p className="text-sm text-muted-foreground">
Start a new conversation to get started
</p>
</div>
)
}

return (
<div className="space-y-1 p-2">
{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 (
<button
key={conversation.id}
onClick={() => onSelect(conversation.id)}
className={cn(
'w-full flex items-center gap-3 p-3 rounded-lg transition-colors text-left',
'hover:bg-muted',
isSelected && 'bg-muted'
)}
>
<Avatar className="w-12 h-12 flex-shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white">
{initials}
</AvatarFallback>
</Avatar>

<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className={cn('font-semibold truncate', conversation.unread_count > 0 && 'text-primary')}>
{name}
</span>
{conversation.last_message_at && (
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistanceToNow(new Date(conversation.last_message_at), { addSuffix: true })}
</span>
)}
</div>
<div className="flex items-center justify-between">
<p className={cn(
'text-sm truncate',
conversation.unread_count > 0 ? 'font-medium text-foreground' : 'text-muted-foreground'
)}>
{conversation.last_message_content || 'No messages yet'}
</p>
{conversation.unread_count > 0 && (
<Badge variant="default" className="ml-2 flex-shrink-0">
{conversation.unread_count}
</Badge>
)}
</div>
</div>
</button>
)
})}
</div>
)
}
92 changes: 92 additions & 0 deletions components/messages/ConversationView.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)

// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])

if (!conversationId) {
return (
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
<MessageSquare className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-xl font-semibold mb-2">Select a conversation</h3>
<p className="text-muted-foreground">
Choose a conversation from the list to start messaging
</p>
</div>
)
}

if (loading) {
return (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className={cn('flex gap-3', i % 2 === 0 && 'flex-row-reverse')}>
<Skeleton className="w-8 h-8 rounded-full flex-shrink-0" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-64 rounded-2xl" />
</div>
</div>
))}
</div>
</div>
)
}

return (
<div className="h-full flex flex-col">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No messages yet</h3>
<p className="text-sm text-muted-foreground">
Send a message to start the conversation
</p>
</div>
) : (
<>
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isOwn={message.sender_id === user?.id}
/>
))}
<div ref={messagesEndRef} />
</>
)}
</div>

{/* Message Input */}
<div className="flex-shrink-0">
<MessageInput onSend={sendMessage} disabled={sending} />
</div>
</div>
)
}

function cn(...classes: (string | boolean | undefined)[]) {
return classes.filter(Boolean).join(' ')
}
68 changes: 68 additions & 0 deletions components/messages/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={cn('flex gap-3 mb-4', isOwn && 'flex-row-reverse')}>
{!isOwn && (
<Avatar className="w-8 h-8 flex-shrink-0">
{message.sender?.avatar_url && (
<AvatarImage src={message.sender.avatar_url} alt={getName()} />
)}
<AvatarFallback className="text-xs bg-gradient-to-br from-blue-500 to-purple-600 text-white">
{getInitials()}
</AvatarFallback>
</Avatar>
)}

<div className={cn('flex flex-col', isOwn ? 'items-end' : 'items-start', 'max-w-[70%]')}>
{!isOwn && (
<span className="text-xs font-medium text-muted-foreground mb-1">
{getName()}
</span>
)}

<div
className={cn(
'rounded-2xl px-4 py-2 break-words',
isOwn
? 'bg-gradient-to-br from-blue-500 to-purple-600 text-white'
: 'bg-muted text-foreground',
message.is_deleted && 'italic opacity-60'
)}
>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
{message.is_edited && !message.is_deleted && (
<span className="text-xs opacity-70 ml-2">(edited)</span>
)}
</div>

<span className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(message.created_at), { addSuffix: true })}
</span>
</div>
</div>
)
}
Loading
Loading