From 93a587f185ac5e3f86b7618771a27e598761cd04 Mon Sep 17 00:00:00 2001 From: Christopher Date: Wed, 28 Jan 2026 07:37:27 -0800 Subject: [PATCH] Refactor chat page useEffect organization and improve compose intent handling Reorganizes useEffect hooks in chat/page.tsx to follow a helper-functions-first structure, improving readability and maintainability. Adds error handling and user feedback for compose intent, preventing UI flash while processing. In send/route.ts, ensures remote user handles are fully qualified and adds debug logging. Temporarily disables key caching in keys/route.ts to address development key mismatches. --- src/app/api/chat/keys/route.ts | 13 +- src/app/api/chat/send/route.ts | 9 +- src/app/chat/page.tsx | 252 +++++++++++++++++---------------- 3 files changed, 148 insertions(+), 126 deletions(-) diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts index b252cf3..317706e 100644 --- a/src/app/api/chat/keys/route.ts +++ b/src/app/api/chat/keys/route.ts @@ -46,12 +46,13 @@ export async function GET(request: NextRequest) { ), }); - if (cached) { - console.log('[Chat Keys GET] Found cached remote key'); - return NextResponse.json({ - publicKey: cached.publicKey, - }); - } + // DEBUG: Force refresh for now to fix development key mismatches + // if (cached) { + // console.log('[Chat Keys GET] Found cached remote key'); + // return NextResponse.json({ + // publicKey: cached.publicKey, + // }); + // } // If not in cache, try to resolve DID to a node console.log('[Chat Keys GET] resolving DID to node...'); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 72756e6..cbb7a61 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -114,11 +114,18 @@ export async function POST(request: NextRequest) { }); if (!registryEntry) { + console.error('Recipient DID not found in registry:', recipientDid); return NextResponse.json({ error: 'Recipient not found in registry' }, { status: 404 }); } const targetDomain = registryEntry.nodeDomain; - const targetHandle = registryEntry.handle; // e.g. user@domain + // Ensure handle is fully qualified for remote users + let targetHandle = registryEntry.handle; + if (!targetHandle.includes('@') && targetDomain) { + targetHandle = `${targetHandle}@${targetDomain}`; + } + + console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`); // 2. Prepare Payload const messageData = { diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 5768f8c..6640e0d 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -63,64 +63,6 @@ export default function ChatPage() { // Track the current conversation ID to prevent race conditions const currentConversationIdRef = useRef(null); - // Load conversations - useEffect(() => { - if (user && isReady) { - 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, isReady]); - - // Handle Compose Intent - useEffect(() => { - if (composeHandle && isReady && !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) { - 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 }); - } - } catch (e) { - console.error("Failed to load user for compose", e); - } - }; - fetchUserAndInitDraft(); - } - } - }, [composeHandle, isReady, selectedConversation, conversations, loading, router]); - - // Legacy / V2 Hybrid State const [showDeleteModal, setShowDeleteModal] = useState(false); const [conversationToDelete, setConversationToDelete] = useState(null); @@ -130,6 +72,10 @@ export default function ChatPage() { const messagesContainerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + // ============================================ + // HELPER FUNCTIONS (Defined before useEffects) + // ============================================ + // Check if user is scrolled to bottom const checkIfAtBottom = () => { if (!messagesContainerRef.current) return true; @@ -150,56 +96,6 @@ export default function ChatPage() { } }; - // Redirect if not logged in - useEffect(() => { - if (user === null) { - router.push('/login'); - } - }, [user, router]); - - // Load messages when conversation is selected - useEffect(() => { - if (selectedConversation && isReady) { - // Update current conversation ref - currentConversationIdRef.current = selectedConversation.id; - - // 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 (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') { - loadMessages(selectedConversation.id); - } - }, 3000); - - return () => clearInterval(pollInterval); - } else if (!selectedConversation) { - // Clear messages when no conversation selected - currentConversationIdRef.current = null; - setMessages([]); - setLoadingMessages(false); - } - }, [selectedConversation, isReady]); - - // 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]); - const loadConversations = async (isInitialLoad = false) => { if (isInitialLoad) setLoading(true); try { @@ -247,12 +143,6 @@ export default function ChatPage() { // - We need the OTHER party's public key // - We use OUR private key - // console.log('[Chat UI] Decrypting message:', { - // isSentByMe: msg.isSentByMe, - // recipientDid: envelope.recipientDid, - // senderPublicKey: envelope.senderPublicKey?.substring(0, 20) + '...' - // }); - // If I sent this message, the "other party" is the recipient // If I received this message, the "other party" is the sender let otherPartyPublicKey = envelope.senderPublicKey; @@ -264,13 +154,12 @@ export default function ChatPage() { if (keyRes.ok) { const keyData = await keyRes.json(); otherPartyPublicKey = keyData.publicKey; - // console.log('[Chat UI] Fetched recipient public key:', otherPartyPublicKey?.substring(0, 20) + '...'); } } catch (e) { console.error('[Chat UI] Failed to fetch recipient key:', e); } } else { - // console.log('[Chat UI] Using sender public key from envelope'); + // Using sender public key from envelope } const plaintext = await decryptMessage( @@ -390,9 +279,6 @@ export default function ChatPage() { return; } - // Previously we auto-sent "👋" here. - // Now we just setup the draft conversation. - // Check if existing conversation const existing = conversations.find(c => c.participant2.handle.toLowerCase() === data.user.handle.toLowerCase() @@ -454,6 +340,125 @@ export default function ChatPage() { } }; + // ============================================ + // EFFECTS (Now that functions are defined) + // ============================================ + + // Load conversations + useEffect(() => { + if (user && isReady) { + 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, isReady]); + + // Handle Compose Intent + useEffect(() => { + if (composeHandle && isReady && !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) { + 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, isReady, 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 && isReady) { + // Update current conversation ref + currentConversationIdRef.current = selectedConversation.id; + + // 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 (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') { + loadMessages(selectedConversation.id); + } + }, 3000); + + return () => clearInterval(pollInterval); + } else if (!selectedConversation) { + // Clear messages when no conversation selected + currentConversationIdRef.current = null; + setMessages([]); + setLoadingMessages(false); + } + }, [selectedConversation, isReady]); + + // 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()) || @@ -511,6 +516,15 @@ export default function ChatPage() { ); } + // Prevent flash of list view while processing compose intent + if (composeHandle && !selectedConversation) { + return ( +
+ +
+ ); + } + // Thread View if (selectedConversation) { return (