Files
Synapsis/src/app/chat/page.tsx
T

266 lines
11 KiB
TypeScript

'use client';
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 { formatFullHandle } from '@/lib/utils/handle';
import { useRouter } 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;
}
export default function ChatPage() {
const { user } = useAuth();
const router = useRouter();
const { isReady, status, ensureReady, sendMessage, decryptMessage } = useChatEncryption();
// UI State
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
// Conversation State (Simplified for V2 Migration)
// We group messages by DID.
const [activeDid, setActiveDid] = useState<string | null>(null);
const [handles, setHandles] = useState<Record<string, string>>({}); // DID -> handle
const [showNewChat, setShowNewChat] = useState(false);
const [newChatHandle, setNewChatHandle] = useState('');
// Encryption Status
// status can be: idle, initializing, ready, error, generating_keys
// Redirect if not logged in
useEffect(() => {
if (!user) {
// router.push('/login'); // Handled by Layout generally, but safe here
}
}, [user, router]);
// Polling Inbox
useEffect(() => {
if (!isReady || !user) return;
const deviceId = localStorage.getItem('synapsis_device_id');
if (!deviceId) return;
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);
// Add to list if valid
const senderDid = envelope.did;
const handle = envelope.handle; // Sender handle in action
setHandles(prev => ({ ...prev, [senderDid]: handle }));
setMessages(prev => {
// Dedup by ID
if (prev.find(p => p.id === msg.id)) return prev;
return [...prev, {
id: msg.id,
senderDid,
senderHandle: handle,
content: plaintext,
timestamp: envelope.ts,
k: msg.id,
isMe: false
}];
});
}
}
}
} catch (e) {
console.error("Poll error", e);
}
};
const interval = setInterval(poll, 3000);
poll(); // Initial
return () => clearInterval(interval);
}, [isReady, decryptMessage, user]);
// Send Handler
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !activeDid) return;
setSending(true);
try {
// Send
await sendMessage(activeDid, newMessage);
// 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
}]);
setNewMessage('');
} catch (err: any) {
alert(`Send failed: ${err.message}`);
} finally {
setSending(false);
}
};
const startNewChat = async (e: React.FormEvent) => {
e.preventDefault();
if (!newChatHandle.trim()) return;
// Resolve Handle to DID
try {
const clean = newChatHandle.replace('@', '');
const res = await fetch(`/api/users/${encodeURIComponent(clean)}`);
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');
}
} catch (e) {
alert('Lookup failed');
}
};
// 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.
);
// 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)));
// Render
if (!user) return null;
if (status === 'initializing' || status === 'generating_keys') {
return (
<div className="flex-center" style={{ height: '50vh' }}>
<Loader2 className="animate-spin" />
<p style={{ marginLeft: 12 }}>Initializing Encryption...</p>
</div>
);
}
if (status === 'error') {
return <div className="p-8 text-center text-red-500">Encryption Error. Check console/logs.</div>;
}
return (
<div className="container max-w-4xl pt-4 h-screen flex flex-col">
<div className="flex h-full border rounded-lg overflow-hidden bg-background">
{/* Sidebar */}
<div className="w-1/3 border-r flex flex-col">
<div className="p-4 border-b bg-muted/20 flex justify-between items-center">
<h2 className="font-bold">Chats (V2)</h2>
<button onClick={() => setShowNewChat(true)}><Plus size={20} /></button>
</div>
{showNewChat && (
<form onSubmit={startNewChat} className="p-2 border-b">
<input
className="w-full p-2 text-sm border rounded"
placeholder="@handle"
value={newChatHandle}
onChange={e => setNewChatHandle(e.target.value)}
/>
</form>
)}
<div className="flex-1 overflow-y-auto">
{uniqueDids.map(did => (
<div
key={did}
onClick={() => setActiveDid(did)}
className={`p-4 border-b cursor-pointer hover:bg-muted/10 ${activeDid === did ? 'bg-muted/20' : ''}`}
>
<div className="font-medium">{handles[did] || did.slice(0, 16)}</div>
<div className="text-xs text-muted-foreground truncate">
{messages.filter(m => m.senderDid === did).pop()?.content.slice(0, 30)}
</div>
</div>
))}
{/* If active did is not in uniqueDids (e.g. new chat), show it */}
{activeDid && !uniqueDids.includes(activeDid) && (
<div className="p-4 bg-muted/20 border-b">
<div className="font-medium">{handles[activeDid] || activeDid}</div>
<div className="text-xs">New Conversation</div>
</div>
)}
</div>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col">
{activeDid ? (
<>
<div className="p-4 border-b font-bold bg-muted/10">
{handles[activeDid] || activeDid}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* 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 => (
<div key={msg.k} className={`flex ${msg.isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] p-3 rounded-xl ${msg.isMe ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
{msg.content}
</div>
</div>
))}
</div>
<form onSubmit={handleSend} className="p-4 border-t flex gap-2">
<input
className="flex-1 p-2 border rounded-md bg-background"
placeholder="Type a message..."
value={newMessage}
onChange={e => setNewMessage(e.target.value)}
/>
<button disabled={sending} type="submit" className="p-2 bg-primary text-primary-foreground rounded-md">
{sending ? <Loader2 className="animate-spin" /> : <Send size={20} />}
</button>
</form>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground flex-col gap-4">
<Shield size={48} className="opacity-20" />
<p>Select a secure conversation</p>
</div>
)}
</div>
</div>
</div>
);
}