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
18 changes: 16 additions & 2 deletions app/protected/messages/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { useSearchParams } from 'next/navigation'
import { ConversationList } from '@/components/messages/ConversationList'
import { ConversationView } from '@/components/messages/ConversationView'
import { NewMessageDialog } from '@/components/messages/NewMessageDialog'
import { UserStatusIndicator } from '@/components/messages/UserStatusIndicator'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useConversations } from '@/hooks/useConversations'
import { useMyPresence } from '@/hooks/useUserPresence'
import { Plus, Search, MessageSquare } from 'lucide-react'

export default function MessagesPage() {
Expand All @@ -16,6 +18,9 @@ export default function MessagesPage() {
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null)
const [showNewMessage, setShowNewMessage] = useState(false)
const [searchQuery, setSearchQuery] = useState('')

// Track user's online presence
useMyPresence()

// Get conversation from URL params
useEffect(() => {
Expand Down Expand Up @@ -94,9 +99,18 @@ export default function MessagesPage() {

{/* Main Area - Conversation View */}
<div className="flex-1 bg-background flex flex-col">
{selectedConversationId && (
{selectedConversationId && selectedConversation && (
<div className="border-b p-4 bg-muted/50 flex-shrink-0">
<h2 className="font-semibold">{conversationName}</h2>
<div className="flex items-center gap-3">
<h2 className="font-semibold">{conversationName}</h2>
{!selectedConversation.is_group && selectedConversation.other_user && (
<UserStatusIndicator
userId={selectedConversation.other_user.id}
showLastSeen={true}
size="sm"
/>
)}
</div>
</div>
)}
<div className="flex-1 min-h-0">
Expand Down
20 changes: 14 additions & 6 deletions components/messages/ConversationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { UserStatusIndicator } from './UserStatusIndicator'
import { formatDistanceToNow } from 'date-fns'
import type { ConversationWithDetails } from '@/types/messaging'
import { cn } from '@/lib/utils'
Expand Down Expand Up @@ -77,12 +78,19 @@ export function ConversationList({ conversations, selectedId, onSelect, loading
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="relative">
<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>
{!conversation.is_group && otherUser && (
<div className="absolute bottom-0 right-0">
<UserStatusIndicator userId={otherUser.id} size="sm" />
</div>
)}
</div>

<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
Expand Down
14 changes: 13 additions & 1 deletion components/messages/ConversationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import React, { useEffect, useRef } from 'react'
import { MessageBubble } from './MessageBubble'
import { MessageInput } from './MessageInput'
import { TypingIndicator } from './TypingIndicator'
import { Skeleton } from '@/components/ui/skeleton'
import { useMessages } from '@/hooks/useMessages'
import { useTypingIndicator } from '@/hooks/useTypingIndicator'
import { useAuth } from '@/lib/hooks/useAuth'
import { MessageSquare } from 'lucide-react'

Expand All @@ -16,6 +18,7 @@ interface ConversationViewProps {
export function ConversationView({ conversationId }: ConversationViewProps) {
const { user } = useAuth()
const { messages, loading, sending, sendMessage } = useMessages(conversationId)
const { typingUsers, sendTypingEvent } = useTypingIndicator(conversationId)
const messagesEndRef = useRef<HTMLDivElement>(null)

// Auto-scroll to bottom when new messages arrive
Expand Down Expand Up @@ -79,9 +82,18 @@ export function ConversationView({ conversationId }: ConversationViewProps) {
)}
</div>

{/* Typing Indicator */}
{typingUsers.length > 0 && (
<TypingIndicator usernames={typingUsers.map(u => u.username)} />
)}

{/* Message Input */}
<div className="flex-shrink-0">
<MessageInput onSend={sendMessage} disabled={sending} />
<MessageInput
onSend={sendMessage}
disabled={sending}
onTyping={sendTypingEvent}
/>
</div>
</div>
)
Expand Down
39 changes: 38 additions & 1 deletion components/messages/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ interface MessageInputProps {
onSend: (content: string) => Promise<void>
disabled?: boolean
placeholder?: string
onTyping?: (isTyping: boolean) => void
}

export function MessageInput({ onSend, disabled, placeholder = 'Type a message...' }: MessageInputProps) {
export function MessageInput({ onSend, disabled, placeholder = 'Type a message...', onTyping }: MessageInputProps) {
const [content, setContent] = useState('')
const [sending, setSending] = useState(false)
const [isTyping, setIsTyping] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
Expand All @@ -26,6 +29,10 @@ export function MessageInput({ onSend, disabled, placeholder = 'Type a message..
await onSend(content)
setContent('')

// Stop typing indicator
setIsTyping(false)
onTyping?.(false)

// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
Expand All @@ -52,6 +59,36 @@ export function MessageInput({ onSend, disabled, placeholder = 'Type a message..
}
}, [content])

// Handle typing indicator
useEffect(() => {
if (content.trim() && !isTyping) {
setIsTyping(true)
onTyping?.(true)
}

// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}

// Set new timeout to stop typing indicator
if (content.trim()) {
typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false)
onTyping?.(false)
}, 2000)
} else if (isTyping) {
setIsTyping(false)
onTyping?.(false)
}

return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
}
}, [content, isTyping, onTyping])

return (
<form onSubmit={handleSubmit} className="border-t bg-background p-3">
<div className="flex gap-2 items-end max-w-full">
Expand Down
32 changes: 32 additions & 0 deletions components/messages/TypingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client'

import React from 'react'

interface TypingIndicatorProps {
usernames: string[]
}

export function TypingIndicator({ usernames }: TypingIndicatorProps) {
if (usernames.length === 0) return null

const getTypingText = () => {
if (usernames.length === 1) {
return `${usernames[0]} is typing...`
} else if (usernames.length === 2) {
return `${usernames[0]} and ${usernames[1]} are typing...`
} else {
return `${usernames[0]} and ${usernames.length - 1} others are typing...`
}
}

return (
<div className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground">
<div className="flex gap-1">
<span className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span>{getTypingText()}</span>
</div>
)
}
68 changes: 68 additions & 0 deletions components/messages/UserStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client'

import React from 'react'
import { useUserPresence } from '@/hooks/useUserPresence'
import { formatDistanceToNow } from 'date-fns'

interface UserStatusIndicatorProps {
userId: string
showLastSeen?: boolean
size?: 'sm' | 'md' | 'lg'
}

export function UserStatusIndicator({
userId,
showLastSeen = false,
size = 'md'
}: UserStatusIndicatorProps) {
const { presence, loading } = useUserPresence(userId)

if (loading || !presence) return null

const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4'
}

const getLastSeenText = () => {
if (!presence.lastSeen) return 'Last seen: Unknown'

try {
const lastSeenDate = new Date(presence.lastSeen)
return `Last seen ${formatDistanceToNow(lastSeenDate, { addSuffix: true })}`
} catch {
return 'Last seen: Unknown'
}
}

return (
<div className="flex items-center gap-2">
<div className="relative">
<div
className={`${sizeClasses[size]} rounded-full ${
presence.isOnline
? 'bg-green-500'
: 'bg-gray-400'
}`}
title={presence.isOnline ? 'Online' : getLastSeenText()}
/>
{presence.isOnline && (
<div
className={`absolute inset-0 ${sizeClasses[size]} rounded-full bg-green-500 animate-ping opacity-75`}
/>
)}
</div>
{showLastSeen && !presence.isOnline && (
<span className="text-xs text-muted-foreground">
{getLastSeenText()}
</span>
)}
{showLastSeen && presence.isOnline && (
<span className="text-xs text-green-600 font-medium">
Online
</span>
)}
</div>
)
}
72 changes: 72 additions & 0 deletions hooks/useTypingIndicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useAuth } from '@/lib/hooks/useAuth'

interface TypingUser {
userId: string
username: string
timestamp: number
}

export function useTypingIndicator(conversationId: string | null) {
const { user } = useAuth()
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([])

useEffect(() => {
if (!conversationId || !user) return

const supabase = createClient()
const channel = supabase.channel(`typing:${conversationId}`)

// Subscribe to typing events
channel
.on('broadcast', { event: 'typing' }, (payload) => {
const { userId, username, isTyping } = payload.payload

// Ignore own typing events
if (userId === user.id) return

if (isTyping) {
setTypingUsers(prev => {
const exists = prev.find(u => u.userId === userId)
if (exists) return prev
return [...prev, { userId, username, timestamp: Date.now() }]
})
} else {
setTypingUsers(prev => prev.filter(u => u.userId !== userId))
}
})
.subscribe()

// Clean up stale typing indicators (after 5 seconds)
const interval = setInterval(() => {
setTypingUsers(prev =>
prev.filter(u => Date.now() - u.timestamp < 5000)
)
}, 1000)

return () => {
channel.unsubscribe()
clearInterval(interval)
}
}, [conversationId, user])

const sendTypingEvent = useCallback((isTyping: boolean) => {
if (!conversationId || !user) return

const supabase = createClient()
const channel = supabase.channel(`typing:${conversationId}`)

channel.send({
type: 'broadcast',
event: 'typing',
payload: {
userId: user.id,
username: user.user_metadata?.username || 'User',
isTyping
}
})
}, [conversationId, user])

return { typingUsers, sendTypingEvent }
}
Loading
Loading