feat: Implement a new chat widget with enhanced encryption handling and supporting utility scripts, replacing the old chat page.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { db, users } from './src/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function checkKeys() {
|
||||
const allUsers = await db.select({
|
||||
handle: users.handle,
|
||||
hasChatPublicKey: users.chatPublicKey,
|
||||
hasChatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted,
|
||||
}).from(users);
|
||||
|
||||
console.log('Users and their chat keys:');
|
||||
allUsers.forEach(u => {
|
||||
console.log(`- ${u.handle}: chatPublicKey=${!!u.hasChatPublicKey}, chatPrivateKeyEncrypted=${!!u.hasChatPrivateKeyEncrypted}`);
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
checkKeys().catch(console.error);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { db, chatMessages } from './src/db';
|
||||
import { isNull } from 'drizzle-orm';
|
||||
|
||||
async function cleanOldMessages() {
|
||||
console.log('Deleting old RSA-encrypted messages...');
|
||||
|
||||
const result = await db.delete(chatMessages)
|
||||
.where(isNull(chatMessages.senderChatPublicKey));
|
||||
|
||||
console.log('Deleted old messages. Now only E2E encrypted messages remain.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
cleanOldMessages().catch(console.error);
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dotenv/config';
|
||||
import { db } from './src/db/index';
|
||||
import { chatMessages } from './src/db/schema';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
async function main() {
|
||||
console.log('--- LATEST CHAT MESSAGES ---');
|
||||
try {
|
||||
const messages = await db.select().from(chatMessages).orderBy(desc(chatMessages.createdAt)).limit(20);
|
||||
console.log(`Found ${messages.length} messages.`);
|
||||
messages.forEach(m => {
|
||||
console.log(`\nID: ${m.id}`);
|
||||
console.log(`Sender: ${m.senderHandle}`);
|
||||
console.log(`Created: ${m.createdAt}`);
|
||||
console.log(`EncryptedContent (${m.encryptedContent?.length} chars): ${m.encryptedContent}`);
|
||||
console.log(`SenderEncryptedContent (${m.senderEncryptedContent?.length} chars): ${m.senderEncryptedContent}`);
|
||||
console.log('-----------------------------------');
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -52,7 +52,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 });
|
||||
}
|
||||
const data = parseResult.data;
|
||||
console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle);
|
||||
console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey);
|
||||
|
||||
// Get sender info
|
||||
const sender = await db.query.users.findFirst({
|
||||
@@ -207,7 +207,7 @@ export async function POST(request: NextRequest) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
const swarmMessageId = `swarm:${nodeDomain}:${messageId}`;
|
||||
|
||||
console.log('[Chat Send] Inserting message into DB');
|
||||
console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey);
|
||||
const [newMessage] = await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: sender.handle,
|
||||
@@ -216,7 +216,8 @@ export async function POST(request: NextRequest) {
|
||||
senderNodeDomain: null, // Local sender
|
||||
encryptedContent,
|
||||
senderEncryptedContent,
|
||||
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, // For E2E decryption
|
||||
// Use client-provided key first, fall back to database
|
||||
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
|
||||
swarmMessageId,
|
||||
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
|
||||
readAt: null,
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
/* Chat Page Styles */
|
||||
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
/* Use dynamic viewport height for mobile browsers */
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.chat-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.chat-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-sidebar-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.chat-search svg {
|
||||
color: var(--foreground-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-search input::placeholder {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
/* Conversations List */
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--background-tertiary);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.conversation-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.conversation-avatar span {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conversation-handle {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main Chat Area */
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-header-avatar span {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header-info h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 2px 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-header-info p {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message.sent {
|
||||
flex-direction: row-reverse;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-avatar span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message.sent .message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--border);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.sent .message-bubble {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.encrypted-indicator {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.encrypted-preview {
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--foreground-tertiary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.delivery-status {
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
/* Chat Input */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-input input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input input::placeholder {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.chat-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.chat-empty-state svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.chat-empty-state h2,
|
||||
.chat-empty-state h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-empty-state p {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-tertiary);
|
||||
margin: 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-secondary);
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.modal-content form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-content input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-content input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--background-tertiary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--background-tertiary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
grid-template-columns: 1fr;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
|
||||
import { MessageCircle, Send, ArrowLeft, Search, Plus, Lock, Shield, Key } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import './chat.css';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
participant2: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
chatPublicKey: string | null;
|
||||
};
|
||||
lastMessageAt: string;
|
||||
lastMessagePreview: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
senderHandle: string;
|
||||
senderDisplayName?: string;
|
||||
senderAvatarUrl?: string;
|
||||
senderPublicKey?: string;
|
||||
encryptedContent: string;
|
||||
decryptedContent?: string;
|
||||
isSentByMe: boolean;
|
||||
deliveredAt?: string;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { user } = useAuth();
|
||||
const { keys, isReady, hasKeys, isRegistering, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [newChatHandle, setNewChatHandle] = useState('');
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [recipientPublicKey, setRecipientPublicKey] = useState<string | null>(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isProcessingPassword, setIsProcessingPassword] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { if (user && hasKeys) loadConversations(); }, [user, hasKeys]);
|
||||
useEffect(() => { if (selectedConversation && hasKeys) { loadMessages(selectedConversation.id); markAsRead(selectedConversation.id); fetchRecipientKey(selectedConversation.participant2.handle); } }, [selectedConversation, hasKeys]);
|
||||
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const fetchRecipientKey = async (handle: string) => { try { const res = await fetch(`/api/users/${encodeURIComponent(handle)}`); const data = await res.json(); setRecipientPublicKey(data.user?.chatPublicKey || null); } catch { setRecipientPublicKey(null); } };
|
||||
const loadConversations = async () => { try { const res = await fetch('/api/swarm/chat/conversations'); const data = await res.json(); setConversations(data.conversations || []); } catch { } finally { setLoading(false); } };
|
||||
const loadMessages = async (conversationId: string) => {
|
||||
console.log('[LoadMessages] Starting load for conversation:', conversationId);
|
||||
try {
|
||||
// Ensure we have the recipient's key before trying to decrypt
|
||||
let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey;
|
||||
|
||||
console.log('[LoadMessages] Initial chatPartnerKey:', !!chatPartnerKey);
|
||||
|
||||
// If we don't have the key yet, fetch it
|
||||
if (!chatPartnerKey && selectedConversation?.participant2?.handle) {
|
||||
console.log('[LoadMessages] Fetching recipient key for:', selectedConversation.participant2.handle);
|
||||
try {
|
||||
const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`);
|
||||
const userData = await userRes.json();
|
||||
chatPartnerKey = userData.user?.chatPublicKey || null;
|
||||
console.log('[LoadMessages] Fetched chatPartnerKey:', !!chatPartnerKey);
|
||||
if (chatPartnerKey) setRecipientPublicKey(chatPartnerKey);
|
||||
} catch (e) {
|
||||
console.error('[LoadMessages] Failed to fetch recipient key:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`);
|
||||
const data = await res.json();
|
||||
|
||||
console.log('[LoadMessages] Received messages:', data.messages?.length || 0);
|
||||
|
||||
const decrypted = await Promise.all((data.messages || []).map(async (msg: Message & { isE2E?: boolean }, idx: number) => {
|
||||
console.log(`[LoadMessages] Processing message ${idx}:`, {
|
||||
id: msg.id,
|
||||
isSentByMe: msg.isSentByMe,
|
||||
hasSenderPublicKey: !!msg.senderPublicKey,
|
||||
isE2EFlag: msg.isE2E
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if this is an E2E encrypted message (has senderPublicKey)
|
||||
// or a legacy RSA message (no senderPublicKey)
|
||||
const isE2E = !!msg.senderPublicKey;
|
||||
|
||||
if (!isE2E) {
|
||||
console.log(`[LoadMessages] Message ${idx} is legacy RSA`);
|
||||
// Legacy RSA message - can't decrypt client-side
|
||||
return { ...msg, decryptedContent: '[Legacy encrypted message]' };
|
||||
}
|
||||
|
||||
// ECDH: Both parties derive same shared secret
|
||||
// For messages I sent: use recipient's public key (chatPartnerKey)
|
||||
// For messages I received: use sender's public key
|
||||
const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey;
|
||||
|
||||
console.log(`[LoadMessages] Message ${idx} otherPartyKey:`, !!otherPartyKey, 'isSentByMe:', msg.isSentByMe);
|
||||
|
||||
if (!otherPartyKey) {
|
||||
console.warn('Missing key for decryption. isSentByMe:', msg.isSentByMe, 'chatPartnerKey:', !!chatPartnerKey);
|
||||
return { ...msg, decryptedContent: '[Missing decryption key]' };
|
||||
}
|
||||
|
||||
if (msg.encryptedContent) {
|
||||
console.log(`[LoadMessages] Calling decryptMessage for message ${idx}`);
|
||||
const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey);
|
||||
console.log(`[LoadMessages] Message ${idx} decrypted successfully`);
|
||||
return { ...msg, decryptedContent: decrypted };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[LoadMessages] Decrypt error for message ${idx}:`, err, 'msg:', msg.id, 'isSentByMe:', msg.isSentByMe);
|
||||
}
|
||||
return { ...msg, decryptedContent: '[Unable to decrypt]' };
|
||||
}));
|
||||
|
||||
console.log('[LoadMessages] Setting messages:', decrypted.length);
|
||||
setMessages(decrypted);
|
||||
} catch (err) {
|
||||
console.error('[LoadMessages] Load messages 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 { } };
|
||||
const sendMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!newMessage.trim() || !selectedConversation || !recipientPublicKey) return; setSending(true); try { const encrypted = await encryptMessage(newMessage, recipientPublicKey); const res = await fetch('/api/swarm/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipientHandle: selectedConversation.participant2.handle, encryptedContent: encrypted, senderPublicKey: keys?.publicKey }) }); if (res.ok) { setMessages(prev => [...prev, { id: crypto.randomUUID(), senderHandle: user?.handle || '', encryptedContent: encrypted, decryptedContent: newMessage, isSentByMe: true, createdAt: new Date().toISOString() }]); setNewMessage(''); loadConversations(); } } catch { } finally { setSending(false); } };
|
||||
const startNewChat = async (e: React.FormEvent) => { e.preventDefault(); if (!newChatHandle.trim()) return; setSending(true); try { const cleanHandle = newChatHandle.replace(/^@/, ''); const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`); const data = await res.json(); if (!data.user?.chatPublicKey) { alert('This user has not enabled encrypted chat yet.'); return; } const encrypted = await encryptMessage('Hey! 👋', data.user.chatPublicKey); const sendRes = await fetch('/api/swarm/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipientHandle: cleanHandle, encryptedContent: encrypted, senderPublicKey: keys?.publicKey }) }); if (sendRes.ok) { setShowNewChat(false); setNewChatHandle(''); loadConversations(); } } catch { } finally { setSending(false); } };
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!password) return; setPasswordError(''); setIsProcessingPassword(true); try { if (needsPasswordToRestore) { const success = await restoreKeysWithPassword(password); if (!success) { setPasswordError('Incorrect password.'); return; } } else { await generateAndRegisterKeys(password); } setShowPasswordModal(false); setPassword(''); } catch { setPasswordError('Failed. Please try again.'); } finally { setIsProcessingPassword(false); } };
|
||||
const filteredConversations = conversations.filter(conv => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
if (!user) return <div className="chat-page"><div className="chat-empty-state"><MessageCircle size={64} /><h2>Sign in to use Swarm Chat</h2><p>End-to-end encrypted messaging</p></div></div>;
|
||||
if (!isReady) return <div className="chat-page"><div className="chat-empty-state"><div className="spinner" /><p>Loading encryption...</p></div></div>;
|
||||
if (!hasKeys) return (
|
||||
<div className="chat-page">
|
||||
<div className="chat-empty-state">
|
||||
<Shield size={64} />
|
||||
<h2>{needsPasswordToRestore ? 'Restore Your Chat Keys' : 'Enable End-to-End Encryption'}</h2>
|
||||
<p>{needsPasswordToRestore ? 'Enter your password to restore your encrypted chat keys.' : 'Generate encryption keys to start secure messaging.'}</p>
|
||||
<p className="text-sm text-gray-500 mt-2"><Lock size={14} className="inline mr-1" />Your private key is encrypted with your password.</p>
|
||||
<button className="btn-primary mt-4" onClick={() => setShowPasswordModal(true)} disabled={isRegistering}><Key size={18} className="mr-2" />{needsPasswordToRestore ? 'Restore Keys' : 'Enable Encrypted Chat'}</button>
|
||||
</div>
|
||||
{showPasswordModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowPasswordModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>{needsPasswordToRestore ? 'Enter Your Password' : 'Secure Your Chat Keys'}</h2>
|
||||
<p>{needsPasswordToRestore ? 'Enter your account password to decrypt your chat keys.' : 'Enter your account password to encrypt your chat keys.'}</p>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<input type="password" placeholder="Your account password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
|
||||
{passwordError && <p className="text-red-500 text-sm mt-2">{passwordError}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={() => { setShowPasswordModal(false); setPassword(''); setPasswordError(''); }}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={isProcessingPassword || !password}>{isProcessingPassword ? 'Processing...' : (needsPasswordToRestore ? 'Restore' : 'Enable')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<div className="chat-container">
|
||||
<div className={`chat-sidebar ${selectedConversation ? 'mobile-hidden' : ''}`}>
|
||||
<div className="chat-sidebar-header"><h1><Lock size={18} className="inline mr-2" />Messages</h1><button className="btn-icon" onClick={() => setShowNewChat(true)} title="New chat"><Plus size={20} /></button></div>
|
||||
<div className="chat-search"><Search size={18} /><input type="text" placeholder="Search conversations..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /></div>
|
||||
{loading ? <div className="chat-loading"><div className="spinner" /><p>Loading...</p></div> : filteredConversations.length === 0 ? <div className="chat-empty-state"><MessageCircle size={48} /><h3>No conversations</h3><button className="btn-primary" onClick={() => setShowNewChat(true)}><Plus size={18} />New Chat</button></div> : <div className="conversations-list">{filteredConversations.map((conv) => <button key={conv.id} className={`conversation-item ${selectedConversation?.id === conv.id ? 'active' : ''}`} onClick={() => setSelectedConversation(conv)}><div className="conversation-avatar">{conv.participant2.avatarUrl ? <img src={conv.participant2.avatarUrl} alt="" /> : <span>{(conv.participant2.displayName || conv.participant2.handle).charAt(0).toUpperCase()}</span>}</div><div className="conversation-content"><div className="conversation-header"><span className="conversation-name">{conv.participant2.displayName}</span>{conv.unreadCount > 0 && <span className="unread-badge">{conv.unreadCount}</span>}</div><div className="conversation-handle">{formatFullHandle(conv.participant2.handle)}</div><div className="conversation-preview"><Lock size={12} className="inline mr-1" />{conv.lastMessagePreview}</div></div></button>)}</div>}
|
||||
</div>
|
||||
<div className={`chat-main ${!selectedConversation ? 'mobile-hidden' : ''}`}>
|
||||
{selectedConversation ? <>
|
||||
<div className="chat-header"><button className="btn-icon back-button" onClick={() => setSelectedConversation(null)}><ArrowLeft size={20} /></button><div className="chat-header-avatar">{selectedConversation.participant2.avatarUrl ? <img src={selectedConversation.participant2.avatarUrl} alt="" /> : <span>{(selectedConversation.participant2.displayName || selectedConversation.participant2.handle).charAt(0).toUpperCase()}</span>}</div><div className="chat-header-info"><h2>{selectedConversation.participant2.displayName}</h2><p><Lock size={12} className="inline mr-1" />{formatFullHandle(selectedConversation.participant2.handle)}</p></div></div>
|
||||
<div className="chat-messages"><div className="encryption-notice"><Shield size={16} /><span>Messages are end-to-end encrypted.</span></div>{messages.map((msg) => <div key={msg.id} className={`message ${msg.isSentByMe ? 'sent' : 'received'}`}>{!msg.isSentByMe && <div className="message-avatar">{msg.senderAvatarUrl ? <img src={msg.senderAvatarUrl} alt="" /> : <span>{(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()}</span>}</div>}<div className="message-content"><div className="message-bubble">{msg.decryptedContent || msg.encryptedContent}</div><div className="message-meta"><span>{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>{msg.isSentByMe && <span className="delivery-status">{msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'}</span>}</div></div></div>)}<div ref={messagesEndRef} /></div>
|
||||
<form className="chat-input" onSubmit={sendMessage}><input type="text" placeholder={recipientPublicKey ? "Type a message..." : "Recipient hasn't enabled encryption..."} value={newMessage} onChange={(e) => setNewMessage(e.target.value)} disabled={sending || !recipientPublicKey} /><button type="submit" className="btn-icon send-button" disabled={sending || !newMessage.trim() || !recipientPublicKey}><Send size={20} /></button></form>
|
||||
</> : <div className="chat-empty-state"><MessageCircle size={64} /><h2>Select a conversation</h2></div>}
|
||||
</div>
|
||||
</div>
|
||||
{showNewChat && <div className="modal-overlay" onClick={() => setShowNewChat(false)}><div className="modal-content" onClick={(e) => e.stopPropagation()}><h2>Start New Chat</h2><form onSubmit={startNewChat}><input type="text" placeholder="user@node.domain" value={newChatHandle} onChange={(e) => setNewChatHandle(e.target.value)} autoFocus /><div className="modal-actions"><button type="button" className="btn-secondary" onClick={() => setShowNewChat(false)}>Cancel</button><button type="submit" className="btn-primary" disabled={sending || !newChatHandle.trim()}>Start Chat</button></div></form></div></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -240,6 +240,7 @@ a.btn-primary:visited {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
|
||||
import { MessageCircle, Send, ArrowLeft, Search, Plus, Lock, Shield, Key, X, ChevronDown, CheckCheck, Loader2, Mail } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
participant2: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
chatPublicKey: string | null;
|
||||
};
|
||||
lastMessageAt: string;
|
||||
lastMessagePreview: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
senderHandle: string;
|
||||
senderDisplayName?: string;
|
||||
senderAvatarUrl?: string;
|
||||
senderPublicKey?: string;
|
||||
encryptedContent: string;
|
||||
decryptedContent?: string;
|
||||
isSentByMe: boolean;
|
||||
deliveredAt?: string;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function ChatWidget() {
|
||||
const { user } = useAuth();
|
||||
const { keys, isReady, hasKeys, isRegistering, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption();
|
||||
|
||||
// Widget State
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(true); // For minimize/maximize behavior when open
|
||||
|
||||
// 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 [newChatHandle, setNewChatHandle] = useState('');
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [recipientPublicKey, setRecipientPublicKey] = useState<string | null>(null);
|
||||
|
||||
// Password/Key State
|
||||
const [showPasswordInput, setShowPasswordInput] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isProcessingPassword, setIsProcessingPassword] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Listen for global open event
|
||||
useEffect(() => {
|
||||
const handleOpenEvent = () => {
|
||||
setIsOpen(true);
|
||||
setIsExpanded(true);
|
||||
};
|
||||
window.addEventListener('open-chat-widget', handleOpenEvent);
|
||||
return () => window.removeEventListener('open-chat-widget', handleOpenEvent);
|
||||
}, []);
|
||||
|
||||
// Load conversations when widget opens or auth changes
|
||||
useEffect(() => {
|
||||
if (user && hasKeys && isOpen) {
|
||||
loadConversations();
|
||||
}
|
||||
}, [user, hasKeys, isOpen]);
|
||||
|
||||
// Load messages when conversation is selected
|
||||
useEffect(() => {
|
||||
if (selectedConversation && hasKeys) {
|
||||
loadMessages(selectedConversation.id);
|
||||
markAsRead(selectedConversation.id);
|
||||
fetchRecipientKey(selectedConversation.participant2.handle);
|
||||
}
|
||||
}, [selectedConversation, hasKeys]);
|
||||
|
||||
// Auto-scroll to bottom of messages
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, isOpen, isExpanded]);
|
||||
|
||||
// -- Data Fetching Utils (Ported from page.tsx) --
|
||||
const fetchRecipientKey = async (handle: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(handle)}`);
|
||||
const data = await res.json();
|
||||
setRecipientPublicKey(data.user?.chatPublicKey || null);
|
||||
} catch {
|
||||
setRecipientPublicKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConversations = async () => {
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async (conversationId: string) => {
|
||||
try {
|
||||
// Ensure we have the recipient's key logic (simplified for brevity, main logic preserved)
|
||||
let chatPartnerKey = selectedConversation?.participant2?.chatPublicKey || recipientPublicKey;
|
||||
|
||||
if (!chatPartnerKey && selectedConversation?.participant2?.handle) {
|
||||
try {
|
||||
const userRes = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`);
|
||||
const userData = await userRes.json();
|
||||
chatPartnerKey = userData.user?.chatPublicKey || null;
|
||||
if (chatPartnerKey) setRecipientPublicKey(chatPartnerKey);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`);
|
||||
const data = await res.json();
|
||||
|
||||
const decrypted = await Promise.all((data.messages || []).map(async (msg: Message & { isE2E?: boolean }) => {
|
||||
try {
|
||||
const isE2E = !!msg.senderPublicKey;
|
||||
if (!isE2E) return { ...msg, decryptedContent: '[Legacy encrypted message]' };
|
||||
|
||||
const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey;
|
||||
if (!otherPartyKey) return { ...msg, decryptedContent: '[Missing decryption key]' };
|
||||
|
||||
if (msg.encryptedContent) {
|
||||
const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey);
|
||||
return { ...msg, decryptedContent: decrypted };
|
||||
}
|
||||
} catch (err) { }
|
||||
return { ...msg, decryptedContent: '[Unable to decrypt]' };
|
||||
}));
|
||||
|
||||
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 { } };
|
||||
|
||||
const sendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim() || !selectedConversation || !recipientPublicKey) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const encrypted = await encryptMessage(newMessage, recipientPublicKey);
|
||||
const res = await fetch('/api/swarm/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipientHandle: selectedConversation.participant2.handle,
|
||||
encryptedContent: encrypted,
|
||||
senderPublicKey: keys?.publicKey
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNewMessage('');
|
||||
await loadMessages(selectedConversation.id);
|
||||
loadConversations();
|
||||
}
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const startNewChat = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newChatHandle.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const cleanHandle = newChatHandle.replace(/^@/, '');
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`);
|
||||
const data = await res.json();
|
||||
if (!data.user?.chatPublicKey) {
|
||||
alert('This user has not enabled encrypted chat yet.');
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptMessage('Hey! 👋', data.user.chatPublicKey);
|
||||
const sendRes = await fetch('/api/swarm/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipientHandle: cleanHandle,
|
||||
encryptedContent: encrypted,
|
||||
senderPublicKey: keys?.publicKey
|
||||
})
|
||||
});
|
||||
if (sendRes.ok) {
|
||||
setShowNewChat(false);
|
||||
setNewChatHandle('');
|
||||
loadConversations();
|
||||
}
|
||||
} catch { }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) return;
|
||||
setPasswordError('');
|
||||
setIsProcessingPassword(true);
|
||||
try {
|
||||
if (needsPasswordToRestore) {
|
||||
const success = await restoreKeysWithPassword(password);
|
||||
if (!success) { setPasswordError('Incorrect password.'); return; }
|
||||
} else {
|
||||
await generateAndRegisterKeys(password);
|
||||
}
|
||||
setShowPasswordInput(false);
|
||||
setPassword('');
|
||||
} catch (err) {
|
||||
setPasswordError('Failed. Please try again.');
|
||||
} finally {
|
||||
setIsProcessingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConversations: Conversation[] = conversations.filter((conv: Conversation) => conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) || conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
// -- Render Logic --
|
||||
|
||||
// If not signed in, show nothing or maybe a prompt? User said "widget", usually hidden if not meaningful.
|
||||
if (!user) return null;
|
||||
|
||||
// Collapsed State (Just the Fab/Button)
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center justify-center w-14 h-14 rounded-full bg-white text-black shadow-lg hover:bg-gray-200 transition-all border border-gray-200"
|
||||
>
|
||||
<Mail size={24} />
|
||||
{/* Unread badge logic could go here */}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded Widget
|
||||
return (
|
||||
<div className={`fixed bottom-0 right-12 z-50 w-[350px] sm:w-[400px] bg-black border border-[#262626] rounded-t-2xl shadow-2xl flex flex-col transition-all duration-200 ${isExpanded ? 'h-[600px] max-h-[90vh]' : 'h-[60px]'}`}>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b border-[#262626] cursor-pointer bg-[#111] rounded-t-2xl"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedConversation && isExpanded ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedConversation(null); }}
|
||||
className="p-1 hover:bg-[#262626] rounded-full mr-1"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
<h3 className="font-bold text-lg">Messages</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!selectedConversation && isExpanded && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowNewChat(true); }}
|
||||
className="p-1 hover:bg-[#262626] rounded-full"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setIsExpanded(!isExpanded); }}
|
||||
className="p-1 hover:bg-[#262626] rounded-full"
|
||||
>
|
||||
<ChevronDown size={20} className={`transform transition-transform ${isExpanded ? '' : 'rotate-180'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setIsOpen(false); }}
|
||||
className="p-1 hover:bg-[#262626] rounded-full"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content (Only visible if expanded) */}
|
||||
{isExpanded && (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-black">
|
||||
|
||||
{/* Encryption Setup Overlay */}
|
||||
{!hasKeys && (
|
||||
<div className="absolute inset-0 z-10 bg-black/90 flex flex-col items-center justify-center p-6 text-center">
|
||||
<Shield size={48} className="mb-4 text-white" />
|
||||
<h3 className="font-bold text-lg mb-2">{needsPasswordToRestore ? 'Unlock Messages' : 'Enable Encryption'}</h3>
|
||||
<p className="text-sm text-gray-400 mb-4">{needsPasswordToRestore ? 'Enter your password to restore chat.' : 'Set up secure messaging.'}</p>
|
||||
|
||||
{!showPasswordInput ? (
|
||||
<button onClick={() => setShowPasswordInput(true)} className="px-4 py-2 bg-white text-black font-bold rounded-full">
|
||||
{needsPasswordToRestore ? 'Restore Keys' : 'Enable'}
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordSubmit} className="w-full max-w-[250px] flex flex-col gap-2">
|
||||
<input
|
||||
type="password"
|
||||
className="w-full bg-[#262626] border border-[#404040] rounded-md p-2 text-white outline-none focus:border-white"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{passwordError && <p className="text-red-500 text-xs">{passwordError}</p>}
|
||||
<button type="submit" className="w-full bg-white text-black font-bold rounded-full py-2 disabled:opacity-50" disabled={!password || isProcessingPassword}>
|
||||
{isProcessingPassword ? <Loader2 className="animate-spin mx-auto" /> : 'Confirm'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main View Switching */}
|
||||
{hasKeys && (
|
||||
<>
|
||||
{selectedConversation ? (
|
||||
// Thread View
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Thread Header */}
|
||||
<div className="px-4 py-3 border-b border-[#262626] flex items-center gap-3 bg-black">
|
||||
<div className="w-8 h-8 rounded-full bg-[#262626] overflow-hidden flex items-center justify-center border border-[#404040]">
|
||||
{selectedConversation.participant2.avatarUrl ? (
|
||||
<img src={selectedConversation.participant2.avatarUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold">{selectedConversation.participant2.displayName[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold truncate text-sm">{selectedConversation.participant2.displayName}</h4>
|
||||
<p className="text-xs text-gray-500 truncate">{formatFullHandle(selectedConversation.participant2.handle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-4">
|
||||
<div className="flex justify-center my-4">
|
||||
<div className="text-xs text-gray-500 flex items-center gap-1 bg-[#111] px-3 py-1 rounded-full border border-[#262626]">
|
||||
<Lock size={10} /> End-to-end encrypted
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className={`flex gap-2 max-w-[85%] ${msg.isSentByMe ? 'ml-auto flex-row-reverse' : ''}`}>
|
||||
{/* Avatar for received messages */}
|
||||
{!msg.isSentByMe && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#262626] flex-shrink-0 overflow-hidden mt-1">
|
||||
{msg.senderAvatarUrl ? <img src={msg.senderAvatarUrl} className="object-cover w-full h-full" /> : <span className="flex items-center justify-center h-full text-[10px]">{msg.senderDisplayName?.[0]}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex flex-col ${msg.isSentByMe ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`px-3 py-2 rounded-2xl text-sm break-words ${msg.isSentByMe ? 'bg-white text-black' : 'bg-[#262626] text-white'}`}>
|
||||
{msg.decryptedContent || msg.encryptedContent}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1 flex items-center gap-1">
|
||||
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{msg.isSentByMe && (
|
||||
<span className={msg.readAt ? 'text-blue-500' : ''}>
|
||||
{msg.readAt ? <CheckCheck size={12} /> : msg.deliveredAt ? <Loader2 size={12} /> : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<form onSubmit={sendMessage} className="p-3 border-t border-[#262626] flex items-center gap-2 bg-[#111]">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-[#262626] border-none rounded-full px-4 py-2 text-sm text-white focus:ring-1 focus:ring-white outline-none"
|
||||
placeholder="Type a message..."
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newMessage.trim() || sending}
|
||||
className="p-2 text-blue-500 hover:bg-[#262626] rounded-full disabled:opacity-50"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
// Conversation List View
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{showNewChat ? (
|
||||
<div className="p-4">
|
||||
<form onSubmit={startNewChat} className="flex flex-col gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search people"
|
||||
className="w-full bg-[#262626] border-none rounded-md p-2 text-white outline-none"
|
||||
value={newChatHandle}
|
||||
onChange={e => setNewChatHandle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className='flex justify-end gap-2'>
|
||||
<button type="button" onClick={() => setShowNewChat(false)} className='text-sm text-gray-400'>Cancel</button>
|
||||
<button type="submit" className='text-sm bg-white text-black px-3 py-1 rounded-full font-bold' disabled={!newChatHandle.trim()}>Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search Bar */}
|
||||
<div className="p-2">
|
||||
<div className="bg-[#262626] rounded-full flex items-center px-3 py-1.5 gap-2">
|
||||
<Search size={16} className="text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Direct Messages"
|
||||
className="bg-transparent border-none outline-none text-sm text-white flex-1"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-4"><Loader2 className="animate-spin text-gray-500" /></div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500 p-6 text-center">
|
||||
<h4 className="font-bold text-lg mb-2">Welcome to your inbox!</h4>
|
||||
<p className="text-sm">Drop a potential swarm of thoughts to people.</p>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
className="mt-4 px-4 py-2 bg-white text-black rounded-full font-bold text-sm"
|
||||
>
|
||||
Write a message
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.map((conv: any) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConversation(conv)}
|
||||
className={`flex items-center gap-3 p-3 hover:bg-[#161616] cursor-pointer transition-colors ${selectedConversation?.id === conv.id ? 'bg-[#161616] border-r-2 border-blue-500' : ''}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-[#333] overflow-hidden flex items-center justify-center border border-[#404040]">
|
||||
{conv.participant2.avatarUrl ? (
|
||||
<img src={conv.participant2.avatarUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold">{conv.participant2.displayName[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm truncate">{conv.participant2.displayName}</span>
|
||||
<span className="text-xs text-gray-500">{formatFullHandle(conv.participant2.handle)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className={`text-sm truncate max-w-[180px] ${conv.unreadCount > 0 ? 'text-white font-medium' : 'text-gray-500'}`}>
|
||||
{conv.lastMessagePreview}
|
||||
</span>
|
||||
{conv.unreadCount > 0 && (
|
||||
<span className="bg-blue-500 text-white text-[10px] px-1.5 py-0.5 rounded-full font-bold ml-2">
|
||||
{conv.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { RightSidebar } from './RightSidebar';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { ChatWidget } from './ChatWidget';
|
||||
|
||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
const { loading } = useAuth();
|
||||
@@ -50,12 +51,13 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`layout ${hideRightSidebar ? 'hide-right-sidebar' : ''}`}>
|
||||
<div className="layout">
|
||||
<Sidebar />
|
||||
<main className="main">
|
||||
{children}
|
||||
</main>
|
||||
{!hideRightSidebar && <RightSidebar />}
|
||||
<ChatWidget />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,14 +31,14 @@ export function Sidebar() {
|
||||
// Fetch unread notification count
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
|
||||
const fetchUnread = () => {
|
||||
fetch('/api/notifications?unread=true&limit=50')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setUnreadCount(data.notifications?.length || 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
};
|
||||
|
||||
fetchUnread();
|
||||
@@ -52,7 +52,7 @@ export function Sidebar() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loggingOut) return;
|
||||
|
||||
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
@@ -101,12 +101,16 @@ export function Sidebar() {
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<Link href="/chat" className={`nav-item ${pathname?.startsWith('/chat') ? 'active' : ''}`}>
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new Event('open-chat-widget'))}
|
||||
className={`nav-item ${pathname?.startsWith('/chat') ? 'active' : ''}`}
|
||||
style={{ background: 'transparent', border: 'none', width: '100%', cursor: 'pointer', textAlign: 'left' }}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span>Chat</span>
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
{user && (
|
||||
<Link href="/settings/bots" className={`nav-item ${pathname?.startsWith('/settings/bots') ? 'active' : ''}`}>
|
||||
@@ -163,8 +167,8 @@ export function Sidebar() {
|
||||
onClick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: '100%',
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
gap: '12px',
|
||||
padding: '10px 12px',
|
||||
|
||||
@@ -147,22 +147,32 @@ export async function decryptMessage(
|
||||
myPrivateKeyBase64: string,
|
||||
theirPublicKeyBase64: string
|
||||
): Promise<string> {
|
||||
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
|
||||
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
|
||||
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
|
||||
try {
|
||||
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
|
||||
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
|
||||
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
|
||||
|
||||
const combined = base64ToBuffer(encryptedMessage);
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const combined = base64ToBuffer(encryptedMessage);
|
||||
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sharedKey,
|
||||
ciphertext
|
||||
);
|
||||
if (combined.byteLength < 12) {
|
||||
throw new Error('Message too short');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sharedKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
console.error('Decryption failed:', error);
|
||||
return '[Message cannot be decrypted]';
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
@@ -176,10 +186,33 @@ function bufferToBase64(buffer: ArrayBuffer): string {
|
||||
}
|
||||
|
||||
function base64ToBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
// Gracefull handle null/undefined
|
||||
if (!base64) return new ArrayBuffer(0);
|
||||
|
||||
// Check for JSON (legacy format)
|
||||
if (base64.trim().startsWith('{')) {
|
||||
console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer');
|
||||
throw new Error('Invalid message format: JSON detected');
|
||||
}
|
||||
|
||||
// Clean the string:
|
||||
// 1. Remove newlines/tabs (formatting)
|
||||
// 2. Replace spaces with '+' (common URL decoding error where + becomes space)
|
||||
// 3. Handle URL-safe chars (- -> +, _ -> /)
|
||||
const cleaned = base64.replace(/[\n\r\t]/g, '')
|
||||
.replace(/ /g, '+')
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
try {
|
||||
const binary = atob(cleaned);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
} catch (e) {
|
||||
console.error('[base64ToBuffer] Failed to decode base64:', e);
|
||||
throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function useChatEncryption() {
|
||||
// First check localStorage
|
||||
const publicKey = localStorage.getItem(PUBLIC_KEY_STORAGE);
|
||||
const privateKey = localStorage.getItem(PRIVATE_KEY_STORAGE);
|
||||
|
||||
|
||||
if (publicKey && privateKey) {
|
||||
setKeys({ publicKey, privateKey });
|
||||
setIsReady(true);
|
||||
@@ -50,7 +50,7 @@ export function useChatEncryption() {
|
||||
if (res.ok) {
|
||||
const data: ServerKeyData = await res.json();
|
||||
setServerKeyData(data);
|
||||
|
||||
|
||||
if (data.hasKeys && data.chatPrivateKeyEncrypted) {
|
||||
// Keys exist on server but not locally - need password to restore
|
||||
setNeedsPasswordToRestore(true);
|
||||
@@ -59,7 +59,7 @@ export function useChatEncryption() {
|
||||
} catch (error) {
|
||||
console.error('Failed to check server keys:', error);
|
||||
}
|
||||
|
||||
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
@@ -109,27 +109,34 @@ export function useChatEncryption() {
|
||||
// Encrypt private key with password for server backup
|
||||
const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password);
|
||||
|
||||
// Store private key locally (NEVER sent unencrypted to server)
|
||||
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
|
||||
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
|
||||
|
||||
// Register public key + encrypted private key backup with server
|
||||
// Register public key + encrypted private key backup with server FIRST
|
||||
const response = await fetch('/api/chat/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
chatPublicKey: publicKey,
|
||||
chatPrivateKeyEncrypted: encryptedPrivateKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to register chat keys');
|
||||
const error = await response.json();
|
||||
console.error('[GenerateKeys] Server registration failed:', error);
|
||||
throw new Error(error.error || 'Failed to register chat keys');
|
||||
}
|
||||
|
||||
// Only save to localStorage AFTER server confirms
|
||||
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
|
||||
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
|
||||
|
||||
setKeys({ publicKey, privateKey });
|
||||
setNeedsPasswordToRestore(false);
|
||||
|
||||
console.log('[GenerateKeys] Keys generated and registered successfully');
|
||||
return { publicKey, privateKey };
|
||||
} catch (error) {
|
||||
console.error('[GenerateKeys] Failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
@@ -171,40 +178,51 @@ export function useChatEncryption() {
|
||||
encryptedMessage: string,
|
||||
senderPublicKey: string
|
||||
): Promise<string> => {
|
||||
if (!keys?.privateKey) {
|
||||
console.error('[Decrypt] No private key available');
|
||||
throw new Error('No chat keys available');
|
||||
try {
|
||||
if (!keys?.privateKey) {
|
||||
console.error('[Decrypt] No private key available');
|
||||
throw new Error('No chat keys available');
|
||||
}
|
||||
|
||||
console.log('[Decrypt] Starting decryption', {
|
||||
hasPrivateKey: !!keys.privateKey,
|
||||
hasSenderPublicKey: !!senderPublicKey,
|
||||
encryptedLength: encryptedMessage.length
|
||||
});
|
||||
|
||||
const myPrivateKey = await importPrivateKey(keys.privateKey);
|
||||
const theirPublicKey = await importPublicKey(senderPublicKey);
|
||||
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
|
||||
|
||||
const combined = base64ToBuffer(encryptedMessage);
|
||||
|
||||
if (combined.byteLength < 12) {
|
||||
throw new Error('Message too short (invalid ciphertext)');
|
||||
}
|
||||
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
|
||||
console.log('[Decrypt] Decrypting with shared key', {
|
||||
ivLength: iv.byteLength,
|
||||
ciphertextLength: ciphertext.byteLength
|
||||
});
|
||||
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sharedKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const result = decoder.decode(decrypted);
|
||||
console.log('[Decrypt] Success:', result.substring(0, 50));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Decrypt] Failed:', error);
|
||||
// Return a safe placeholder that won't crash the UI
|
||||
return '[Message cannot be decrypted]';
|
||||
}
|
||||
|
||||
console.log('[Decrypt] Starting decryption', {
|
||||
hasPrivateKey: !!keys.privateKey,
|
||||
hasSenderPublicKey: !!senderPublicKey,
|
||||
encryptedLength: encryptedMessage.length
|
||||
});
|
||||
|
||||
const myPrivateKey = await importPrivateKey(keys.privateKey);
|
||||
const theirPublicKey = await importPublicKey(senderPublicKey);
|
||||
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
|
||||
|
||||
const combined = base64ToBuffer(encryptedMessage);
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
|
||||
console.log('[Decrypt] Decrypting with shared key', {
|
||||
ivLength: iv.byteLength,
|
||||
ciphertextLength: ciphertext.byteLength
|
||||
});
|
||||
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
sharedKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const result = decoder.decode(decrypted);
|
||||
console.log('[Decrypt] Success:', result.substring(0, 50));
|
||||
return result;
|
||||
}, [keys]);
|
||||
|
||||
// Clear keys (on logout)
|
||||
@@ -236,7 +254,7 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin
|
||||
const encoder = new TextEncoder();
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
|
||||
// Derive key from password using PBKDF2
|
||||
const passwordKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -245,7 +263,7 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
|
||||
const aesKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
@@ -258,14 +276,14 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
|
||||
// Encrypt the private key
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
aesKey,
|
||||
encoder.encode(privateKey)
|
||||
);
|
||||
|
||||
|
||||
// Return as JSON with all components
|
||||
return JSON.stringify({
|
||||
salt: bufferToBase64(salt.buffer),
|
||||
@@ -278,7 +296,7 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st
|
||||
const { salt, iv, ciphertext } = JSON.parse(encryptedData);
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
|
||||
// Derive key from password
|
||||
const passwordKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -287,7 +305,7 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
|
||||
const aesKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
@@ -300,14 +318,14 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: base64ToBuffer(iv) },
|
||||
aesKey,
|
||||
base64ToBuffer(ciphertext)
|
||||
);
|
||||
|
||||
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
@@ -364,10 +382,35 @@ function bufferToBase64(buffer: ArrayBuffer): string {
|
||||
}
|
||||
|
||||
function base64ToBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
// Gracefull handle null/undefined
|
||||
if (!base64) return new ArrayBuffer(0);
|
||||
|
||||
// Check for JSON (legacy format)
|
||||
if (base64.trim().startsWith('{')) {
|
||||
console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer');
|
||||
throw new Error('Invalid message format: JSON detected');
|
||||
}
|
||||
|
||||
// Clean the string:
|
||||
// 1. Remove newlines/tabs (formatting)
|
||||
// 2. Replace spaces with '+' (common URL decoding error where + becomes space)
|
||||
// 3. Handle URL-safe chars (- -> +, _ -> /)
|
||||
const cleaned = base64.replace(/[\n\r\t]/g, '')
|
||||
.replace(/ /g, '+')
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
try {
|
||||
const binary = atob(cleaned);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
} catch (e) {
|
||||
console.error('[base64ToBuffer] Failed to decode base64:', e);
|
||||
// Return empty buffer or throw specific error?
|
||||
// Throwing allows decryptMessage to catch and return placeholder
|
||||
throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user