Add Chat page with E2E encryption setup and real-time messaging
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useChatStore } from '@/stores/chat-store';
|
||||
import { useFriendsStore } from '@/stores/friends-store';
|
||||
import { getCurrentPrivateKey, hasStoredKey, setupEncryption, unlockEncryption } from '@/lib/crypto';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Send, Lock, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Chat() {
|
||||
const { friendId } = useParams<{ friendId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const {
|
||||
messages,
|
||||
typingFriendIds,
|
||||
loading,
|
||||
hasMore,
|
||||
connect,
|
||||
disconnect,
|
||||
openConversation,
|
||||
loadMoreMessages,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
} = useChatStore();
|
||||
const { friends } = useFriendsStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [encryptionReady, setEncryptionReady] = useState(false);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [needsUnlock, setNeedsUnlock] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [settingUp, setSettingUp] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const friend = friends.find((f) => f.friend.id === friendId)?.friend;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check encryption state
|
||||
checkEncryptionState();
|
||||
|
||||
return () => {
|
||||
clearChat();
|
||||
};
|
||||
}, [user, navigate, clearChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (encryptionReady && friendId) {
|
||||
connect();
|
||||
openConversation(friendId);
|
||||
}
|
||||
return () => {
|
||||
if (encryptionReady) disconnect();
|
||||
};
|
||||
}, [encryptionReady, friendId, connect, disconnect, openConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
async function checkEncryptionState() {
|
||||
if (getCurrentPrivateKey()) {
|
||||
setEncryptionReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasKey = await hasStoredKey();
|
||||
if (hasKey) {
|
||||
setNeedsUnlock(true);
|
||||
} else {
|
||||
setNeedsSetup(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetup() {
|
||||
if (!password) return;
|
||||
setSettingUp(true);
|
||||
try {
|
||||
await setupEncryption(password);
|
||||
setEncryptionReady(true);
|
||||
setNeedsSetup(false);
|
||||
toast.success('Encryption keys generated!');
|
||||
} catch {
|
||||
toast.error('Failed to set up encryption');
|
||||
} finally {
|
||||
setSettingUp(false);
|
||||
setPassword('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlock() {
|
||||
if (!password) return;
|
||||
setSettingUp(true);
|
||||
try {
|
||||
const success = await unlockEncryption(password);
|
||||
if (success) {
|
||||
setEncryptionReady(true);
|
||||
setNeedsUnlock(false);
|
||||
} else {
|
||||
toast.error('Incorrect password');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to unlock encryption');
|
||||
} finally {
|
||||
setSettingUp(false);
|
||||
setPassword('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || !friendId || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await sendMessage(friendId, input.trim());
|
||||
setInput('');
|
||||
} catch {
|
||||
toast.error('Failed to send message');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || !hasMore || loading) return;
|
||||
if (container.scrollTop === 0) {
|
||||
loadMoreMessages();
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Encryption setup/unlock screens
|
||||
if (needsSetup || needsUnlock) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mx-auto max-w-sm space-y-6 pt-12">
|
||||
<div className="text-center">
|
||||
<Lock className="mx-auto mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<h2 className="text-xl font-bold">
|
||||
{needsSetup ? 'Set Up Encrypted Chat' : 'Unlock Encrypted Chat'}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{needsSetup
|
||||
? 'Enter your account password to generate encryption keys. Messages are encrypted end-to-end.'
|
||||
: 'Enter your password to decrypt your messages.'}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (needsSetup) { handleSetup(); } else { handleUnlock(); }
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={settingUp || !password}>
|
||||
{settingUp && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{needsSetup ? 'Generate Keys' : 'Unlock'}
|
||||
</Button>
|
||||
</form>
|
||||
{needsSetup && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
If you clear browser data, old messages become unreadable. You can regenerate keys but won't be able to decrypt past messages.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-border pb-3">
|
||||
<Link to="/friends">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{(friend?.displayName || friend?.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{friend?.displayName || friend?.username || 'Unknown'}</p>
|
||||
{typingFriendIds.has(friendId!) && (
|
||||
<p className="text-xs text-muted-foreground">typing...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Lock className="ml-auto h-4 w-4 text-green-500" title="End-to-end encrypted" />
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto py-4 space-y-2"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && !loading && (
|
||||
<p className="text-center text-sm text-muted-foreground py-8">
|
||||
No messages yet. Say hello!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => {
|
||||
const isMine = msg.senderId === user.id;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${isMine ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${
|
||||
isMine
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
{msg.decryptionFailed ? (
|
||||
<div className="flex items-center gap-1 text-xs opacity-70">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Cannot decrypt message
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
<p className={`mt-1 text-[10px] ${isMine ? 'text-primary-foreground/60' : 'text-muted-foreground'}`}>
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSend} className="flex gap-2 border-t border-border pt-3">
|
||||
<Input
|
||||
placeholder="Type a message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={!input.trim() || sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user