Add chat page, improve chat API, and remove ChatWidget
Introduces a new chat page with end-to-end encryption, conversation list, and message thread UI. Adds new API endpoints for unread message count and debugging chat keys/messages. Improves chat key validation and reciprocal conversation/message creation for local recipients. Removes the old ChatWidget component. Updates login and chat API logic for better Turnstile and key handling.
This commit is contained in:
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
|||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
turnstileToken: z.string().optional(),
|
turnstileToken: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@@ -14,7 +14,7 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const data = loginSchema.parse(body);
|
const data = loginSchema.parse(body);
|
||||||
|
|
||||||
// Verify Turnstile token if provided
|
// Verify Turnstile token only if it's provided (meaning Turnstile is enabled)
|
||||||
if (data.turnstileToken) {
|
if (data.turnstileToken) {
|
||||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
|
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
|
||||||
const isValid = await verifyTurnstileToken(data.turnstileToken, ip);
|
const isValid = await verifyTurnstileToken(data.turnstileToken, ip);
|
||||||
|
|||||||
@@ -53,9 +53,26 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 });
|
return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate it looks like a base64 SPKI key
|
// Validate it looks like a base64 SPKI key (should be ~120 chars for P-256)
|
||||||
if (chatPublicKey.length < 50 || chatPublicKey.length > 500) {
|
if (chatPublicKey.length < 100 || chatPublicKey.length > 200) {
|
||||||
return NextResponse.json({ error: 'Invalid public key format' }, { status: 400 });
|
console.error('[Chat Keys API] Invalid public key length:', chatPublicKey.length);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `Invalid public key format: expected ~120 characters, got ${chatPublicKey.length}`
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional validation: try to decode as base64
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(chatPublicKey, 'base64');
|
||||||
|
if (decoded.length < 80 || decoded.length > 100) {
|
||||||
|
console.error('[Chat Keys API] Invalid decoded key size:', decoded.length);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `Invalid public key: decoded size ${decoded.length} bytes (expected ~91 bytes for P-256)`
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Chat Keys API] Failed to decode public key as base64:', e);
|
||||||
|
return NextResponse.json({ error: 'Public key is not valid base64' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// chatPrivateKeyEncrypted should be a JSON string with encrypted data
|
// chatPrivateKeyEncrypted should be a JSON string with encrypted data
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, chatConversations, chatMessages } from '@/db';
|
||||||
|
import { eq, and, isNull, inArray } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's conversations
|
||||||
|
const conversations = await db.query.chatConversations.findMany({
|
||||||
|
where: eq(chatConversations.participant1Id, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
return NextResponse.json({ unreadCount: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count unread messages across all conversations
|
||||||
|
const conversationIds = conversations.map(c => c.id);
|
||||||
|
|
||||||
|
const unreadMessages = await db.query.chatMessages.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(chatMessages.conversationId, conversationIds),
|
||||||
|
isNull(chatMessages.readAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out messages sent by the current user
|
||||||
|
const unreadCount = unreadMessages.filter(
|
||||||
|
msg => msg.senderHandle !== session.user.handle
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return NextResponse.json({ unreadCount });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get unread chat count error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get unread count' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUsers = await db.select({
|
||||||
|
id: users.id,
|
||||||
|
handle: users.handle,
|
||||||
|
chatPublicKey: users.chatPublicKey,
|
||||||
|
chatPrivateKeyEncrypted: users.chatPrivateKeyEncrypted,
|
||||||
|
}).from(users);
|
||||||
|
|
||||||
|
const results = allUsers.map(user => {
|
||||||
|
let publicKeyInfo = null;
|
||||||
|
if (user.chatPublicKey) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(user.chatPublicKey, 'base64');
|
||||||
|
publicKeyInfo = {
|
||||||
|
stringLength: user.chatPublicKey.length,
|
||||||
|
decodedBytes: decoded.byteLength,
|
||||||
|
isValidSize: decoded.byteLength === 91,
|
||||||
|
isCorrupted: decoded.byteLength !== 91,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
publicKeyInfo = {
|
||||||
|
error: 'Not valid base64',
|
||||||
|
stringLength: user.chatPublicKey.length,
|
||||||
|
isCorrupted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handle: user.handle,
|
||||||
|
hasPublicKey: !!user.chatPublicKey,
|
||||||
|
publicKeyInfo,
|
||||||
|
encryptedPrivateKeyLength: user.chatPrivateKeyEncrypted?.length || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const corrupted = results.filter(r => r.publicKeyInfo?.isCorrupted);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
total: results.length,
|
||||||
|
withKeys: results.filter(r => r.hasPublicKey).length,
|
||||||
|
corrupted: corrupted.length,
|
||||||
|
corruptedUsers: corrupted.map(r => r.handle),
|
||||||
|
allUsers: results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Debug error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to debug keys' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, users } from '@/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: (users, { eq }) => eq(users.id, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let publicKeyInfo = null;
|
||||||
|
if (user.chatPublicKey) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(user.chatPublicKey, 'base64');
|
||||||
|
publicKeyInfo = {
|
||||||
|
stringLength: user.chatPublicKey.length,
|
||||||
|
decodedBytes: decoded.byteLength,
|
||||||
|
isValidSize: decoded.byteLength === 91,
|
||||||
|
firstChars: user.chatPublicKey.substring(0, 20),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
publicKeyInfo = {
|
||||||
|
error: 'Not valid base64',
|
||||||
|
stringLength: user.chatPublicKey.length,
|
||||||
|
firstChars: user.chatPublicKey.substring(0, 20),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
handle: user.handle,
|
||||||
|
hasPublicKey: !!user.chatPublicKey,
|
||||||
|
hasEncryptedPrivateKey: !!user.chatPrivateKeyEncrypted,
|
||||||
|
publicKeyInfo,
|
||||||
|
encryptedPrivateKeyLength: user.chatPrivateKeyEncrypted?.length || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Debug error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to debug keys' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db, chatMessages } from '@/db';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await db.select({
|
||||||
|
id: chatMessages.id,
|
||||||
|
senderHandle: chatMessages.senderHandle,
|
||||||
|
senderChatPublicKey: chatMessages.senderChatPublicKey,
|
||||||
|
createdAt: chatMessages.createdAt,
|
||||||
|
}).from(chatMessages).limit(50);
|
||||||
|
|
||||||
|
const results = messages.map(msg => {
|
||||||
|
let keyInfo = null;
|
||||||
|
if (msg.senderChatPublicKey) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(msg.senderChatPublicKey, 'base64');
|
||||||
|
keyInfo = {
|
||||||
|
stringLength: msg.senderChatPublicKey.length,
|
||||||
|
decodedBytes: decoded.byteLength,
|
||||||
|
isValidSize: decoded.byteLength === 91,
|
||||||
|
isCorrupted: decoded.byteLength !== 91,
|
||||||
|
firstChars: msg.senderChatPublicKey.substring(0, 20),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
keyInfo = {
|
||||||
|
error: 'Not valid base64',
|
||||||
|
stringLength: msg.senderChatPublicKey.length,
|
||||||
|
isCorrupted: true,
|
||||||
|
firstChars: msg.senderChatPublicKey.substring(0, 20),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.id,
|
||||||
|
senderHandle: msg.senderHandle,
|
||||||
|
createdAt: msg.createdAt,
|
||||||
|
hasSenderKey: !!msg.senderChatPublicKey,
|
||||||
|
keyInfo,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const corrupted = results.filter(r => r.keyInfo?.isCorrupted);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
total: results.length,
|
||||||
|
withKeys: results.filter(r => r.hasSenderKey).length,
|
||||||
|
corrupted: corrupted.length,
|
||||||
|
corruptedMessages: corrupted,
|
||||||
|
allMessages: results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Debug error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to debug messages' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ export async function GET(request: NextRequest) {
|
|||||||
handle: cachedUser.handle,
|
handle: cachedUser.handle,
|
||||||
displayName: cachedUser.displayName || cachedUser.handle,
|
displayName: cachedUser.displayName || cachedUser.handle,
|
||||||
avatarUrl: cachedUser.avatarUrl,
|
avatarUrl: cachedUser.avatarUrl,
|
||||||
chatPublicKey: cachedUser.publicKey, // This is the chat public key
|
chatPublicKey: cachedUser.chatPublicKey, // ECDH key for E2E chat
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export async function POST(request: NextRequest) {
|
|||||||
participant1Id: sender.id,
|
participant1Id: sender.id,
|
||||||
participant2Handle: recipientHandle,
|
participant2Handle: recipientHandle,
|
||||||
lastMessageAt: new Date(),
|
lastMessageAt: new Date(),
|
||||||
lastMessagePreview: '🔒 Encrypted message',
|
lastMessagePreview: 'New message',
|
||||||
}).returning();
|
}).returning();
|
||||||
conversation = newConversation;
|
conversation = newConversation;
|
||||||
}
|
}
|
||||||
@@ -227,11 +227,68 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.update(chatConversations)
|
await db.update(chatConversations)
|
||||||
.set({
|
.set({
|
||||||
lastMessageAt: new Date(),
|
lastMessageAt: new Date(),
|
||||||
lastMessagePreview: '🔒 Encrypted message',
|
lastMessagePreview: 'New message',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(chatConversations.id, conversation.id));
|
.where(eq(chatConversations.id, conversation.id));
|
||||||
|
|
||||||
|
// For LOCAL recipients, create/update their conversation too
|
||||||
|
if (!isRemote && recipientUser) {
|
||||||
|
try {
|
||||||
|
console.log('[Chat Send] Creating reciprocal conversation for local recipient:', recipientUser.handle);
|
||||||
|
|
||||||
|
// Check if recipient has a conversation with sender
|
||||||
|
let recipientConversation = await db.query.chatConversations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(chatConversations.participant1Id, recipientUser.id),
|
||||||
|
eq(chatConversations.participant2Handle, sender.handle)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipientConversation) {
|
||||||
|
// Create conversation for recipient
|
||||||
|
const [newRecipientConv] = await db.insert(chatConversations).values({
|
||||||
|
participant1Id: recipientUser.id,
|
||||||
|
participant2Handle: sender.handle,
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
lastMessagePreview: 'New message',
|
||||||
|
}).returning();
|
||||||
|
recipientConversation = newRecipientConv;
|
||||||
|
console.log('[Chat Send] Created new conversation for recipient');
|
||||||
|
} else {
|
||||||
|
// Update existing conversation
|
||||||
|
await db.update(chatConversations)
|
||||||
|
.set({
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
lastMessagePreview: 'New message',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(chatConversations.id, recipientConversation.id));
|
||||||
|
console.log('[Chat Send] Updated existing conversation for recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert message into recipient's conversation
|
||||||
|
await db.insert(chatMessages).values({
|
||||||
|
conversationId: recipientConversation.id,
|
||||||
|
senderHandle: sender.handle,
|
||||||
|
senderDisplayName: sender.displayName,
|
||||||
|
senderAvatarUrl: sender.avatarUrl,
|
||||||
|
senderNodeDomain: null,
|
||||||
|
encryptedContent,
|
||||||
|
senderEncryptedContent,
|
||||||
|
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
|
||||||
|
swarmMessageId: `${swarmMessageId}-recipient`, // Make it unique for recipient's copy
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
readAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Chat Send] Reciprocal message created for local recipient');
|
||||||
|
} catch (recipError) {
|
||||||
|
console.error('[Chat Send] Failed to create reciprocal conversation:', recipError);
|
||||||
|
// Don't fail the whole request - sender's message was still saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If remote, send to their node
|
// If remote, send to their node
|
||||||
if (isRemote && recipientNodeDomain) {
|
if (isRemote && recipientNodeDomain) {
|
||||||
// ... (remote logic remains similar but add logs)
|
// ... (remote logic remains similar but add logs)
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
|
||||||
|
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus } from 'lucide-react';
|
||||||
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
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 router = useRouter();
|
||||||
|
const { keys, isReady, hasKeys, needsPasswordToRestore, generateAndRegisterKeys, restoreKeysWithPassword, encryptMessage, decryptMessage } = useChatEncryption();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Encryption loading state to prevent flash
|
||||||
|
const [encryptionChecked, setEncryptionChecked] = useState(false);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Wait for encryption to be ready before showing UI
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady) {
|
||||||
|
setEncryptionChecked(true);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
// Redirect if not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [user, router]);
|
||||||
|
|
||||||
|
// Load conversations
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && hasKeys) {
|
||||||
|
loadConversations(true); // Initial load with spinner
|
||||||
|
|
||||||
|
// Poll for new conversations every 5 seconds (no spinner)
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
loadConversations(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
}, [user, hasKeys]);
|
||||||
|
|
||||||
|
// Load messages when conversation is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedConversation && hasKeys) {
|
||||||
|
loadMessages(selectedConversation.id);
|
||||||
|
markAsRead(selectedConversation.id);
|
||||||
|
fetchRecipientKey(selectedConversation.participant2.handle);
|
||||||
|
|
||||||
|
// Poll for new messages every 3 seconds
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
loadMessages(selectedConversation.id);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
}, [selectedConversation, hasKeys]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom of messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesEndRef.current) {
|
||||||
|
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 (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 {
|
||||||
|
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();
|
||||||
|
console.log('[Chat] Fetched user data:', {
|
||||||
|
handle: selectedConversation.participant2.handle,
|
||||||
|
hasChatPublicKey: !!userData.user?.chatPublicKey,
|
||||||
|
hasPublicKey: !!userData.user?.publicKey,
|
||||||
|
chatPublicKeyLength: userData.user?.chatPublicKey?.length,
|
||||||
|
publicKeyLength: userData.user?.publicKey?.length,
|
||||||
|
chatPublicKeyStart: userData.user?.chatPublicKey?.substring(0, 20),
|
||||||
|
publicKeyStart: userData.user?.publicKey?.substring(0, 20),
|
||||||
|
});
|
||||||
|
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 message - incompatible encryption]' };
|
||||||
|
|
||||||
|
const otherPartyKey = msg.isSentByMe ? chatPartnerKey : msg.senderPublicKey;
|
||||||
|
if (!otherPartyKey) return { ...msg, decryptedContent: '[Missing encryption key]' };
|
||||||
|
|
||||||
|
console.log('[Chat] Decrypting message:', {
|
||||||
|
messageId: msg.id,
|
||||||
|
isSentByMe: msg.isSentByMe,
|
||||||
|
keyLength: otherPartyKey?.length,
|
||||||
|
keySource: msg.isSentByMe ? 'chatPartnerKey' : 'msg.senderPublicKey',
|
||||||
|
firstChars: otherPartyKey?.substring(0, 20)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (msg.encryptedContent) {
|
||||||
|
const decrypted = await decryptMessage(msg.encryptedContent, otherPartyKey);
|
||||||
|
return { ...msg, decryptedContent: decrypted };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Chat] Failed to decrypt message:', msg.id, err);
|
||||||
|
}
|
||||||
|
return { ...msg, decryptedContent: '[Unable to decrypt - incompatible format]' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 {
|
||||||
|
console.log('[Send] Starting encryption...', {
|
||||||
|
messageLength: newMessage.length,
|
||||||
|
recipientHandle: selectedConversation.participant2.handle,
|
||||||
|
hasRecipientKey: !!recipientPublicKey,
|
||||||
|
recipientKeyLength: recipientPublicKey?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const encrypted = await encryptMessage(newMessage, recipientPublicKey);
|
||||||
|
console.log('[Send] Message encrypted, sending to server...');
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Send] Server response:', res.status, res.statusText);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
console.error('[Send] Server error:', errorData);
|
||||||
|
alert(`Failed to send: ${errorData.error || 'Unknown error'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
console.log('[Send] Success:', result);
|
||||||
|
|
||||||
|
setNewMessage('');
|
||||||
|
await loadMessages(selectedConversation.id);
|
||||||
|
loadConversations(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Send] Error:', err);
|
||||||
|
alert(`Failed to send message: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} 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(false);
|
||||||
|
}
|
||||||
|
} 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 = conversations.filter((conv) =>
|
||||||
|
conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
// Show loading while checking encryption status
|
||||||
|
if (!encryptionChecked) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
|
||||||
|
<Loader2 className="animate-spin" size={32} style={{ color: 'var(--foreground-tertiary)' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encryption Setup Screen
|
||||||
|
if (!hasKeys) {
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ maxWidth: '500px', paddingTop: '80px', paddingBottom: '80px' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 24px'
|
||||||
|
}}>
|
||||||
|
<Shield size={40} style={{ color: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: 600, marginBottom: '12px' }}>
|
||||||
|
{needsPasswordToRestore ? 'Unlock Messages' : 'Secure Messaging'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '32px', lineHeight: 1.6 }}>
|
||||||
|
{needsPasswordToRestore
|
||||||
|
? 'Enter your password to decrypt your conversation history.'
|
||||||
|
: 'End-to-end encryption keeps your personal messages private.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{needsPasswordToRestore && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--foreground-tertiary)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
Having issues? You can{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('This will delete your local encryption keys. You\'ll need to set up encryption again. Continue?')) {
|
||||||
|
localStorage.removeItem('synapsis_chat_private_key');
|
||||||
|
localStorage.removeItem('synapsis_chat_public_key');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
font: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
reset your encryption keys
|
||||||
|
</button>
|
||||||
|
{' '}and start fresh.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showPasswordInput ? (
|
||||||
|
<button onClick={() => setShowPasswordInput(true)} className="btn btn-primary">
|
||||||
|
{needsPasswordToRestore ? 'Restore Keys' : 'Enable Encryption'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handlePasswordSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{passwordError && (
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--error)',
|
||||||
|
fontSize: '13px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.2)'
|
||||||
|
}}>
|
||||||
|
{passwordError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowPasswordInput(false); setPasswordError(''); }}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
disabled={isProcessingPassword}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 2 }}
|
||||||
|
disabled={!password || isProcessingPassword}
|
||||||
|
>
|
||||||
|
{isProcessingPassword ? <Loader2 className="animate-spin" size={18} /> : 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thread View
|
||||||
|
if (selectedConversation) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedConversation(null)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--background-secondary)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div style={{ padding: '16px', minHeight: 'calc(100vh - 250px)' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
maxWidth: '70%',
|
||||||
|
marginLeft: msg.isSentByMe ? 'auto' : '0',
|
||||||
|
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
|
||||||
|
}}>
|
||||||
|
{!msg.isSentByMe && (
|
||||||
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
|
{msg.senderAvatarUrl ? (
|
||||||
|
<img src={msg.senderAvatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
msg.senderDisplayName?.[0]
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="compose">
|
||||||
|
<form onSubmit={sendMessage} 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversations List
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<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>
|
||||||
|
{!showNewChat && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewChat(true)}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewChat ? (
|
||||||
|
<form onSubmit={startNewChat} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter handle (@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={!newChatHandle.trim() || sending}
|
||||||
|
>
|
||||||
|
{sending ? <Loader2 size={14} className="animate-spin" /> : 'Start Chat'}
|
||||||
|
</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 conversations"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
flex: 1,
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversations */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
|
||||||
|
<Loader2 className="animate-spin" size={32} style={{ color: 'var(--foreground-tertiary)' }} />
|
||||||
|
</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 }} />
|
||||||
|
<h3 style={{ fontWeight: 600, fontSize: '16px', marginBottom: '8px', color: 'var(--foreground)' }}>
|
||||||
|
No conversations yet
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '14px', marginBottom: '16px' }}>
|
||||||
|
Start a conversation with someone on the network.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => setShowNewChat(true)} className="btn btn-primary">
|
||||||
|
New Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredConversations.map(conv => (
|
||||||
|
<div
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => setSelectedConversation(conv)}
|
||||||
|
className="post"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div style={{ 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', alignItems: 'center', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{conv.participant2.displayName}</span>
|
||||||
|
{conv.unreadCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
minWidth: '20px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{conv.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
|
||||||
|
{formatFullHandle(conv.participant2.handle)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: conv.unreadCount > 0 ? 'var(--foreground)' : 'var(--foreground-secondary)',
|
||||||
|
fontWeight: conv.unreadCount > 0 ? 500 : 400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{conv.lastMessagePreview}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-2
@@ -225,9 +225,21 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
|
|
||||||
|
// Only include turnstileToken if Turnstile is enabled (site key exists)
|
||||||
const body = mode === 'login'
|
const body = mode === 'login'
|
||||||
? { email, password, turnstileToken }
|
? {
|
||||||
: { email, password, handle, displayName, turnstileToken };
|
email,
|
||||||
|
password,
|
||||||
|
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
handle,
|
||||||
|
displayName,
|
||||||
|
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,511 +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, 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,7 +4,6 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { RightSidebar } from './RightSidebar';
|
import { RightSidebar } from './RightSidebar';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { ChatWidget } from './ChatWidget';
|
|
||||||
|
|
||||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const { loading } = useAuth();
|
const { loading } = useAuth();
|
||||||
@@ -17,7 +16,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||||||
pathname?.startsWith('/install');
|
pathname?.startsWith('/install');
|
||||||
|
|
||||||
// Hide right sidebar on chat page for more space
|
// Hide right sidebar on chat page for more space
|
||||||
const hideRightSidebar = pathname?.startsWith('/chat');
|
const hideRightSidebar = false;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +56,6 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
{!hideRightSidebar && <RightSidebar />}
|
{!hideRightSidebar && <RightSidebar />}
|
||||||
<ChatWidget />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-20
@@ -15,6 +15,7 @@ export function Sidebar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,6 +48,25 @@ export function Sidebar() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Fetch unread chat count
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const fetchUnreadChats = () => {
|
||||||
|
fetch('/api/chat/unread')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setUnreadChatCount(data.unreadCount || 0);
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUnreadChats();
|
||||||
|
// Poll every 10 seconds
|
||||||
|
const interval = setInterval(fetchUnreadChats, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Home is exact match
|
// Home is exact match
|
||||||
const isHome = pathname === '/';
|
const isHome = pathname === '/';
|
||||||
|
|
||||||
@@ -84,33 +104,38 @@ export function Sidebar() {
|
|||||||
<span>Explore</span>
|
<span>Explore</span>
|
||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`} style={{ position: 'relative' }}>
|
<Link href="/notifications" className={`nav-item ${pathname?.startsWith('/notifications') ? 'active' : ''}`}>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
<span>Notifications</span>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{unreadCount > 0 && (
|
Notifications
|
||||||
<span style={{
|
{unreadCount > 0 && (
|
||||||
position: 'absolute',
|
<span style={{
|
||||||
top: '8px',
|
width: '8px',
|
||||||
left: '24px',
|
height: '8px',
|
||||||
width: '8px',
|
background: 'var(--error)',
|
||||||
height: '8px',
|
borderRadius: '50%',
|
||||||
background: 'var(--error)',
|
}} />
|
||||||
borderRadius: '50%',
|
)}
|
||||||
}} />
|
</span>
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<Link href="/chat" className={`nav-item ${pathname?.startsWith('/chat') ? 'active' : ''}`}>
|
||||||
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">
|
<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>
|
<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>
|
</svg>
|
||||||
<span>Chat</span>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
</button>
|
Chat
|
||||||
|
{unreadChatCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
background: 'var(--error)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<Link href="/settings/bots" className={`nav-item ${pathname?.startsWith('/settings/bots') ? 'active' : ''}`}>
|
<Link href="/settings/bots" className={`nav-item ${pathname?.startsWith('/settings/bots') ? 'active' : ''}`}>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export function useChatEncryption() {
|
|||||||
|
|
||||||
// Check for existing keys on mount
|
// Check for existing keys on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only run in browser
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkKeys();
|
checkKeys();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -91,6 +95,9 @@ export function useChatEncryption() {
|
|||||||
|
|
||||||
// Generate new keys and register with server (encrypted backup)
|
// Generate new keys and register with server (encrypted backup)
|
||||||
const generateAndRegisterKeys = useCallback(async (password: string) => {
|
const generateAndRegisterKeys = useCallback(async (password: string) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Key generation can only be performed in the browser');
|
||||||
|
}
|
||||||
setIsRegistering(true);
|
setIsRegistering(true);
|
||||||
try {
|
try {
|
||||||
// Generate ECDH key pair using Web Crypto API
|
// Generate ECDH key pair using Web Crypto API
|
||||||
@@ -106,9 +113,18 @@ export function useChatEncryption() {
|
|||||||
const publicKey = bufferToBase64(publicKeyBuffer);
|
const publicKey = bufferToBase64(publicKeyBuffer);
|
||||||
const privateKey = bufferToBase64(privateKeyBuffer);
|
const privateKey = bufferToBase64(privateKeyBuffer);
|
||||||
|
|
||||||
|
console.log('[GenerateKeys] Generated keys:', {
|
||||||
|
publicKeyLength: publicKey.length,
|
||||||
|
privateKeyLength: privateKey.length,
|
||||||
|
publicKeyBytes: publicKeyBuffer.byteLength,
|
||||||
|
privateKeyBytes: privateKeyBuffer.byteLength
|
||||||
|
});
|
||||||
|
|
||||||
// Encrypt private key with password for server backup
|
// Encrypt private key with password for server backup
|
||||||
const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password);
|
const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password);
|
||||||
|
|
||||||
|
console.log('[GenerateKeys] Encrypted private key length:', encryptedPrivateKey.length);
|
||||||
|
|
||||||
// Register public key + encrypted private key backup with server FIRST
|
// Register public key + encrypted private key backup with server FIRST
|
||||||
const response = await fetch('/api/chat/keys', {
|
const response = await fetch('/api/chat/keys', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -147,6 +163,9 @@ export function useChatEncryption() {
|
|||||||
message: string,
|
message: string,
|
||||||
recipientPublicKey: string
|
recipientPublicKey: string
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Encryption can only be performed in the browser');
|
||||||
|
}
|
||||||
if (!keys?.privateKey) {
|
if (!keys?.privateKey) {
|
||||||
throw new Error('No chat keys available');
|
throw new Error('No chat keys available');
|
||||||
}
|
}
|
||||||
@@ -178,17 +197,21 @@ export function useChatEncryption() {
|
|||||||
encryptedMessage: string,
|
encryptedMessage: string,
|
||||||
senderPublicKey: string
|
senderPublicKey: string
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
|
// Early browser check before any operations
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '[Decryption only available in browser]';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!keys?.privateKey) {
|
if (!keys?.privateKey) {
|
||||||
console.error('[Decrypt] No private key available');
|
console.error('[Decrypt] No private key available');
|
||||||
throw new Error('No chat keys available');
|
return '[No decryption key available]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderPublicKey) {
|
||||||
|
console.error('[Decrypt] No sender public key provided');
|
||||||
|
return '[Sender key missing]';
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Decrypt] Starting decryption', {
|
|
||||||
hasPrivateKey: !!keys.privateKey,
|
|
||||||
hasSenderPublicKey: !!senderPublicKey,
|
|
||||||
encryptedLength: encryptedMessage.length
|
|
||||||
});
|
|
||||||
|
|
||||||
const myPrivateKey = await importPrivateKey(keys.privateKey);
|
const myPrivateKey = await importPrivateKey(keys.privateKey);
|
||||||
const theirPublicKey = await importPublicKey(senderPublicKey);
|
const theirPublicKey = await importPublicKey(senderPublicKey);
|
||||||
@@ -203,11 +226,6 @@ export function useChatEncryption() {
|
|||||||
const iv = combined.slice(0, 12);
|
const iv = combined.slice(0, 12);
|
||||||
const ciphertext = combined.slice(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(
|
const decrypted = await window.crypto.subtle.decrypt(
|
||||||
{ name: 'AES-GCM', iv },
|
{ name: 'AES-GCM', iv },
|
||||||
sharedKey,
|
sharedKey,
|
||||||
@@ -215,13 +233,22 @@ export function useChatEncryption() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const result = decoder.decode(decrypted);
|
return decoder.decode(decrypted);
|
||||||
console.log('[Decrypt] Success:', result.substring(0, 50));
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Decrypt] Failed:', error);
|
console.warn('[Decrypt] Failed:', error instanceof Error ? error.message : error);
|
||||||
// Return a safe placeholder that won't crash the UI
|
// Return a descriptive placeholder based on the error
|
||||||
return '[Message cannot be decrypted]';
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes('public key') || error.message.includes('import key')) {
|
||||||
|
return '[Incompatible encryption format]';
|
||||||
|
}
|
||||||
|
if (error.message.includes('private key')) {
|
||||||
|
return '[Invalid private key]';
|
||||||
|
}
|
||||||
|
if (error.message.includes('base64') || error.message.includes('decode')) {
|
||||||
|
return '[Corrupted message data]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '[Cannot decrypt message]';
|
||||||
}
|
}
|
||||||
}, [keys]);
|
}, [keys]);
|
||||||
|
|
||||||
@@ -251,6 +278,10 @@ export function useChatEncryption() {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise<string> {
|
async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise<string> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Encryption can only be performed in the browser');
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
@@ -293,6 +324,10 @@ async function encryptPrivateKeyWithPassword(privateKey: string, password: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise<string> {
|
async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise<string> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Decryption can only be performed in the browser');
|
||||||
|
}
|
||||||
|
|
||||||
const { salt, iv, ciphertext } = JSON.parse(encryptedData);
|
const { salt, iv, ciphertext } = JSON.parse(encryptedData);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -334,17 +369,58 @@ async function decryptPrivateKeyWithPassword(encryptedData: string, password: st
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||||
const keyBuffer = base64ToBuffer(publicKeyBase64);
|
if (typeof window === 'undefined') {
|
||||||
return window.crypto.subtle.importKey(
|
throw new Error('Crypto operations can only be performed in the browser');
|
||||||
'spki',
|
}
|
||||||
keyBuffer,
|
|
||||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
// Validate the key format
|
||||||
false,
|
if (!publicKeyBase64 || typeof publicKeyBase64 !== 'string') {
|
||||||
[]
|
throw new Error('Invalid public key: must be a non-empty string');
|
||||||
);
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyBuffer = base64ToBuffer(publicKeyBase64);
|
||||||
|
|
||||||
|
// Try SPKI format first (standard format, typically ~91 bytes for P-256)
|
||||||
|
try {
|
||||||
|
return await window.crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
keyBuffer,
|
||||||
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
} catch (spkiError) {
|
||||||
|
// Try raw format (65 bytes for uncompressed P-256 public key)
|
||||||
|
// Raw format is: 0x04 + X coordinate (32 bytes) + Y coordinate (32 bytes)
|
||||||
|
if (keyBuffer.byteLength === 65) {
|
||||||
|
try {
|
||||||
|
return await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBuffer,
|
||||||
|
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
} catch (rawError) {
|
||||||
|
// Both formats failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither worked, throw a descriptive error
|
||||||
|
throw new Error(`Cannot import key (${keyBuffer.byteLength} bytes): incompatible format`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn('[ImportPublicKey] Failed:', errorMsg);
|
||||||
|
throw new Error(`Failed to import public key: ${errorMsg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
|
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Crypto operations can only be performed in the browser');
|
||||||
|
}
|
||||||
const keyBuffer = base64ToBuffer(privateKeyBase64);
|
const keyBuffer = base64ToBuffer(privateKeyBase64);
|
||||||
return window.crypto.subtle.importKey(
|
return window.crypto.subtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
@@ -359,6 +435,10 @@ async function deriveSharedKey(
|
|||||||
myPrivateKey: CryptoKey,
|
myPrivateKey: CryptoKey,
|
||||||
theirPublicKey: CryptoKey
|
theirPublicKey: CryptoKey
|
||||||
): Promise<CryptoKey> {
|
): Promise<CryptoKey> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Key derivation can only be performed in the browser');
|
||||||
|
}
|
||||||
|
|
||||||
return window.crypto.subtle.deriveKey(
|
return window.crypto.subtle.deriveKey(
|
||||||
{ name: 'ECDH', public: theirPublicKey },
|
{ name: 'ECDH', public: theirPublicKey },
|
||||||
myPrivateKey,
|
myPrivateKey,
|
||||||
@@ -373,6 +453,11 @@ async function deriveSharedKey(
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
function bufferToBase64(buffer: ArrayBuffer): string {
|
function bufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
// btoa is available in both browser and Node 16+, but let's be safe
|
||||||
|
if (typeof btoa === 'undefined') {
|
||||||
|
throw new Error('Base64 encoding not available in this environment');
|
||||||
|
}
|
||||||
|
|
||||||
const bytes = new Uint8Array(buffer);
|
const bytes = new Uint8Array(buffer);
|
||||||
let binary = '';
|
let binary = '';
|
||||||
for (let i = 0; i < bytes.byteLength; i++) {
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
@@ -382,7 +467,7 @@ function bufferToBase64(buffer: ArrayBuffer): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function base64ToBuffer(base64: string): ArrayBuffer {
|
function base64ToBuffer(base64: string): ArrayBuffer {
|
||||||
// Gracefull handle null/undefined
|
// Gracefully handle null/undefined
|
||||||
if (!base64) return new ArrayBuffer(0);
|
if (!base64) return new ArrayBuffer(0);
|
||||||
|
|
||||||
// Check for JSON (legacy format)
|
// Check for JSON (legacy format)
|
||||||
@@ -401,6 +486,11 @@ function base64ToBuffer(base64: string): ArrayBuffer {
|
|||||||
.replace(/_/g, '/');
|
.replace(/_/g, '/');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// atob is available in both browser and Node 16+, but let's be safe
|
||||||
|
if (typeof atob === 'undefined') {
|
||||||
|
throw new Error('Base64 decoding not available in this environment');
|
||||||
|
}
|
||||||
|
|
||||||
const binary = atob(cleaned);
|
const binary = atob(cleaned);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
for (let i = 0; i < binary.length; i++) {
|
for (let i = 0; i < binary.length; i++) {
|
||||||
@@ -409,8 +499,6 @@ function base64ToBuffer(base64: string): ArrayBuffer {
|
|||||||
return bytes.buffer;
|
return bytes.buffer;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[base64ToBuffer] Failed to decode base64:', 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)}`);
|
throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user