'use client'; import { useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { signedAPI } from '@/lib/api/signed-fetch'; import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import Link from 'next/link'; import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle'; import { useRouter, useSearchParams } from 'next/navigation'; import { AvatarImage } from '@/components/AvatarImage'; interface Conversation { id: string; participant2: { handle: string; displayName: string; avatarUrl: string | null; did?: string; // Add DID support }; lastMessageAt: string; lastMessagePreview: string; unreadCount: number; } interface Message { id: string; senderHandle: string; senderDisplayName?: string; senderAvatarUrl?: string; senderDid?: string; content: string; isSentByMe: boolean; deliveredAt?: string; readAt?: string; createdAt: string; } export default function ChatPage() { const { user } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const composeHandle = searchParams.get('compose'); const sharedPostUrl = searchParams.get('share'); // Chat Data State const [conversations, setConversations] = useState([]); const [selectedConversation, setSelectedConversation] = useState(null); const selectedHandle = selectedConversation ? useFormattedHandle(selectedConversation.participant2.handle) : ''; const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); // Legacy / V2 Hybrid State const [showDeleteModal, setShowDeleteModal] = useState(false); const [conversationToDelete, setConversationToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const appliedSharedPostRef = useRef(null); // ============================================ // HELPER FUNCTIONS (Defined before useEffects) // ============================================ // Check if user is scrolled to bottom const checkIfAtBottom = () => { if (!messagesContainerRef.current) return true; const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current; const threshold = 100; // pixels from bottom return scrollHeight - scrollTop - clientHeight < threshold; }; // Handle scroll to track if user is at bottom const handleScroll = () => { setIsAtBottom(checkIfAtBottom()); }; // Scroll to bottom manually const scrollToBottom = () => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }; const loadConversations = async (isInitialLoad = true) => { try { if (isInitialLoad) setLoading(true); const res = await fetch('/api/swarm/chat/conversations'); if (res.ok) { const data = await res.json(); setConversations(data.conversations || []); } } catch (e) { console.error("Failed to load conversations", e); } finally { if (isInitialLoad) setLoading(false); } }; const loadMessages = async (conversationId: string) => { try { const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); const data = await res.json(); const plainMessages = (data.messages || []).map((msg: any) => ({ ...msg, content: msg.content || '[Empty Message]' })); // Only update if different setMessages(prev => { const prevIds = prev.map(m => m.id).join(','); const newIds = plainMessages.map((m: any) => m.id).join(','); if (prevIds === newIds && prev.length === plainMessages.length) return prev; return plainMessages; }); // Mark as read markAsRead(conversationId); } catch (e) { console.error("Failed to load messages", e); } }; const markAsRead = async (conversationId: string) => { try { const res = await fetch('/api/swarm/chat/messages', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId }) }); if (!res.ok) { return; } setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); window.dispatchEvent(new Event('synapsis:chat-updated')); } catch { } }; const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!newMessage.trim() || !selectedConversation) return; setSending(true); try { // Get recipient DID let did = selectedConversation.participant2.did; if (!did) { const res = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`); const data = await res.json(); did = data.user?.did; if (!did) throw new Error('User not found'); } if (!user || !user.did) throw new Error('User identity not loaded or DID missing'); // Send using Signed API await signedAPI.sendChat( did, selectedConversation.participant2.handle, newMessage, user.did, user.handle ); setNewMessage(''); // If this was a new conversation, we need to refresh the conversation list and select the real one if (selectedConversation.id === 'new') { // Refresh conversations to get the new ID const res = await fetch('/api/swarm/chat/conversations'); const data = await res.json(); const updatedConversations = data.conversations || []; setConversations(updatedConversations); // Find the real conversation const realConv = updatedConversations.find((c: Conversation) => c.participant2.handle === selectedConversation.participant2.handle ); if (realConv) { setSelectedConversation(realConv); loadMessages(realConv.id); } } else { await loadMessages(selectedConversation.id); loadConversations(false); } } catch (err: any) { console.error('[Send] Error:', err); alert(`Failed: ${err.message}`); } finally { setSending(false); } }; const handleDeleteConversation = async (deleteFor: 'self' | 'both') => { if (!conversationToDelete) return; setIsDeleting(true); try { const res = await fetch(`/api/swarm/chat/conversations/${conversationToDelete.id}?deleteFor=${deleteFor}`, { method: 'DELETE', }); if (res.ok) { setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id)); if (selectedConversation?.id === conversationToDelete.id) { setSelectedConversation(null); } setShowDeleteModal(false); setConversationToDelete(null); } } catch (err) { alert('Failed to delete'); } finally { setIsDeleting(false); } }; // ============================================ // EFFECTS (Now that functions are defined) // ============================================ // Load conversations // Load conversations useEffect(() => { if (user) { loadConversations(true); // Initial load with spinner // Poll for new conversations every 5 seconds (no spinner) const pollInterval = setInterval(() => { loadConversations(false); }, 5000); return () => clearInterval(pollInterval); } }, [user]); // Handle Compose Intent useEffect(() => { if (composeHandle && !selectedConversation && conversations.length >= 0) { // Check if we already have a conversation with this user const existing = conversations.find(c => c.participant2.handle.toLowerCase() === composeHandle.toLowerCase() ); if (existing) { setSelectedConversation(existing); // Clear the query param so refresh doesn't keep resetting state router.replace('/chat', { scroll: false }); } else if (!loading) { // Fetch user details to create a draft conversation const fetchUserAndInitDraft = async () => { try { const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`); const data = await res.json(); if (data.user) { if (data.user.isBot || data.user.canReceiveDms === false) { console.error('Cannot DM this account due to privacy settings'); router.replace('/chat'); return; } const draftConv: Conversation = { id: 'new', participant2: { handle: data.user.handle, displayName: data.user.displayName || data.user.handle, avatarUrl: data.user.avatarUrl, did: data.user.did }, lastMessageAt: new Date().toISOString(), lastMessagePreview: 'New Conversation', unreadCount: 0 }; setSelectedConversation(draftConv); router.replace('/chat', { scroll: false }); } else { // User not found, clear compose param to show list console.error('User not found for compose'); router.replace('/chat'); } } catch (e) { console.error("Failed to load user for compose", e); router.replace('/chat'); } }; fetchUserAndInitDraft(); } } }, [composeHandle, selectedConversation, conversations, loading, router]); // Redirect if not logged in useEffect(() => { if (user === null) { router.push('/login'); } }, [user, router]); // Load messages when conversation is selected useEffect(() => { if (selectedConversation) { // Clear messages immediately to prevent flash setMessages([]); if (selectedConversation.id === 'new') { setLoadingMessages(false); return; // Don't load messages for new/draft conversation } setLoadingMessages(true); loadMessages(selectedConversation.id); markAsRead(selectedConversation.id); // Poll for new messages every 3 seconds const pollInterval = setInterval(() => { // Only load if still the same conversation if (selectedConversation.id !== 'new') { loadMessages(selectedConversation.id); } }, 3000); return () => clearInterval(pollInterval); } else if (!selectedConversation) { // Clear messages when no conversation selected setMessages([]); setLoadingMessages(false); } }, [selectedConversation]); // A post shared from the timeline waits for the user to choose a conversation, // then appears in the composer so they remain in control of sending it. useEffect(() => { if (!selectedConversation || !sharedPostUrl || appliedSharedPostRef.current === sharedPostUrl) return; setNewMessage(sharedPostUrl); appliedSharedPostRef.current = sharedPostUrl; router.replace('/chat', { scroll: false }); }, [selectedConversation, sharedPostUrl, router]); // Auto-scroll to bottom of messages only if user was already at bottom useEffect(() => { if (messagesEndRef.current && isAtBottom) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages, isAtBottom]); // ============================================ // RENDER LOGIC // ============================================ const filteredConversations = conversations.filter((conv) => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) ); if (user === null) return null; // Prevent flash of list view while processing compose intent if (composeHandle && !selectedConversation) { return (
); } // Thread View if (selectedConversation) { return (
{/* Header */}
{selectedConversation.participant2.displayName}
{selectedHandle}
{/* Messages */}
{messages.map((msg, i) => (
{msg.content}
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
))}
{/* Input */}
setNewMessage(e.target.value)} />
{/* Delete Modal */} {showDeleteModal && (

Delete Conversation

This action cannot be undone.

)}
); } // LIST VIEW return (

Chat

{sharedPostUrl && (
Choose a conversation to share this post.
)}
setSearchQuery(e.target.value)} />
{loading ? (
) : filteredConversations.length === 0 ? (

No conversations yet

) : ( filteredConversations.map(conv => (
{ setMessages([]); setSelectedConversation(conv); }} style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }} >
{conv.participant2.displayName || conv.participant2.handle} {conv.unreadCount > 0 && {conv.unreadCount}}
{conv.lastMessagePreview}
)) )}
); }