Revamp chat UI and add V2 encrypted messaging support
Major update to chat page with new conversation list, message thread view, and support for V2 end-to-end encrypted messaging using DIDs. Adds chat lock state, message sending, conversation deletion, and improved polling. Updates user API and types to include DID and chatPublicKey. Fixes race conditions in feed loading, adds message button to user profile, and improves crypto and identity hooks for better locked/unlocked state detection.
This commit is contained in:
@@ -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
|
||||
|
||||
+515
-185
@@ -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<ChatMessage[]>([]);
|
||||
|
||||
|
||||
// Chat Data State
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
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('');
|
||||
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<Conversation | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex-center" style={{ height: '50vh' }}>
|
||||
<Loader2 className="animate-spin" />
|
||||
<p style={{ marginLeft: 12 }}>Initializing Encryption...</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', padding: '16px', textAlign: 'center' }}>
|
||||
<Lock size={48} style={{ color: 'var(--accent)', marginBottom: '16px' }} />
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Chat Locked</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', marginBottom: '24px' }}>
|
||||
Your end-to-end encrypted identity is locked. Please unlock it to view your messages.
|
||||
</p>
|
||||
<button onClick={() => setShowUnlockPrompt(true)} className="btn btn-primary">
|
||||
Unlock Identity
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div className="p-8 text-center text-red-500">Encryption Error. Check console/logs.</div>;
|
||||
// Loading State
|
||||
if (status === 'initializing' || status === 'generating_keys') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p style={{ marginTop: 16 }}>Initializing Secure Encrypted Chat...</p>
|
||||
</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>
|
||||
// Thread View
|
||||
if (selectedConversation) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: '600px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setSelectedConversation(null)}
|
||||
style={{ background: 'none', border: 'none', padding: '8px', cursor: 'pointer', color: 'var(--foreground)' }}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="avatar">
|
||||
{selectedConversation.participant2.avatarUrl ? (
|
||||
<img src={selectedConversation.participant2.avatarUrl} alt="" />
|
||||
) : (
|
||||
selectedConversation.participant2.displayName[0] || '?'
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{selectedConversation.participant2.displayName}</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
|
||||
{formatFullHandle(selectedConversation.participant2.handle)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--foreground-tertiary)' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
padding: '16px',
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
paddingBottom: '16px',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{messages.map((msg, i) => (
|
||||
<div key={msg.id || i} style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
maxWidth: '70%',
|
||||
marginLeft: msg.isSentByMe ? 'auto' : '0',
|
||||
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
|
||||
}}>
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{msg.isSentByMe ? (
|
||||
user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : user.displayName[0]
|
||||
) : (
|
||||
msg.senderAvatarUrl ? <img src={msg.senderAvatarUrl} alt="" /> : msg.senderDisplayName?.[0]
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: '16px',
|
||||
background: msg.isSentByMe ? 'var(--accent)' : 'var(--background-secondary)',
|
||||
color: msg.isSentByMe ? '#000' : 'var(--foreground)',
|
||||
border: msg.isSentByMe ? 'none' : '1px solid var(--border)',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{msg.decryptedContent || msg.encryptedContent}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</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 ref={messagesEndRef} />
|
||||
</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>
|
||||
)}
|
||||
{/* Input */}
|
||||
<div className="compose" style={{ borderTop: '1px solid var(--border)', background: 'var(--background)', flexShrink: 0 }}>
|
||||
<form onSubmit={handleSendMessage} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Type a message..."
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={!newMessage.trim() || sending} className="btn btn-primary">
|
||||
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/* Delete Modal */}
|
||||
{showDeleteModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--background)',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '320px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '18px', fontWeight: 600 }}>Delete Conversation</h3>
|
||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', marginBottom: '24px' }}>
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<button
|
||||
disabled={isDeleting}
|
||||
onClick={() => handleDeleteConversation('self')}
|
||||
className="btn"
|
||||
style={{ justifyContent: 'center', width: '100%' }}
|
||||
>
|
||||
Delete for me
|
||||
</button>
|
||||
<button
|
||||
disabled={isDeleting}
|
||||
onClick={() => handleDeleteConversation('both')}
|
||||
className="btn btn-danger" // Assuming btn-danger exists or falls back
|
||||
style={{ justifyContent: 'center', width: '100%', color: 'var(--destructive)', background: 'var(--destructive-10)' }}
|
||||
>
|
||||
Delete for everyone
|
||||
</button>
|
||||
<button
|
||||
disabled={isDeleting}
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
className="btn btn-ghost"
|
||||
style={{ justifyContent: 'center', width: '100%' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LIST VIEW
|
||||
return (
|
||||
<>
|
||||
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>Messages</h1>
|
||||
<button onClick={() => setShowNewChat(true)} className="btn btn-primary btn-sm">
|
||||
<Plus size={16} style={{ marginRight: 6 }} /> New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewChat ? (
|
||||
<form onSubmit={startNewChat} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="@username"
|
||||
className="input"
|
||||
value={newChatHandle}
|
||||
onChange={e => setNewChatHandle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button type="button" onClick={() => setShowNewChat(false)} className="btn btn-ghost btn-sm">Cancel</button>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={sending}>Start</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--background-secondary)', borderRadius: 'var(--radius-full)', padding: '8px 16px', border: '1px solid var(--border)' }}>
|
||||
<Search size={16} style={{ color: 'var(--foreground-tertiary)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
style={{ background: 'transparent', border: 'none', outline: 'none', flex: 1, color: 'var(--foreground)' }}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
</div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 16px', color: 'var(--foreground-tertiary)' }}>
|
||||
<MessageCircle size={48} style={{ margin: '0 auto 16px', opacity: 0.5 }} />
|
||||
<p>No conversations yet</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className="post"
|
||||
onClick={() => setSelectedConversation(conv)}
|
||||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }}
|
||||
>
|
||||
<div className="avatar">
|
||||
{conv.participant2.avatarUrl ? <img src={conv.participant2.avatarUrl} alt="" /> : conv.participant2.displayName[0]}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontWeight: 600 }}>{conv.participant2.displayName}</span>
|
||||
{conv.unreadCount > 0 && <span className="badge">{conv.unreadCount}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{conv.lastMessagePreview}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+22
-16
@@ -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 }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{user.displayName || user.handle}</span>
|
||||
{user.isBot && (
|
||||
<span
|
||||
style={{
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
@@ -184,7 +184,7 @@ export default function ProfilePage() {
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
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,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
@@ -504,6 +504,12 @@ export default function ProfilePage() {
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
)}
|
||||
{/* Message Button (V2 Chat) */}
|
||||
{user.did && (
|
||||
<Link href={`/chat?compose=${user.handle}`} className="btn btn-ghost" style={{ padding: '8px' }}>
|
||||
<Mail size={20} />
|
||||
</Link>
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
@@ -623,7 +629,7 @@ export default function ProfilePage() {
|
||||
{(user as any).botOwner && (
|
||||
<>
|
||||
{' · Managed by '}
|
||||
<Link
|
||||
<Link
|
||||
href={`/u/${(user as any).botOwner.handle}`}
|
||||
style={{ color: 'var(--accent)', fontWeight: 500 }}
|
||||
>
|
||||
@@ -798,7 +804,7 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||
{(user?.isBot
|
||||
{(user?.isBot
|
||||
? ['posts', 'replies', 'followers', 'following'] as const
|
||||
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
|
||||
).map(tab => (
|
||||
|
||||
+19
-10
@@ -162,12 +162,17 @@ export async function encrypt(
|
||||
? new TextEncoder().encode(plaintext)
|
||||
: plaintext;
|
||||
|
||||
const algorithm: any = {
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
};
|
||||
|
||||
if (associatedData) {
|
||||
algorithm.additionalData = associatedData;
|
||||
}
|
||||
|
||||
const encrypted = await cryptoSubtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
additionalData: associatedData
|
||||
},
|
||||
algorithm,
|
||||
key,
|
||||
data
|
||||
);
|
||||
@@ -196,12 +201,16 @@ export async function decrypt(
|
||||
const iv = base64ToArrayBuffer(ivBase64);
|
||||
|
||||
try {
|
||||
const algorithm: any = {
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
};
|
||||
if (associatedData) {
|
||||
algorithm.additionalData = associatedData;
|
||||
}
|
||||
|
||||
const decrypted = await cryptoSubtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
additionalData: associatedData
|
||||
},
|
||||
algorithm,
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
unlockChatStorage,
|
||||
loadDeviceKeys,
|
||||
@@ -40,6 +40,44 @@ export function useChatEncryption() {
|
||||
// Session Cache (In-Memory)
|
||||
const sessionsRef = useRef<Map<string, RatchetState>>(new Map());
|
||||
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
|
||||
// Auto-detect if storage is unlocked (by AuthContext)
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsLocked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = async () => {
|
||||
const unlocked = isStorageUnlocked();
|
||||
setIsLocked(!unlocked);
|
||||
|
||||
if (unlocked) {
|
||||
try {
|
||||
// If storage is open, verify keys exist
|
||||
// If so, we are ready.
|
||||
// If not, we might need ensureReady to generate, but usually they exist.
|
||||
const keys = await loadDeviceKeys();
|
||||
if (keys) {
|
||||
setIsReady(true);
|
||||
setStatus('ready');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auto-ready check failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
check(); // Checks immediately
|
||||
const interval = setInterval(check, 1000); // And polls
|
||||
return () => clearInterval(interval);
|
||||
}, [isReady]);
|
||||
|
||||
// ... (ensureReady, sendMessage, decryptMessage) ...
|
||||
|
||||
|
||||
|
||||
const ensureReady = useCallback(async (password: string, userId: string) => {
|
||||
setStatus('initializing');
|
||||
try {
|
||||
@@ -279,6 +317,7 @@ export function useChatEncryption() {
|
||||
|
||||
return {
|
||||
isReady,
|
||||
isLocked,
|
||||
status,
|
||||
ensureReady,
|
||||
sendMessage,
|
||||
|
||||
@@ -29,9 +29,18 @@ export function useUserIdentity() {
|
||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||
|
||||
// Check status on mount / updates
|
||||
// Check status on mount / updates and poll for changes in singleton
|
||||
useEffect(() => {
|
||||
const hasKey = !!keyStore.getPrivateKey();
|
||||
setIsUnlocked(hasKey);
|
||||
const check = () => {
|
||||
const hasKey = !!keyStore.getPrivateKey();
|
||||
setIsUnlocked(hasKey);
|
||||
// We could also try to recover identity data if it's missing but key exists?
|
||||
// But identity data usually comes from initializeIdentity
|
||||
};
|
||||
|
||||
check();
|
||||
const interval = setInterval(check, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface User {
|
||||
isBot?: boolean;
|
||||
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
|
||||
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
|
||||
did?: string;
|
||||
chatPublicKey?: string;
|
||||
botOwner?: {
|
||||
id: string;
|
||||
handle: string;
|
||||
|
||||
Reference in New Issue
Block a user