266 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|