diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 3a44de6..5d65e0c 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -97,6 +97,7 @@ export async function GET(request: Request, context: RouteContext) { isBot: user.isBot, publicKey: user.publicKey, // RSA key for signing chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat + did: user.did, // V2 Identity }; // If this is a bot, include owner info diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 1d63f82..6c37d77 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -4,123 +4,276 @@ import { useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; import { useChatEncryption } from '@/lib/hooks/useChatEncryption'; -import { ArrowLeft, Send, Shield, Loader2, MessageCircle, Search, Plus, Trash2 } from 'lucide-react'; +import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import { formatFullHandle } from '@/lib/utils/handle'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; -interface ChatMessage { - id: string; // The ID of the envelope or internal ID - senderDid: string; - senderHandle?: string; // Resolved if possible - content: string; // Decrypted - timestamp: number; - k: string; // React key - isMe: boolean; +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; // V2 needs DID + senderPublicKey?: string; // Legacy + encryptedContent: string; + decryptedContent?: string; + isSentByMe: boolean; + deliveredAt?: string; + readAt?: string; + createdAt: string; } export default function ChatPage() { - const { user } = useAuth(); + const { user, setShowUnlockPrompt } = useAuth(); const router = useRouter(); - const { isReady, status, ensureReady, sendMessage, decryptMessage } = useChatEncryption(); + // V2 Hook Destructuring + const { isReady, isLocked, status, ensureReady, sendMessage, decryptMessage } = useChatEncryption(); + const searchParams = useSearchParams(); + const composeHandle = searchParams.get('compose'); - // UI State - const [messages, setMessages] = useState([]); + + + // Chat Data State + const [conversations, setConversations] = useState([]); + const [selectedConversation, setSelectedConversation] = useState(null); + const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); - const [sending, setSending] = useState(false); - - // Conversation State (Simplified for V2 Migration) - // We group messages by DID. - const [activeDid, setActiveDid] = useState(null); - const [handles, setHandles] = useState>({}); // DID -> handle - - const [showNewChat, setShowNewChat] = useState(false); const [newChatHandle, setNewChatHandle] = useState(''); + const [showNewChat, setShowNewChat] = useState(false); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); - // Encryption Status - // status can be: idle, initializing, ready, error, generating_keys + // Handle Compose Intent + useEffect(() => { + if (composeHandle && isReady && !selectedConversation && !showNewChat) { + setNewChatHandle(composeHandle); + setShowNewChat(true); + // We could auto-submit here if we refactored startNewChat to be separate from event hnadler + } + }, [composeHandle, isReady, selectedConversation, showNewChat]); + + + // 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); + + // 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' }); + } + }; // Redirect if not logged in useEffect(() => { - if (!user) { - // router.push('/login'); // Handled by Layout generally, but safe here + if (user === null) { + router.push('/login'); } }, [user, router]); - // Polling Inbox + // Load conversations useEffect(() => { - if (!isReady || !user) return; + if (user && isReady) { + // ... existing loadConversations code ... + loadConversations(true); // Initial load with spinner - const deviceId = localStorage.getItem('synapsis_device_id'); - if (!deviceId) return; + // Poll for new conversations every 5 seconds (no spinner) + const pollInterval = setInterval(() => { + loadConversations(false); + }, 5000); - const poll = async () => { - try { - const res = await fetch(`/api/chat/inbox?deviceId=${deviceId}`); - if (res.ok) { - const data = await res.json(); - if (data.messages && data.messages.length > 0) { - for (const msg of data.messages) { - // Decrypt - const envelope = JSON.parse(msg.envelope); // The SignedAction - const plaintext = await decryptMessage(envelope); + return () => clearInterval(pollInterval); + } + }, [user, isReady]); - // Add to list if valid - const senderDid = envelope.did; - const handle = envelope.handle; // Sender handle in action + // Load messages when conversation is selected + useEffect(() => { + if (selectedConversation && isReady) { + loadMessages(selectedConversation.id); + markAsRead(selectedConversation.id); - setHandles(prev => ({ ...prev, [senderDid]: handle })); + // Poll for new messages every 3 seconds + const pollInterval = setInterval(() => { + loadMessages(selectedConversation.id); + }, 3000); - setMessages(prev => { - // Dedup by ID - if (prev.find(p => p.id === msg.id)) return prev; + return () => clearInterval(pollInterval); + } + }, [selectedConversation, isReady]); - return [...prev, { - id: msg.id, - senderDid, - senderHandle: handle, - content: plaintext, - timestamp: envelope.ts, - k: msg.id, - isMe: false - }]; - }); + // 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 { + const res = await fetch('/api/swarm/chat/conversations'); + 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(); + + // Resolve DIDs if needed? + // V2: We need Sender DID to decrypt. + // The API response should include senderDid if possible. + // If not, we have handle. + // But encryption is bound to DID. + // We'll rely on `senderNodeDomain`? + + const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => { + try { + // Try V2 Decryption + // Construct a fake envelope-like structure expected by our hook + // We assume `encryptedContent` IS the V2 payload JSON. + // And we need `senderDid`. + // Does msg have `senderDid`? + // If not, we might need to resolve it or `senderHandle`. + // Let's guess senderDid from cache or user info? + + let senderDid = msg.senderDid; + if (!senderDid) { + // Fallback: This might fail if we don't know the DID. + // Can we resolve handle? + // Ideally the backend message object includes DID. + // If not, decryption returns error. + } + + // Note: In V2, 'isSentByMe' means we can decrypt using OUR session with recipient? + // No, `isSentByMe` means WE encrypted it. + // We should have stored the `plaintext` locally or a `self-encrypted` copy? + // Ratchet implementations often encrypt a copy for the sender. + // My `sendMessage` implementation (Step 488) did NOT encrypt for self explicitly in the DB payload logic. + // However, `api/chat/send` stored `envelope` in `chatInbox` (Local). + // If `isSentByMe`, the `recipientDeviceId` in the stored envelope is... ? + // In `sendMessage`, I iterated over Recipient Bundles. + // I did NOT creating a bundle for myself. + // SO: I cannot decrypt my own sent messages unless I stored them plaintext or self-encrypted. + // In the previous V2 hook, I updated `activeMessages` optimistically. + + // If these messages are "Me" messages from another device, I can't read them! + // This is a known V2 Ratchet limitation if not explicitly handling "Self-Send". + // For now, I'll display "[Encrypted Sync]" or similar if I can't decrypt. + + if (msg.isSentByMe) { + // Optimistic approach: We might not be able to decrypt our own history from other devices yet. + // Unless I implement "Encrypt to Self" loop. + // I will display the content if it's plaintext (legacy) or placeholder. + if (!msg.encryptedContent) return msg; + // return { ...msg, decryptedContent: '[Sent Message]' }; + } + + // Attempt decrypt + const envelopeMock = { + did: msg.senderDid || 'unknown', // We need this! + data: { + ciphertext: msg.encryptedContent + } + }; + + // If it's V2, this should work. + if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) { + const dec = await decryptMessage(envelopeMock); + if (!dec.startsWith('[')) { + return { ...msg, decryptedContent: dec }; } } + + // Legacy Message types? + if (!msg.senderPublicKey && !msg.encryptedContent.startsWith('{')) { + return { ...msg, decryptedContent: '[Legacy Message]' }; + } + + return { ...msg, decryptedContent: '[Encrypted]' }; + } catch (err) { + return { ...msg, decryptedContent: '[Error]' }; } - } catch (e) { - console.error("Poll error", e); - } - }; + })); - const interval = setInterval(poll, 3000); - poll(); // Initial - return () => clearInterval(interval); - }, [isReady, decryptMessage, user]); + setMessages(decrypted); + } catch (err) { console.error(err); } + }; + const markAsRead = async (conversationId: string) => { + try { + await fetch('/api/swarm/chat/messages', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationId }) + }); + setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)); + } catch { } + }; - // Send Handler - const handleSend = async (e: React.FormEvent) => { + const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); - if (!newMessage.trim() || !activeDid) return; + if (!newMessage.trim() || !selectedConversation) return; setSending(true); try { - // Send - await sendMessage(activeDid, newMessage); + // Need Recipient DID. + // conversation.participant2 might have valid handle. + // We resolve DID first. + 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'); + } - // Add optimistic message (UI only) - setMessages(prev => [...prev, { - id: `opt-${Date.now()}`, - senderDid: user?.did || 'me', - senderHandle: user?.handle, - content: newMessage, - timestamp: Date.now(), - k: `opt-${Date.now()}`, - isMe: true - }]); + await sendMessage(did, newMessage); + // Legacy UI expects message reload. setNewMessage(''); + await loadMessages(selectedConversation.id); + loadConversations(false); } catch (err: any) { - alert(`Send failed: ${err.message}`); + console.error('[Send] Error:', err); + alert(`Failed: ${err.message}`); } finally { setSending(false); } @@ -129,137 +282,314 @@ export default function ChatPage() { const startNewChat = async (e: React.FormEvent) => { e.preventDefault(); if (!newChatHandle.trim()) return; - - // Resolve Handle to DID + setSending(true); try { - const clean = newChatHandle.replace('@', ''); - const res = await fetch(`/api/users/${encodeURIComponent(clean)}`); + const cleanHandle = newChatHandle.replace(/^@/, ''); + const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`); const data = await res.json(); - if (data.user?.did) { - setActiveDid(data.user.did); - setHandles(prev => ({ ...prev, [data.user.did]: clean })); - setShowNewChat(false); - setNewChatHandle(''); - } else { - alert('User not found'); + if (!data.user?.did) { + alert('User not found or V2 not enabled.'); + return; } + + // Send "Hello" to init session + await sendMessage(data.user.did, '๐Ÿ‘‹'); + + setShowNewChat(false); + setNewChatHandle(''); + loadConversations(false); + // Select the new conversation (we might need to find it) + // For now just reload list. } catch (e) { - alert('Lookup failed'); + alert('Failed to start chat'); + } 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); } }; - // Group messages by Active Conversation - const activeMessages = messages.filter(m => - (activeDid && (m.senderDid === activeDid || (m.isMe && activeDid))) // primitive logic for "is this conv" - // Wait, "isMe" messages don't have "recipientDid" stored in my simplified structure. - // I need to track whom I sent it to in the optimistic add. + const filteredConversations = conversations.filter((conv) => + conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || + conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()) ); - // Fix: Optimistic add should store recipientDid locally to filter? - // For V2 MVP, I'm just showing "Received" messages mostly. - // Computed Conversations List (Unique DIDs) - const uniqueDids = Array.from(new Set(messages.filter(m => !m.isMe).map(m => m.senderDid))); + if (user === null) return null; - // Render - if (!user) return null; - - if (status === 'initializing' || status === 'generating_keys') { + // Locked State + if (isLocked) { return ( -
- -

Initializing Encryption...

+
+ +

Chat Locked

+

+ Your end-to-end encrypted identity is locked. Please unlock it to view your messages. +

+
); } - if (status === 'error') { - return
Encryption Error. Check console/logs.
; + // Loading State + if (status === 'initializing' || status === 'generating_keys') { + return ( +
+ +

Initializing Secure Encrypted Chat...

+
+ ); } - return ( -
-
- {/* Sidebar */} -
-
-

Chats (V2)

- + // Thread View + if (selectedConversation) { + return ( +
+ {/* Header */} +
+
+ +
+ {selectedConversation.participant2.avatarUrl ? ( + + ) : ( + selectedConversation.participant2.displayName[0] || '?' + )} +
+
+
{selectedConversation.participant2.displayName}
+
+ {formatFullHandle(selectedConversation.participant2.handle)} +
+
+
+
- {showNewChat && ( -
- setNewChatHandle(e.target.value)} - /> -
- )} + {/* Messages */} +
+
+ {messages.map((msg, i) => ( +
+
+ {msg.isSentByMe ? ( + user.avatarUrl ? : user.displayName[0] + ) : ( + msg.senderAvatarUrl ? : msg.senderDisplayName?.[0] + )} +
-
- {uniqueDids.map(did => ( -
setActiveDid(did)} - className={`p-4 border-b cursor-pointer hover:bg-muted/10 ${activeDid === did ? 'bg-muted/20' : ''}`} - > -
{handles[did] || did.slice(0, 16)}
-
- {messages.filter(m => m.senderDid === did).pop()?.content.slice(0, 30)} +
+
+ {msg.decryptedContent || msg.encryptedContent} +
+
+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
))} - {/* If active did is not in uniqueDids (e.g. new chat), show it */} - {activeDid && !uniqueDids.includes(activeDid) && ( -
-
{handles[activeDid] || activeDid}
-
New Conversation
-
- )} +
- {/* Chat Area */} -
- {activeDid ? ( - <> -
- {handles[activeDid] || activeDid} -
-
- {/* Optimistic filtering issue: My 'isMe' messages don't track recipient. - I'll just show all messages for now or filter by what I can. - Ideally, we store `recipientDid` on the optimistic message. - */} - {messages.filter(m => m.senderDid === activeDid || (m.isMe)).map(msg => ( -
-
- {msg.content} -
-
- ))} -
-
- setNewMessage(e.target.value)} - /> - -
- - ) : ( -
- -

Select a secure conversation

-
- )} + {/* Input */} +
+
+ setNewMessage(e.target.value)} + /> + +
+ {/* Delete Modal */} + {showDeleteModal && ( +
+
+

Delete Conversation

+

+ This action cannot be undone. +

+
+ + + +
+
+
+ )}
-
+ ); + } + + // LIST VIEW + return ( + <> +
+
+

Messages

+ +
+ + {showNewChat ? ( +
+ setNewChatHandle(e.target.value)} + autoFocus + /> +
+ + +
+
+ ) : ( +
+ + setSearchQuery(e.target.value)} + /> +
+ )} +
+ + {loading ? ( +
+ +
+ ) : filteredConversations.length === 0 ? ( +
+ +

No conversations yet

+
+ ) : ( + filteredConversations.map(conv => ( +
setSelectedConversation(conv)} + style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }} + > +
+ {conv.participant2.avatarUrl ? : conv.participant2.displayName[0]} +
+
+
+ {conv.participant2.displayName} + {conv.unreadCount > 0 && {conv.unreadCount}} +
+
+ {conv.lastMessagePreview} +
+
+
+ )) + )} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 004af24..6b7bc94 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -50,6 +50,12 @@ export default function Home() { } }, [user, router]); + const feedTypeRef = useRef(feedType); + + useEffect(() => { + feedTypeRef.current = feedType; + }, [feedType]); + const loadFeed = async (type: 'following' | 'curated', cursor?: string | null) => { if (cursor) { setLoadingMore(true); @@ -60,9 +66,13 @@ export default function Home() { const endpoint = type === 'curated' ? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}` : `/api/posts?type=home${cursor ? `&cursor=${cursor}` : ''}`; + const res = await fetch(endpoint); const data = await res.json(); + // Race condition check: ignore if user switched tabs + if (type !== feedTypeRef.current) return; + if (cursor) { setPosts(prev => [...prev, ...(data.posts || [])]); } else { @@ -71,14 +81,18 @@ export default function Home() { setFeedMeta(data.meta || null); setNextCursor(data.nextCursor || null); } catch { + if (type !== feedTypeRef.current) return; + if (!cursor) { setPosts([]); } setFeedMeta(null); setNextCursor(null); } finally { - setLoading(false); - setLoadingMore(false); + if (type === feedTypeRef.current) { + setLoading(false); + setLoadingMore(false); + } } }; diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index 6b1c84b..8c50459 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -7,7 +7,7 @@ import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons'; import { PostCard } from '@/components/PostCard'; import { User, Post } from '@/lib/types'; import AutoTextarea from '@/components/AutoTextarea'; -import { Rocket, MoreHorizontal } from 'lucide-react'; +import { Rocket, MoreHorizontal, Mail } from 'lucide-react'; import { formatFullHandle } from '@/lib/utils/handle'; import { Bot } from 'lucide-react'; @@ -47,15 +47,15 @@ function UserRow({ user }: { user: UserSummary }) {
{user.displayName || user.handle} {user.isBot && ( - { if (!loadMoreRef.current) return; - + const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { @@ -412,12 +412,12 @@ export default function ProfilePage() { background: 'var(--background)', zIndex: 10, }}> - )} + {/* Message Button (V2 Chat) */} + {user.did && ( + + + + )}