From 611bf171a032e08f2cba595dc25c0814d8ec2f66 Mon Sep 17 00:00:00 2001 From: Akshay Date: Tue, 4 Nov 2025 09:12:15 +0530 Subject: [PATCH] feat(messages): Add real-time typing and user presence indicators - Implement user presence tracking with `useUserPresence` hook - Add `UserStatusIndicator` component to show online/offline status - Create `TypingIndicator` component to display when users are typing - Integrate typing events with `useTypingIndicator` hook in conversation view - Update `MessageInput` to manage and emit typing events - Add status indicators in conversation list and header - Enhance messaging UI with real-time user interaction signals Improves user experience by providing immediate feedback about user availability and current messaging activity. --- app/protected/messages/page.tsx | 18 ++- components/messages/ConversationList.tsx | 20 ++- components/messages/ConversationView.tsx | 14 +- components/messages/MessageInput.tsx | 39 +++++- components/messages/TypingIndicator.tsx | 32 +++++ components/messages/UserStatusIndicator.tsx | 68 ++++++++++ hooks/useTypingIndicator.ts | 72 ++++++++++ hooks/useUserPresence.ts | 143 ++++++++++++++++++++ 8 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 components/messages/TypingIndicator.tsx create mode 100644 components/messages/UserStatusIndicator.tsx create mode 100644 hooks/useTypingIndicator.ts create mode 100644 hooks/useUserPresence.ts diff --git a/app/protected/messages/page.tsx b/app/protected/messages/page.tsx index 3ddf9d8e..eaf4c8b8 100644 --- a/app/protected/messages/page.tsx +++ b/app/protected/messages/page.tsx @@ -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() { @@ -16,6 +18,9 @@ export default function MessagesPage() { const [selectedConversationId, setSelectedConversationId] = useState(null) const [showNewMessage, setShowNewMessage] = useState(false) const [searchQuery, setSearchQuery] = useState('') + + // Track user's online presence + useMyPresence() // Get conversation from URL params useEffect(() => { @@ -94,9 +99,18 @@ export default function MessagesPage() { {/* Main Area - Conversation View */}
- {selectedConversationId && ( + {selectedConversationId && selectedConversation && (
-

{conversationName}

+
+

{conversationName}

+ {!selectedConversation.is_group && selectedConversation.other_user && ( + + )} +
)}
diff --git a/components/messages/ConversationList.tsx b/components/messages/ConversationList.tsx index e1e3fa6b..9b0b1669 100644 --- a/components/messages/ConversationList.tsx +++ b/components/messages/ConversationList.tsx @@ -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' @@ -77,12 +78,19 @@ export function ConversationList({ conversations, selectedId, onSelect, loading isSelected && 'bg-muted' )} > - - {avatarUrl && } - - {initials} - - +
+ + {avatarUrl && } + + {initials} + + + {!conversation.is_group && otherUser && ( +
+ +
+ )} +
diff --git a/components/messages/ConversationView.tsx b/components/messages/ConversationView.tsx index 2dc2edb4..a3ec3ff5 100644 --- a/components/messages/ConversationView.tsx +++ b/components/messages/ConversationView.tsx @@ -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' @@ -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(null) // Auto-scroll to bottom when new messages arrive @@ -79,9 +82,18 @@ export function ConversationView({ conversationId }: ConversationViewProps) { )}
+ {/* Typing Indicator */} + {typingUsers.length > 0 && ( + u.username)} /> + )} + {/* Message Input */}
- +
) diff --git a/components/messages/MessageInput.tsx b/components/messages/MessageInput.tsx index e591e198..c3ccaa43 100644 --- a/components/messages/MessageInput.tsx +++ b/components/messages/MessageInput.tsx @@ -9,12 +9,15 @@ interface MessageInputProps { onSend: (content: string) => Promise 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(null) + const typingTimeoutRef = useRef(null) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -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' @@ -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 (
diff --git a/components/messages/TypingIndicator.tsx b/components/messages/TypingIndicator.tsx new file mode 100644 index 00000000..01c170cd --- /dev/null +++ b/components/messages/TypingIndicator.tsx @@ -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 ( +
+
+ + + +
+ {getTypingText()} +
+ ) +} diff --git a/components/messages/UserStatusIndicator.tsx b/components/messages/UserStatusIndicator.tsx new file mode 100644 index 00000000..3549b21c --- /dev/null +++ b/components/messages/UserStatusIndicator.tsx @@ -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 ( +
+
+
+ {presence.isOnline && ( +
+ )} +
+ {showLastSeen && !presence.isOnline && ( + + {getLastSeenText()} + + )} + {showLastSeen && presence.isOnline && ( + + Online + + )} +
+ ) +} diff --git a/hooks/useTypingIndicator.ts b/hooks/useTypingIndicator.ts new file mode 100644 index 00000000..28852479 --- /dev/null +++ b/hooks/useTypingIndicator.ts @@ -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([]) + + 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 } +} diff --git a/hooks/useUserPresence.ts b/hooks/useUserPresence.ts new file mode 100644 index 00000000..52a170d1 --- /dev/null +++ b/hooks/useUserPresence.ts @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import { createClient } from '@/lib/supabase/client' +import { useAuth } from '@/lib/hooks/useAuth' + +interface UserPresence { + userId: string + isOnline: boolean + lastSeen: string | null +} + +export function useUserPresence(userId: string | null) { + const [presence, setPresence] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!userId) { + setLoading(false) + return + } + + const supabase = createClient() + + // Fetch initial presence + const fetchPresence = async () => { + const { data, error } = await supabase + .from('user_presence') + .select('*') + .eq('user_id', userId) + .single() + + if (data) { + setPresence({ + userId: data.user_id, + isOnline: data.is_online, + lastSeen: data.last_seen + }) + } + setLoading(false) + } + + fetchPresence() + + // Subscribe to presence changes + const subscription = supabase + .channel(`presence:${userId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'user_presence', + filter: `user_id=eq.${userId}` + }, + (payload) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newData = payload.new as any + if (newData) { + setPresence({ + userId: newData.user_id, + isOnline: newData.is_online, + lastSeen: newData.last_seen + }) + } + } + ) + .subscribe() + + return () => { + subscription.unsubscribe() + } + }, [userId]) + + return { presence, loading } +} + +// Hook to manage current user's presence +export function useMyPresence() { + const { user } = useAuth() + const [isOnline, setIsOnline] = useState(true) + + useEffect(() => { + if (!user) return + + const supabase = createClient() + + // Set user as online + const setOnline = async () => { + await supabase + .from('user_presence') + .upsert({ + user_id: user.id, + is_online: true, + last_seen: new Date().toISOString() + }) + } + + // Set user as offline + const setOffline = async () => { + await supabase + .from('user_presence') + .upsert({ + user_id: user.id, + is_online: false, + last_seen: new Date().toISOString() + }) + } + + // Update presence every 30 seconds + const updatePresence = async () => { + if (document.visibilityState === 'visible') { + await setOnline() + } + } + + // Set online on mount + setOnline() + + // Update presence periodically + const interval = setInterval(updatePresence, 30000) + + // Handle visibility change + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + setOnline() + setIsOnline(true) + } else { + setOffline() + setIsOnline(false) + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + // Set offline on unmount + return () => { + clearInterval(interval) + document.removeEventListener('visibilitychange', handleVisibilityChange) + setOffline() + } + }, [user]) + + return { isOnline } +}