'use client'; import { Fragment, useCallback, useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { signedAPI } from '@/lib/api/signed-fetch'; import { ArrowLeft, Send, Loader2, LockKeyhole, MessageCircle, Search, Trash2 } 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'; import { E2EEChatGate } from '@/components/E2EEChatGate'; import { IdentityUnlockPrompt } from '@/components/IdentityUnlockPrompt'; import { decryptStoredChatMessage, E2EEClientError, resolveE2EEPublicBundle, type StoredChatMessage, } from '@/lib/e2ee/client'; import { encryptE2EEMessage } from '@/lib/e2ee/client-crypto'; import type { E2EEKeyBundle, E2EEMessageEnvelope } from '@/lib/e2ee/protocol'; import { useE2EEIdentity } from '@/lib/e2ee/use-e2ee-identity'; interface Conversation { id: string; participant2: { handle: string; displayName: string; avatarUrl: string | null; did?: string; // Add DID support }; lastMessageAt: string; lastMessagePreview: string; lastMessage?: StoredChatMessage | null; unreadCount: number; encryptionMode?: 'legacy' | 'e2ee'; e2eeActivatedAt?: string | null; } interface ChatMessagePayload extends StoredChatMessage { id: string; senderHandle: string; senderDisplayName?: string; senderAvatarUrl?: string; senderDid?: string; isSentByMe: boolean; deliveredAt?: string; readAt?: string; createdAt: string; } type Message = Omit & { content: string; legacy: boolean; decryptionError: boolean; }; type ConversationEncryptionState = | { status: 'idle' } | { status: 'resolving'; conversationKey: string } | { status: 'ready'; conversationKey: string; recipientDid: string; senderBundle: E2EEKeyBundle; recipientBundle: E2EEKeyBundle; } | { status: 'error'; conversationKey: string; code: string; message: string }; interface ComposeIntentError { handle: string; message: string; } const CHAT_REQUEST_TIMEOUT_MS = 15_000; interface PreparedSend { accountDid: string; conversationKey: string; draft: string; senderKeyId: string; recipientKeyId: string; envelope: E2EEMessageEnvelope; } function encryptionConversationKey(conversation: Conversation): string { return `${conversation.id}:${conversation.participant2.did || conversation.participant2.handle}`; } function accountConversationKey(accountDid: string | null, conversationKey: string): string { return JSON.stringify([accountDid, conversationKey]); } export default function ChatPage() { const { user, loading: authLoading, isIdentityUnlocked, isRestoring: isIdentityRestoring } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const composeHandle = searchParams.get('compose'); const sharedPostUrl = searchParams.get('share'); const e2eeIdentity = useE2EEIdentity(user?.did, user?.handle); const activeE2EEKeyId = e2eeIdentity.state.status === 'ready' ? e2eeIdentity.state.material.keyId : null; // Chat Data State const [conversations, setConversations] = useState([]); const [selectedConversation, setSelectedConversation] = useState(null); const selectedHandle = useFormattedHandle(selectedConversation?.participant2.handle || ''); const [messages, setMessages] = useState([]); const [drafts, setDrafts] = useState>({}); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [conversationsError, setConversationsError] = useState(null); const [messagesError, setMessagesError] = useState(null); const [composeIntentError, setComposeIntentError] = useState(null); const [dismissedComposeHandle, setDismissedComposeHandle] = useState(null); const [composeRetryVersion, setComposeRetryVersion] = useState(0); const [conversationEncryption, setConversationEncryption] = useState({ status: 'idle' }); 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 [deleteError, setDeleteError] = useState(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const appliedSharedPostRef = useRef(null); const messagesRequestRef = useRef(0); const conversationsRequestRef = useRef(0); const peerResolutionRef = useRef(0); const composeRequestRef = useRef(0); const sendRequestRef = useRef(0); const accountDidRef = useRef(null); const renderedAccountDidRef = useRef(user?.did ?? null); const selectedConversationRef = useRef(selectedConversation); const selectedConversationKeyRef = useRef(null); const preparedSendsRef = useRef(new Map()); const activeSendKeysRef = useRef(new Set()); renderedAccountDidRef.current = user?.did ?? null; selectedConversationRef.current = selectedConversation; selectedConversationKeyRef.current = selectedConversation ? encryptionConversationKey(selectedConversation) : null; const selectedConversationKey = selectedConversationKeyRef.current; const newMessage = selectedConversationKey ? drafts[selectedConversationKey] || '' : ''; const updateSelectedDraft = useCallback((value: string) => { const key = selectedConversationKeyRef.current; if (!key) return; const cacheKey = accountConversationKey(renderedAccountDidRef.current, key); const prepared = preparedSendsRef.current.get(cacheKey); if (prepared && prepared.draft !== value) preparedSendsRef.current.delete(cacheKey); setDrafts((current) => current[key] === value ? current : { ...current, [key]: value }); setSendError(null); }, []); const selectConversation = useCallback((conversation: Conversation | null) => { messagesRequestRef.current += 1; peerResolutionRef.current += 1; sendRequestRef.current += 1; selectedConversationRef.current = conversation; selectedConversationKeyRef.current = conversation ? encryptionConversationKey(conversation) : null; setMessages([]); setMessagesError(null); setSendError(null); setSending(conversation ? activeSendKeysRef.current.has(accountConversationKey( renderedAccountDidRef.current, encryptionConversationKey(conversation), )) : false); setIsAtBottom(true); setSelectedConversation(conversation); }, []); // ============================================ // 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()); }; const loadConversations = useCallback(async (isInitialLoad = true) => { const requestId = ++conversationsRequestRef.current; const requestAccountDid = user?.did ?? null; const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), CHAT_REQUEST_TIMEOUT_MS); try { if (isInitialLoad) { setLoading(true); setConversationsError(null); } const res = await fetch('/api/swarm/chat/conversations', { signal: controller.signal }); if (!res.ok) throw new Error('Conversations could not be loaded'); const data = await res.json(); let nextConversations = (Array.isArray(data.conversations) ? data.conversations : []) as Conversation[]; // Render the server's generic encrypted previews as soon as the list // arrives; local preview decryption can finish without holding the // whole screen on a spinner. if (isInitialLoad && requestId === conversationsRequestRef.current && renderedAccountDidRef.current === requestAccountDid) { setConversations(nextConversations); setConversationsError(null); setLoading(false); } if (user?.did && e2eeIdentity.state.status === 'ready') { const material = e2eeIdentity.state.material; nextConversations = await Promise.all(nextConversations.map(async (conversation) => { if (!conversation.lastMessage) return conversation; try { const { content } = await decryptStoredChatMessage( conversation.lastMessage, user.did!, material, ); const normalized = content.replace(/\s+/g, ' ').trim(); const characters = Array.from(normalized); return { ...conversation, lastMessagePreview: characters.length > 96 ? `${characters.slice(0, 96).join('')}…` : normalized, }; } catch { return { ...conversation, lastMessagePreview: 'Encrypted message' }; } })); } if (requestId === conversationsRequestRef.current && renderedAccountDidRef.current === requestAccountDid) { setConversations(nextConversations); setConversationsError(null); } } catch (e) { console.error("Failed to load conversations", e); if (requestId === conversationsRequestRef.current && renderedAccountDidRef.current === requestAccountDid) { setConversationsError(controller.signal.aborted ? 'The node took too long to load conversations. Try again.' : e instanceof TypeError ? 'The connection to the node was interrupted. Try again.' : e instanceof Error ? e.message : 'Conversations could not be loaded'); } } finally { window.clearTimeout(timeout); if (requestId === conversationsRequestRef.current && renderedAccountDidRef.current === requestAccountDid) { setLoading(false); } } }, [user?.did, e2eeIdentity.state]); const markAsRead = useCallback(async (conversationId: string) => { const requestAccountDid = renderedAccountDidRef.current; try { const res = await fetch('/api/swarm/chat/messages', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId }) }); if (!res.ok || renderedAccountDidRef.current !== requestAccountDid) return; setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); window.dispatchEvent(new Event('synapsis:chat-updated')); } catch { } }, []); const loadMessages = useCallback(async (conversationId: string) => { const requestId = ++messagesRequestRef.current; const requestAccountDid = user?.did ?? null; if (!user?.did || e2eeIdentity.state.status !== 'ready') { if (requestId === messagesRequestRef.current) setLoadingMessages(false); return; } const material = e2eeIdentity.state.material; try { setMessagesError(null); const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); if (!res.ok) throw new Error('Messages could not be loaded'); const data = await res.json(); const storedMessages = (Array.isArray(data.messages) ? data.messages : []) as ChatMessagePayload[]; const decryptedMessages = await Promise.all(storedMessages.map(async (msg): Promise => { if (msg.protocolVersion !== 0 && msg.protocolVersion !== 1) { return { ...msg, content: 'Encrypted message unavailable', legacy: false, decryptionError: true, }; } try { const decrypted = await decryptStoredChatMessage(msg, user.did!, material); return { ...msg, content: decrypted.content, legacy: decrypted.legacy, decryptionError: false, }; } catch (error) { console.error(`[E2EE Chat] Message ${msg.id} could not be decrypted:`, error); return { ...msg, content: 'Encrypted message unavailable', legacy: false, decryptionError: true, }; } })); if (requestId !== messagesRequestRef.current || renderedAccountDidRef.current !== requestAccountDid || selectedConversationRef.current?.id !== conversationId) return; // Only update if different setMessages(prev => { const unchanged = prev.length === decryptedMessages.length && prev.every((message, index) => { const next = decryptedMessages[index]; return message.id === next.id && message.content === next.content && message.decryptionError === next.decryptionError && message.readAt === next.readAt && message.deliveredAt === next.deliveredAt; }); return unchanged ? prev : decryptedMessages; }); // Mark as read void markAsRead(conversationId); } catch (e) { console.error("Failed to load messages", e); if (requestId === messagesRequestRef.current && renderedAccountDidRef.current === requestAccountDid && selectedConversationRef.current?.id === conversationId) { setMessagesError(e instanceof Error ? e.message : 'Messages could not be loaded'); } } finally { if (requestId === messagesRequestRef.current && renderedAccountDidRef.current === requestAccountDid && selectedConversationRef.current?.id === conversationId) { setLoadingMessages(false); } } }, [user?.did, e2eeIdentity.state, markAsRead]); const resolveConversationEncryption = useCallback(async (conversation: Conversation) => { const requestId = ++peerResolutionRef.current; const conversationKey = encryptionConversationKey(conversation); const requestAccountDid = user?.did ?? null; setConversationEncryption({ status: 'resolving', conversationKey }); try { if (!user?.did || !user.handle || e2eeIdentity.state.status !== 'ready') { throw new E2EEClientError('Your encrypted message key is not ready', 'E2EE_IDENTITY_NOT_READY'); } let recipientDid = conversation.participant2.did; if (!recipientDid) { const response = await fetch(`/api/users/${encodeURIComponent(conversation.participant2.handle)}`, { cache: 'no-store', }); const body = await response.json().catch(() => null); recipientDid = body?.user?.did; if (!response.ok || !recipientDid) { throw new E2EEClientError('Recipient identity could not be loaded', 'E2EE_RECIPIENT_NOT_FOUND'); } } const [sender, recipient] = await Promise.all([ resolveE2EEPublicBundle(user.did, user.handle), resolveE2EEPublicBundle(recipientDid, conversation.participant2.handle), ]); if (sender.bundle.keyId !== e2eeIdentity.state.material.keyId || sender.bundle.publicKey !== e2eeIdentity.state.material.publicKey) { throw new E2EEClientError( 'Your local encryption key does not match your signed public key', 'E2EE_IDENTITY_KEY_MISMATCH', ); } if (requestId !== peerResolutionRef.current || renderedAccountDidRef.current !== requestAccountDid || selectedConversationKeyRef.current !== conversationKey) return; setConversationEncryption({ status: 'ready', conversationKey, recipientDid, senderBundle: sender.bundle, recipientBundle: recipient.bundle, }); } catch (error) { if (requestId !== peerResolutionRef.current || renderedAccountDidRef.current !== requestAccountDid || selectedConversationKeyRef.current !== conversationKey) return; const clientError = error instanceof E2EEClientError ? error : null; setConversationEncryption({ status: 'error', conversationKey, code: clientError?.code || 'E2EE_KEY_LOOKUP_FAILED', message: error instanceof Error ? error.message : 'Encryption keys could not be verified', }); } }, [user?.did, user?.handle, e2eeIdentity.state]); const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); const conversation = selectedConversationRef.current; const conversationKey = selectedConversationKeyRef.current; const draftToSend = conversationKey ? drafts[conversationKey] || '' : ''; if (!draftToSend.trim() || !conversation || !conversationKey) return; const cacheKey = accountConversationKey(renderedAccountDidRef.current, conversationKey); if (activeSendKeysRef.current.has(cacheKey)) { setSendError('This encrypted message is still being confirmed.'); return; } if (conversationEncryption.status !== 'ready' || conversationEncryption.conversationKey !== conversationKey) { setSendError('Encryption keys are still being verified. Your draft has not been sent.'); return; } if (!user?.did || e2eeIdentity.state.status !== 'ready') { setSendError('Encrypted messages are locked. Your draft has not been sent.'); return; } const accountDid = user.did; const requestId = ++sendRequestRef.current; if (new TextEncoder().encode(draftToSend).length > 8_000) { setSendError('Encrypted messages can be up to 8,000 bytes. Your draft has not been sent.'); return; } activeSendKeysRef.current.add(cacheKey); setSending(true); setSendError(null); try { const priorPrepared = preparedSendsRef.current.get(cacheKey); let envelope: E2EEMessageEnvelope; if (priorPrepared && priorPrepared.accountDid === accountDid && priorPrepared.draft === draftToSend && priorPrepared.senderKeyId === conversationEncryption.senderBundle.keyId && priorPrepared.recipientKeyId === conversationEncryption.recipientBundle.keyId) { envelope = priorPrepared.envelope; } else { envelope = await encryptE2EEMessage({ plaintext: draftToSend, senderDid: accountDid, senderHandle: user.handle, senderBundle: conversationEncryption.senderBundle, recipientDid: conversationEncryption.recipientDid, recipientHandle: conversation.participant2.handle, recipientBundle: conversationEncryption.recipientBundle, }); preparedSendsRef.current.set(cacheKey, { accountDid, conversationKey, draft: draftToSend, senderKeyId: conversationEncryption.senderBundle.keyId, recipientKeyId: conversationEncryption.recipientBundle.keyId, envelope, }); } let response: Response; try { response = await signedAPI.sendChat( envelope, accountDid, user.handle ); } catch (error) { throw new E2EEClientError( error instanceof Error ? error.message : 'Delivery could not be confirmed', 'E2EE_SEND_AMBIGUOUS', ); } if (!response.ok) { const body = await response.json().catch(() => null); if (response.status < 500) preparedSendsRef.current.delete(cacheKey); throw new E2EEClientError( body?.error || 'Encrypted message could not be sent', body?.code || (response.status >= 500 ? 'E2EE_SEND_AMBIGUOUS' : 'E2EE_SEND_FAILED'), body || undefined, ); } preparedSendsRef.current.delete(cacheKey); setDrafts((current) => { if (current[conversationKey] !== draftToSend) return current; const next = { ...current }; delete next[conversationKey]; return next; }); if (requestId !== sendRequestRef.current || renderedAccountDidRef.current !== accountDid || selectedConversationKeyRef.current !== conversationKey) { void loadConversations(false); return; } // Refresh failures after a successful send must not be reported as send // failures or restore a draft that the server already accepted. try { if (conversation.id === 'new') { const res = await fetch('/api/swarm/chat/conversations'); if (!res.ok) throw new Error('Conversation list could not be refreshed'); const data = await res.json(); const updatedConversations = (data.conversations || []) as Conversation[]; if (requestId !== sendRequestRef.current || renderedAccountDidRef.current !== accountDid || selectedConversationKeyRef.current !== conversationKey) return; setConversations(updatedConversations); const realConv = updatedConversations.find((c: Conversation) => c.participant2.handle === conversation.participant2.handle ); if (realConv) selectConversation(realConv); } else { await loadMessages(conversation.id); void loadConversations(false); } } catch (refreshError) { console.error('[Send] Message sent, but Chat could not refresh:', refreshError); void loadConversations(false); } } catch (err) { console.error('[Send] Error:', err); if (requestId !== sendRequestRef.current || renderedAccountDidRef.current !== accountDid || selectedConversationKeyRef.current !== conversationKey) return; if (err instanceof E2EEClientError) { if (err.code === 'E2EE_SENDER_KEY_STALE') { preparedSendsRef.current.delete(cacheKey); void e2eeIdentity.retry(); } else if (err.code === 'E2EE_RECIPIENT_KEY_STALE' || err.code === 'E2EE_NOT_CONFIGURED') { preparedSendsRef.current.delete(cacheKey); void resolveConversationEncryption(conversation); } } setSendError(err instanceof E2EEClientError && err.code === 'E2EE_SEND_AMBIGUOUS' ? 'Delivery could not be confirmed. Retry will safely reuse the same encrypted message.' : err instanceof Error ? `${err.message} Your draft has not been sent.` : 'Encrypted message could not be sent. Your draft has not been sent.'); } finally { activeSendKeysRef.current.delete(cacheKey); if (renderedAccountDidRef.current === accountDid && selectedConversationKeyRef.current === conversationKey) { setSending(activeSendKeysRef.current.has(cacheKey)); } } }; const handleDeleteConversation = async (deleteFor: 'self' | 'both') => { if (!conversationToDelete) return; setIsDeleting(true); setDeleteError(null); try { const res = await fetch(`/api/swarm/chat/conversations/${conversationToDelete.id}?deleteFor=${deleteFor}`, { method: 'DELETE', }); if (!res.ok) { const body = await res.json().catch(() => null); throw new Error(body?.error || 'Conversation could not be deleted'); } setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id)); if (selectedConversation?.id === conversationToDelete.id) { selectConversation(null); } setShowDeleteModal(false); setConversationToDelete(null); } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Conversation could not be deleted'); } finally { setIsDeleting(false); } }; // ============================================ // EFFECTS (Now that functions are defined) // ============================================ // Load conversations useEffect(() => { const did = user?.did ?? null; if (accountDidRef.current === did) return; accountDidRef.current = did; messagesRequestRef.current += 1; conversationsRequestRef.current += 1; peerResolutionRef.current += 1; composeRequestRef.current += 1; sendRequestRef.current += 1; preparedSendsRef.current.clear(); activeSendKeysRef.current.clear(); selectedConversationRef.current = null; selectedConversationKeyRef.current = null; setConversations([]); setSelectedConversation(null); setMessages([]); setDrafts({}); setSendError(null); setConversationsError(null); setMessagesError(null); setComposeIntentError(null); setDismissedComposeHandle(null); setComposeRetryVersion(0); setSending(false); setIsAtBottom(true); setConversationEncryption({ status: 'idle' }); setLoading(Boolean(did)); }, [user?.did]); // Load conversations useEffect(() => { if (!user) return; void loadConversations(true); const pollInterval = setInterval(() => { void loadConversations(false); }, 5000); return () => { clearInterval(pollInterval); conversationsRequestRef.current += 1; }; }, [user, activeE2EEKeyId, loadConversations]); // Handle Compose Intent useEffect(() => { if (!composeHandle || selectedConversation || loading || dismissedComposeHandle === composeHandle) return; // Check if we already have a conversation with this user. const existing = conversations.find(c => c.participant2.handle.toLowerCase() === composeHandle.toLowerCase() ); if (existing) { setComposeIntentError(null); selectConversation(existing); // Clear the query param so refresh doesn't keep resetting state. router.replace('/chat', { scroll: false }); return; } // Keep a failed intent stable until the user retries. Conversation // polling replaces the array every few seconds and must not turn the // visible error back into a spinner on each successful poll. if (composeIntentError?.handle === composeHandle) return; const requestId = ++composeRequestRef.current; const requestAccountDid = renderedAccountDidRef.current; const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), CHAT_REQUEST_TIMEOUT_MS); setComposeIntentError(null); // Fetch user details to create a draft conversation. A transient node // restart must surface a retry action instead of leaving the compose // route behind an unconditional full-page spinner. const fetchUserAndInitDraft = async () => { try { const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`, { signal: controller.signal, }); if (!res.ok) throw new Error(`Account lookup failed (${res.status})`); const data = await res.json(); if (requestId !== composeRequestRef.current || renderedAccountDidRef.current !== requestAccountDid) return; if (data.user) { if (data.user.isBot || data.user.canReceiveDms === false) { setComposeIntentError({ handle: composeHandle, message: 'This account cannot receive direct messages.', }); 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 }; selectConversation(draftConv); router.replace('/chat', { scroll: false }); } else { setComposeIntentError({ handle: composeHandle, message: 'That account could not be found.', }); } } catch (error) { if (requestId !== composeRequestRef.current || renderedAccountDidRef.current !== requestAccountDid) return; console.error('Failed to load user for compose', error); setComposeIntentError({ handle: composeHandle, message: controller.signal.aborted ? 'The node took too long to respond. Try again.' : 'The connection to the node was interrupted. Try again.', }); } finally { window.clearTimeout(timeout); } }; void fetchUserAndInitDraft(); return () => { composeRequestRef.current += 1; controller.abort(); window.clearTimeout(timeout); }; }, [composeHandle, selectedConversation, conversations, loading, router, selectConversation, composeIntentError, dismissedComposeHandle, composeRetryVersion]); // Redirect if not logged in useEffect(() => { if (!authLoading && user === null) { router.push('/login'); } }, [authLoading, user, router]); // A thread is sendable only after both participants' public bundles have // been resolved and verified. A failed lookup never falls back to plaintext. useEffect(() => { setSendError(null); if (!selectedConversation || !activeE2EEKeyId) { peerResolutionRef.current += 1; setConversationEncryption({ status: 'idle' }); return; } void resolveConversationEncryption(selectedConversation); return () => { peerResolutionRef.current += 1; }; }, [selectedConversation, activeE2EEKeyId, resolveConversationEncryption]); // Load messages when conversation is selected useEffect(() => { messagesRequestRef.current += 1; setMessages([]); if (!selectedConversation || !activeE2EEKeyId) { setLoadingMessages(false); return; } if (selectedConversation.id === 'new') { setLoadingMessages(false); return; } setLoadingMessages(true); void loadMessages(selectedConversation.id); // Polling only retrieves ciphertext; decryption remains local. const pollInterval = setInterval(() => { void loadMessages(selectedConversation.id); }, 3000); return () => { clearInterval(pollInterval); messagesRequestRef.current += 1; }; }, [selectedConversation, activeE2EEKeyId, loadMessages]); // 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; updateSelectedDraft(sharedPostUrl); appliedSharedPostRef.current = sharedPostUrl; router.replace('/chat', { scroll: false }); }, [selectedConversation, sharedPostUrl, router, updateSelectedDraft]); // 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]); useEffect(() => { setIsAtBottom(true); const frame = window.requestAnimationFrame(() => { const container = messagesContainerRef.current; if (container) container.scrollTop = container.scrollHeight; }); return () => window.cancelAnimationFrame(frame); }, [selectedConversationKey]); useEffect(() => { if (!showDeleteModal) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape' || isDeleting) return; setDeleteError(null); setShowDeleteModal(false); }; window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); }, [showDeleteModal, isDeleting]); // ============================================ // RENDER LOGIC // ============================================ const filteredConversations = conversations.filter((conv) => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) ); const selectedEncryptionReady = conversationEncryption.status === 'ready' && conversationEncryption.conversationKey === selectedConversationKey; const selectedEncryptionError = conversationEncryption.status === 'error' && conversationEncryption.conversationKey === selectedConversationKey ? conversationEncryption : null; if (authLoading || isIdentityRestoring) { return (
); } if (user === null) return null; if (!isIdentityUnlocked) { return router.push('/')} />; } if (e2eeIdentity.state.status !== 'ready') { return ( router.push('/')} /> ); } // Prevent a flash of the list view while processing a compose intent, but // never hide a failed request behind an endless spinner. if (composeHandle && !selectedConversation && dismissedComposeHandle !== composeHandle) { const currentComposeError = composeIntentError?.handle === composeHandle ? composeIntentError.message : null; if (currentComposeError) { return (

Couldn't open this conversation

{currentComposeError}

); } return (

Opening conversation…

); } // Thread View if (selectedConversation) { return (
{/* Header */}
{selectedConversation.participant2.displayName}
{selectedHandle}
{/* Messages */}
{loadingMessages ? (
) : messagesError ? (

{messagesError}

) : messages.length === 0 ? (
{selectedEncryptionReady ? 'New messages in this conversation will be end-to-end encrypted.' : 'No messages yet.'}
) : messages.map((msg, i) => { const startsEncryptedSection = msg.protocolVersion === 1 && i > 0 && messages[i - 1].legacy; return ( {startsEncryptedSection && (
)}
{msg.decryptionError ? (
This message could not be decrypted on this device.
) : msg.content}
{msg.legacy && (
Sent before end-to-end encryption
)}
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
); })}
{/* Input */}
{selectedEncryptionReady ? ( <>
updateSelectedDraft(e.target.value)} aria-label={`Encrypted message to ${selectedHandle}`} aria-describedby={sendError ? 'encrypted-send-error' : undefined} />
{sendError && ( )} ) : selectedEncryptionError ? (

{selectedEncryptionError.code === 'E2EE_NOT_CONFIGURED' ? 'They need to set up encrypted messages before you can send them a DM. ' : `${selectedEncryptionError.message}. `} Synapsis will not send this conversation unencrypted.

) : (
)}
{/* Delete Modal */} {showDeleteModal && (

Delete Conversation

This action cannot be undone.

{deleteError && (

{deleteError}

)}
{!selectedConversation.participant2.handle.includes('@') && ( )} {selectedConversation.participant2.handle.includes('@') && (

Across nodes, deleting removes only your copy.

)}
)}
); } // LIST VIEW return (

Chat

{sharedPostUrl && (
Choose a conversation to share this post.
)}
{loading ? (
) : conversationsError ? (

{conversationsError}

) : filteredConversations.length === 0 ? (

No conversations yet

) : ( filteredConversations.map(conv => ( )) )}
); }