Refactor chat encryption to use sodium, update API
Replaces legacy chat encryption modules with new sodium-based implementation. Adds and updates API routes for chat key management and debugging, introduces new hooks and scripts for sodium chat, and updates related database schema and configuration. Removes old crypto modules and updates dependencies accordingly.
This commit is contained in:
@@ -10,33 +10,24 @@ export async function GET(
|
||||
) {
|
||||
const { did } = await params;
|
||||
|
||||
// 1. Fetch all devices for this DID
|
||||
const bundles = await db.query.chatDeviceBundles.findMany({
|
||||
// Fetch device bundle for this DID
|
||||
const bundle = await db.query.chatDeviceBundles.findFirst({
|
||||
where: eq(chatDeviceBundles.did, did),
|
||||
with: {
|
||||
oneTimeKeys: {
|
||||
limit: 5, // Return a few keys; client picks one
|
||||
// Ideally we pick non-conflicted ones or random ones
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!bundles || bundles.length === 0) {
|
||||
return NextResponse.json([], { status: 404 });
|
||||
if (!bundle) {
|
||||
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Format Response
|
||||
const response = bundles.map(b => ({
|
||||
did: b.did,
|
||||
deviceId: b.deviceId,
|
||||
identityKey: b.identityKey, // Base64 X25519
|
||||
signedPreKey: JSON.parse(b.signedPreKey),
|
||||
oneTimeKeys: b.oneTimeKeys.map(k => ({
|
||||
id: k.keyId,
|
||||
key: k.publicKey
|
||||
})),
|
||||
signature: b.signature // ECDSA signature of this bundle
|
||||
}));
|
||||
const signedPreKey = JSON.parse(bundle.signedPreKey);
|
||||
const kyberPreKey = bundle.kyberPreKey ? JSON.parse(bundle.kyberPreKey) : null;
|
||||
|
||||
// Format Response for Olm
|
||||
const response = {
|
||||
identityKey: bundle.identityKey, // curve25519
|
||||
signingKey: signedPreKey.signingKey, // ed25519
|
||||
oneTimeKeys: kyberPreKey?.oneTimeKeys || [],
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
|
||||
+101
-60
@@ -1,79 +1,120 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatDeviceBundles, chatOneTimeKeys } from '@/db/schema';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { chatDeviceBundles } from '@/db/schema';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* GET /api/chat/keys?did=<did>
|
||||
* Fetch public key for a user
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const did = searchParams.get('did');
|
||||
|
||||
if (!did) {
|
||||
return NextResponse.json({ error: 'Missing did parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('[Chat Keys GET] Looking for DID:', did);
|
||||
|
||||
const bundle = await db.query.chatDeviceBundles.findFirst({
|
||||
where: eq(chatDeviceBundles.did, did),
|
||||
});
|
||||
|
||||
console.log('[Chat Keys GET] Found bundle:', bundle ? 'YES' : 'NO');
|
||||
if (bundle) {
|
||||
console.log('[Chat Keys GET] Bundle data:', {
|
||||
userId: bundle.userId,
|
||||
did: bundle.did,
|
||||
deviceId: bundle.deviceId,
|
||||
hasIdentityKey: !!bundle.identityKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (!bundle) {
|
||||
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// For libsodium, we just need the public key
|
||||
return NextResponse.json({
|
||||
publicKey: bundle.identityKey, // Reusing identityKey field for libsodium public key
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Chat Keys] Failed to fetch keys:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/keys
|
||||
* Publish a new Device Bundle and OTKs.
|
||||
* Publish public key
|
||||
*
|
||||
* Payload: SignedAction < {
|
||||
* deviceId: string,
|
||||
* identityKey: string,
|
||||
* signedPreKey: { id, key, sig },
|
||||
* signature: string, (The ECDSA signature of the bundle itself)
|
||||
* oneTimeKeys: { id, key }[]
|
||||
* } >
|
||||
* Body: {
|
||||
* publicKey: string (base64)
|
||||
* }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
console.log('[Chat Keys POST] User:', user.handle, 'DID:', user.did, 'UserID:', user.id);
|
||||
|
||||
const body = await request.json();
|
||||
const { publicKey } = body;
|
||||
|
||||
// 1. Verify User Identity (ECDSA Root)
|
||||
// The wrapper itself is a SignedAction with action="chat.keys.publish"
|
||||
// This proves the user (DID) is authorizing this device registration.
|
||||
const user = await requireSignedAction(body);
|
||||
console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING');
|
||||
|
||||
const { deviceId, identityKey, signedPreKey, signature, oneTimeKeys } = body.data;
|
||||
if (!publicKey) {
|
||||
return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Validate Bundle Signature (The bundle.signature must cover the fields)
|
||||
// Actually, requireSignedAction already verified the payload signature.
|
||||
// The "signature" field inside data is redundant if the whole thing is signed by DID?
|
||||
// Or is "signature" the signature of the bundle bytes?
|
||||
// In our design, the SignedAction *is* the signature.
|
||||
// So "signature" inside might be the "Self-Signature" of the X25519 Identity Key signing the structure?
|
||||
// No, standard Signal: The Bundle is signed by Identity Key.
|
||||
// Here, Identity Key is ECDSA. The SignedAction covers it.
|
||||
// So we just trust the SignedAction.
|
||||
|
||||
// 3. Upsert Bundle
|
||||
await db.transaction(async (tx) => {
|
||||
// Upsert device bundle
|
||||
await tx.delete(chatDeviceBundles).where(
|
||||
and(
|
||||
eq(chatDeviceBundles.userId, user.id),
|
||||
eq(chatDeviceBundles.deviceId, deviceId)
|
||||
)
|
||||
);
|
||||
|
||||
const [bundle] = await tx.insert(chatDeviceBundles).values({
|
||||
userId: user.id,
|
||||
did: user.did,
|
||||
deviceId,
|
||||
identityKey,
|
||||
signedPreKey: JSON.stringify(signedPreKey),
|
||||
signature, // We store the action signature or the explicit inner signature provided
|
||||
}).returning();
|
||||
|
||||
// 4. Insert OTKs
|
||||
if (oneTimeKeys && oneTimeKeys.length > 0) {
|
||||
await tx.insert(chatOneTimeKeys).values(
|
||||
oneTimeKeys.map((k: any) => ({
|
||||
userId: user.id,
|
||||
bundleId: bundle.id,
|
||||
keyId: k.id,
|
||||
publicKey: k.key
|
||||
}))
|
||||
).onConflictDoNothing();
|
||||
}
|
||||
// Check if bundle exists
|
||||
const existing = await db.query.chatDeviceBundles.findFirst({
|
||||
where: and(
|
||||
eq(chatDeviceBundles.userId, user.id),
|
||||
eq(chatDeviceBundles.deviceId, '1')
|
||||
)
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, count: oneTimeKeys?.length || 0 });
|
||||
console.log('[Chat Keys POST] Existing bundle found:', existing ? 'YES' : 'NO');
|
||||
|
||||
if (existing) {
|
||||
// Update existing bundle
|
||||
console.log('[Chat Keys POST] Updating existing bundle...');
|
||||
await db.update(chatDeviceBundles)
|
||||
.set({
|
||||
identityKey: publicKey,
|
||||
did: user.did, // Update DID too in case it changed
|
||||
lastSeenAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeviceBundles.userId, user.id),
|
||||
eq(chatDeviceBundles.deviceId, '1')
|
||||
)
|
||||
);
|
||||
console.log('[Chat Keys POST] Updated existing key');
|
||||
} else {
|
||||
// Insert new bundle
|
||||
console.log('[Chat Keys POST] Inserting new bundle...');
|
||||
await db.insert(chatDeviceBundles).values({
|
||||
userId: user.id,
|
||||
did: user.did,
|
||||
deviceId: '1',
|
||||
identityKey: publicKey,
|
||||
signedPreKey: 'libsodium', // Placeholder
|
||||
registrationId: 1,
|
||||
signature: 'libsodium',
|
||||
});
|
||||
console.log('[Chat Keys POST] Inserted new key');
|
||||
}
|
||||
|
||||
console.log('[Chat Keys POST] Key published successfully for', user.handle);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to publish keys:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
console.error('[Chat Keys] Failed to publish key:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-160
@@ -1,212 +1,116 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatInbox, chatConversations, chatMessages, users } from '@/db/schema';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { chatConversations, chatMessages, users } from '@/db/schema';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { signedFetch } from '@/lib/api/signed-fetch';
|
||||
|
||||
/**
|
||||
* POST /api/chat/send
|
||||
* Deliver an encrypted envelope to a local or remote user's inbox.
|
||||
* Store encrypted message (server never decrypts)
|
||||
*
|
||||
* Body: {
|
||||
* recipientDid: string,
|
||||
* senderPublicKey: string (base64),
|
||||
* ciphertext: string (base64),
|
||||
* nonce: string (base64),
|
||||
* recipientHandle?: string
|
||||
* }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
|
||||
const body = await request.json();
|
||||
const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body;
|
||||
|
||||
// 1. Verify Envelope Signature (Anti-Spoofing & Replay)
|
||||
const user = await requireSignedAction(body);
|
||||
|
||||
const { action, data } = body;
|
||||
if (action !== 'chat.deliver') {
|
||||
return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
|
||||
if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { recipientDid, recipientDeviceId, ciphertext, nodeDomain, recipientHandle } = data;
|
||||
|
||||
if (!recipientDid || !ciphertext) {
|
||||
return NextResponse.json({ error: 'Missing required delivery fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Check if recipient is local or remote
|
||||
// Check if recipient is local
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: eq(users.did, recipientDid)
|
||||
});
|
||||
|
||||
if (recipientUser) {
|
||||
// LOCAL RECIPIENT - Store in local inbox
|
||||
// LOCAL RECIPIENT
|
||||
|
||||
// Ensure conversation exists for recipient
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
// Ensure conversations exist
|
||||
let recipientConv = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipientUser.id),
|
||||
eq(chatConversations.participant2Handle, user.handle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
if (!recipientConv) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipientUser.id,
|
||||
participant2Handle: user.handle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted Message]'
|
||||
lastMessagePreview: '[Encrypted]'
|
||||
}).returning();
|
||||
conversation = newConv;
|
||||
recipientConv = newConv;
|
||||
}
|
||||
|
||||
// Ensure conversation exists for sender
|
||||
let senderConversation = await db.query.chatConversations.findFirst({
|
||||
let senderConv = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, user.id),
|
||||
eq(chatConversations.participant2Handle, recipientUser.handle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!senderConversation) {
|
||||
await db.insert(chatConversations).values({
|
||||
if (!senderConv) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: user.id,
|
||||
participant2Handle: recipientUser.handle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted Message]'
|
||||
});
|
||||
lastMessagePreview: '[Encrypted]'
|
||||
}).returning();
|
||||
senderConv = newConv;
|
||||
}
|
||||
|
||||
// Insert into local inbox
|
||||
await db.insert(chatInbox).values({
|
||||
// Store encrypted message (libsodium format)
|
||||
// Include recipientDid so sender can decrypt their own sent messages
|
||||
const messageData = {
|
||||
senderPublicKey,
|
||||
recipientDid, // Add this so sender knows who to decrypt with
|
||||
ciphertext,
|
||||
nonce,
|
||||
};
|
||||
|
||||
// Create message for recipient
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: recipientConv.id,
|
||||
senderHandle: user.handle,
|
||||
senderDisplayName: user.displayName,
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: null,
|
||||
senderDid: user.did,
|
||||
recipientDid,
|
||||
recipientDeviceId: recipientDeviceId || null,
|
||||
envelope: JSON.stringify(body),
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
||||
encryptedContent: JSON.stringify(messageData),
|
||||
deliveredAt: new Date(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, local: true });
|
||||
// Create message for sender
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: senderConv.id,
|
||||
senderHandle: user.handle,
|
||||
senderDisplayName: user.displayName,
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: null,
|
||||
senderDid: user.did,
|
||||
encryptedContent: JSON.stringify(messageData),
|
||||
deliveredAt: new Date(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
// REMOTE RECIPIENT - Forward to their node
|
||||
|
||||
let targetNodeDomain = nodeDomain;
|
||||
|
||||
if (!targetNodeDomain) {
|
||||
// Try to extract from DID
|
||||
if (recipientDid.startsWith('did:web:')) {
|
||||
targetNodeDomain = recipientDid.replace('did:web:', '');
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
error: 'Remote delivery requires node domain'
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Forward the signed envelope to the remote node
|
||||
const remoteUrl = `https://${targetNodeDomain}/api/swarm/chat/inbox`;
|
||||
|
||||
try {
|
||||
console.log('[Chat Send] Forwarding to remote:', remoteUrl);
|
||||
|
||||
// Get or create conversation for sender
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, user.id),
|
||||
eq(chatConversations.participant2Handle, recipientHandle || recipientDid)
|
||||
)
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: user.id,
|
||||
participant2Handle: recipientHandle || recipientDid,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: '[Encrypted Message]'
|
||||
}).returning();
|
||||
conversation = newConv;
|
||||
console.log('[Chat Send] Created conversation:', conversation.id);
|
||||
}
|
||||
|
||||
// Store message locally so sender can see it
|
||||
const messageId = crypto.randomUUID();
|
||||
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
const swarmMessageId = `swarm:${localDomain}:${messageId}`;
|
||||
|
||||
// Store full envelope data for decryption
|
||||
const envelopeData = {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
ciphertext: ciphertext
|
||||
};
|
||||
|
||||
const [newMessage] = await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: user.handle,
|
||||
senderDisplayName: user.displayName,
|
||||
senderAvatarUrl: user.avatarUrl,
|
||||
senderNodeDomain: null, // Local sender
|
||||
encryptedContent: JSON.stringify(envelopeData), // Full envelope
|
||||
senderChatPublicKey: null, // V2 E2E - keys are in the envelope
|
||||
swarmMessageId,
|
||||
deliveredAt: null, // Will update when remote confirms
|
||||
readAt: null,
|
||||
}).returning();
|
||||
|
||||
console.log('[Chat Send] Stored local message:', newMessage.id);
|
||||
|
||||
const response = await fetch(remoteUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData;
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = { raw: errorText };
|
||||
}
|
||||
console.error('[Chat Send] Remote delivery failed:', response.status, errorData);
|
||||
|
||||
// Check if remote node doesn't support V2 (returns V1 validation errors)
|
||||
const isV1ValidationError = errorData.details &&
|
||||
Array.isArray(errorData.details) &&
|
||||
errorData.details.some((d: any) =>
|
||||
d.path && ['messageId', 'senderHandle', 'senderNodeDomain', 'recipientHandle', 'encryptedContent', 'timestamp'].includes(d.path[0])
|
||||
);
|
||||
|
||||
if (isV1ValidationError) {
|
||||
return NextResponse.json({
|
||||
error: 'Remote node needs update',
|
||||
details: 'The recipient node is running an older version that does not support the V2 chat protocol. Please ask the node administrator to update to the latest version.',
|
||||
remoteError: errorData
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Remote delivery failed',
|
||||
details: errorData
|
||||
}, { status: response.status });
|
||||
}
|
||||
|
||||
// Mark message as delivered since remote accepted it
|
||||
await db.update(chatMessages)
|
||||
.set({ deliveredAt: new Date() })
|
||||
.where(eq(chatMessages.id, newMessage.id));
|
||||
|
||||
console.log('[Chat Send] Message marked as delivered');
|
||||
|
||||
return NextResponse.json({ success: true, remote: true, messageId: newMessage.id });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[Chat Send] Remote delivery error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Failed to reach remote node',
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
// REMOTE RECIPIENT - not implemented yet
|
||||
return NextResponse.json({ error: 'Remote delivery not yet implemented' }, { status: 501 });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Delivery failed:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
console.error('Send failed:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, chatDeviceBundles } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handle = searchParams.get('handle');
|
||||
|
||||
if (!handle) {
|
||||
return NextResponse.json({ error: 'Missing handle parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check for device bundles
|
||||
const bundles = await db.query.chatDeviceBundles.findMany({
|
||||
where: eq(chatDeviceBundles.userId, user.id),
|
||||
with: {
|
||||
oneTimeKeys: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
did: user.did,
|
||||
hasChatPublicKey: !!user.chatPublicKey,
|
||||
},
|
||||
bundles: bundles.map(b => ({
|
||||
deviceId: b.deviceId,
|
||||
identityKey: b.identityKey?.substring(0, 20) + '...',
|
||||
hasSignedPreKey: !!b.signedPreKey,
|
||||
oneTimeKeysCount: b.oneTimeKeys.length,
|
||||
})),
|
||||
bundleCount: bundles.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Check chat keys error:', error);
|
||||
return NextResponse.json({ error: 'Failed to check keys' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatDeviceBundles } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const bundles = await db.select({
|
||||
did: chatDeviceBundles.did,
|
||||
userId: chatDeviceBundles.userId,
|
||||
deviceId: chatDeviceBundles.deviceId,
|
||||
identityKey: chatDeviceBundles.identityKey,
|
||||
createdAt: chatDeviceBundles.createdAt,
|
||||
}).from(chatDeviceBundles).orderBy(chatDeviceBundles.createdAt);
|
||||
|
||||
return NextResponse.json({ bundles, count: bundles.length });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatDeviceBundles } from '@/db/schema';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
console.log('[Debug] Clearing all chat device bundles...');
|
||||
|
||||
await db.delete(chatDeviceBundles);
|
||||
|
||||
console.log('[Debug] Cleared all chat device bundles');
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Cleared all chat keys' });
|
||||
} catch (error: any) {
|
||||
console.error('[Debug] Failed to clear keys:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const parseRemoteHandleQuery = (query: string): { handle: string; domain: string
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q') || '';
|
||||
let query = searchParams.get('q') || '';
|
||||
const type = searchParams.get('type') || 'all'; // all, users, posts
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
@@ -50,7 +50,22 @@ export async function GET(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`;
|
||||
// Normalize query for local user search
|
||||
// Strip leading @ and local domain if present
|
||||
let localSearchQuery = query.trim();
|
||||
if (localSearchQuery.startsWith('@')) {
|
||||
localSearchQuery = localSearchQuery.slice(1);
|
||||
}
|
||||
// Remove local domain if searching like "admin2@dev.syn.quest"
|
||||
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN;
|
||||
if (localDomain && localSearchQuery.includes('@')) {
|
||||
const parts = localSearchQuery.split('@');
|
||||
if (parts[1] === localDomain) {
|
||||
localSearchQuery = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
const searchPattern = `%${localSearchQuery}%`;
|
||||
let searchUsers: SearchUser[] = [];
|
||||
let searchPosts: typeof posts.$inferSelect[] = [];
|
||||
|
||||
|
||||
@@ -64,8 +64,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Also create conversation and message for recipient UI
|
||||
// Extract sender info from envelope
|
||||
const senderHandle = body.handle || body.did; // Fallback to DID if no handle
|
||||
const senderFullHandle = `${senderHandle}@${body.did?.split(':')[2] || 'unknown'}`; // Extract domain from DID
|
||||
const senderHandle = body.handle || 'unknown';
|
||||
const senderNodeDomain = body.data?.senderNodeDomain || 'unknown';
|
||||
const senderFullHandle = senderNodeDomain !== 'unknown'
|
||||
? `${senderHandle}@${senderNodeDomain}`
|
||||
: senderHandle;
|
||||
|
||||
console.log('[Swarm Chat V2] Sender info:', { senderHandle, senderNodeDomain, senderFullHandle });
|
||||
|
||||
// Get or create conversation for recipient
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
@@ -108,7 +113,7 @@ export async function POST(request: NextRequest) {
|
||||
senderHandle: senderFullHandle,
|
||||
senderDisplayName: null, // Unknown until decrypted
|
||||
senderAvatarUrl: null,
|
||||
senderNodeDomain: body.did?.split(':')[2] || null,
|
||||
senderNodeDomain: senderNodeDomain !== 'unknown' ? senderNodeDomain : null,
|
||||
encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption
|
||||
senderChatPublicKey: null,
|
||||
swarmMessageId: `swarm:v2:${messageId}`,
|
||||
|
||||
+231
-179
@@ -3,43 +3,11 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
|
||||
import { loadDeviceKeys } from '@/lib/crypto/chat-storage';
|
||||
import { useSodiumChat } from '@/lib/hooks/useSodiumChat';
|
||||
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
// Helper to decrypt self-encrypted message (copied from useChatEncryption)
|
||||
async function decryptForSelf(selfEncrypted: { ciphertext: string; iv: string }, identityKeyPair: CryptoKeyPair): Promise<string> {
|
||||
// Export public key raw to use as key material (same as encryption)
|
||||
const keyMaterial = await crypto.subtle.exportKey('raw', identityKeyPair.publicKey);
|
||||
|
||||
// Derive AES key
|
||||
const aesKey = await crypto.subtle.digest('SHA-256', keyMaterial);
|
||||
|
||||
// Import as AES-GCM key
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
aesKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decode IV and ciphertext
|
||||
const iv = Uint8Array.from(atob(selfEncrypted.iv), c => c.charCodeAt(0));
|
||||
const ciphertext = Uint8Array.from(atob(selfEncrypted.ciphertext), c => c.charCodeAt(0));
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
cryptoKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
participant2: {
|
||||
@@ -69,15 +37,13 @@ interface Message {
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { user, setShowUnlockPrompt } = useAuth();
|
||||
const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
|
||||
const router = useRouter();
|
||||
// V2 Hook Destructuring
|
||||
const { isReady, isLocked, status, ensureReady, sendMessage, decryptMessage } = useChatEncryption();
|
||||
// Libsodium E2EE Hook
|
||||
const { isReady, status, sendMessage, decryptMessage } = useSodiumChat();
|
||||
const searchParams = useSearchParams();
|
||||
const composeHandle = searchParams.get('compose');
|
||||
|
||||
|
||||
|
||||
// Chat Data State
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
@@ -88,15 +54,71 @@ export default function ChatPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
|
||||
// Cache for decrypted messages to avoid re-decrypting on every poll
|
||||
const decryptedCacheRef = useRef<Map<string, string>>(new Map());
|
||||
// Track which messages we've attempted to decrypt (even if they failed)
|
||||
const attemptedDecryptionRef = useRef<Set<string>>(new Set());
|
||||
// Track the current conversation ID to prevent race conditions
|
||||
const currentConversationIdRef = useRef<string | null>(null);
|
||||
|
||||
// Load conversations
|
||||
useEffect(() => {
|
||||
if (user && isReady) {
|
||||
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, isReady]);
|
||||
|
||||
// Handle Compose Intent
|
||||
useEffect(() => {
|
||||
if (composeHandle && isReady && !selectedConversation && !showNewChat) {
|
||||
setNewChatHandle(composeHandle);
|
||||
setShowNewChat(true);
|
||||
// We could auto-submit here if we refactored startNewChat to be separate from event hnadler
|
||||
if (composeHandle && isReady && !selectedConversation && conversations.length >= 0) {
|
||||
// Check if we already have a conversation with this user
|
||||
const existing = conversations.find(c =>
|
||||
c.participant2.handle.toLowerCase() === composeHandle.toLowerCase()
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
setSelectedConversation(existing);
|
||||
// Clear the query param so refresh doesn't keep resetting state
|
||||
router.replace('/chat', { scroll: false });
|
||||
} else if (!loading) {
|
||||
// Fetch user details to create a draft conversation
|
||||
const fetchUserAndInitDraft = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`);
|
||||
const data = await res.json();
|
||||
if (data.user) {
|
||||
const draftConv: Conversation = {
|
||||
id: 'new',
|
||||
participant2: {
|
||||
handle: data.user.handle,
|
||||
displayName: data.user.displayName || data.user.handle,
|
||||
avatarUrl: data.user.avatarUrl,
|
||||
did: data.user.did
|
||||
},
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
lastMessagePreview: 'New Conversation',
|
||||
unreadCount: 0
|
||||
};
|
||||
setSelectedConversation(draftConv);
|
||||
router.replace('/chat', { scroll: false });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load user for compose", e);
|
||||
}
|
||||
};
|
||||
fetchUserAndInitDraft();
|
||||
}
|
||||
}
|
||||
}, [composeHandle, isReady, selectedConversation, showNewChat]);
|
||||
}, [composeHandle, isReady, selectedConversation, conversations, loading, router]);
|
||||
|
||||
|
||||
// Legacy / V2 Hybrid State
|
||||
@@ -135,33 +157,39 @@ export default function ChatPage() {
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
// Load conversations
|
||||
useEffect(() => {
|
||||
if (user && isReady) {
|
||||
// ... existing loadConversations code ...
|
||||
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, isReady]);
|
||||
|
||||
// Load messages when conversation is selected
|
||||
useEffect(() => {
|
||||
if (selectedConversation && isReady) {
|
||||
// Update current conversation ref
|
||||
currentConversationIdRef.current = selectedConversation.id;
|
||||
|
||||
// Clear messages immediately to prevent flash
|
||||
setMessages([]);
|
||||
|
||||
if (selectedConversation.id === 'new') {
|
||||
setLoadingMessages(false);
|
||||
return; // Don't load messages for new/draft conversation
|
||||
}
|
||||
|
||||
setLoadingMessages(true);
|
||||
|
||||
loadMessages(selectedConversation.id);
|
||||
markAsRead(selectedConversation.id);
|
||||
|
||||
// Poll for new messages every 3 seconds
|
||||
const pollInterval = setInterval(() => {
|
||||
loadMessages(selectedConversation.id);
|
||||
// Only load if still the same conversation
|
||||
if (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') {
|
||||
loadMessages(selectedConversation.id);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
} else if (!selectedConversation) {
|
||||
// Clear messages when no conversation selected
|
||||
currentConversationIdRef.current = null;
|
||||
setMessages([]);
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [selectedConversation, isReady]);
|
||||
|
||||
@@ -192,97 +220,77 @@ export default function ChatPage() {
|
||||
|
||||
const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => {
|
||||
try {
|
||||
console.log('[Chat UI] Processing message:', {
|
||||
id: msg.id,
|
||||
isSentByMe: msg.isSentByMe,
|
||||
hasEncryptedContent: !!msg.encryptedContent,
|
||||
contentPreview: msg.encryptedContent?.substring(0, 100)
|
||||
});
|
||||
// Check cache first
|
||||
const cacheKey = `${msg.id}`;
|
||||
const cached = decryptedCacheRef.current.get(cacheKey);
|
||||
if (cached) {
|
||||
return { ...msg, decryptedContent: cached };
|
||||
}
|
||||
|
||||
// Check if this is a V2 encrypted message (JSON envelope)
|
||||
// Check if already attempted
|
||||
if (attemptedDecryptionRef.current.has(cacheKey)) {
|
||||
const fallback = decryptedCacheRef.current.get(cacheKey) || '🔒 [Encrypted]';
|
||||
return { ...msg, decryptedContent: fallback };
|
||||
}
|
||||
|
||||
// Mark as attempted
|
||||
attemptedDecryptionRef.current.add(cacheKey);
|
||||
|
||||
// Parse libsodium message format
|
||||
if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) {
|
||||
try {
|
||||
// First layer: { did, handle, ciphertext }
|
||||
const envelope = JSON.parse(msg.encryptedContent);
|
||||
console.log('[Chat UI] Parsed envelope:', {
|
||||
hasDid: !!envelope.did,
|
||||
hasHandle: !!envelope.handle,
|
||||
hasCiphertext: !!envelope.ciphertext,
|
||||
ciphertextPreview: envelope.ciphertext?.substring(0, 100)
|
||||
});
|
||||
|
||||
// Second layer: the actual payload with selfEncrypted
|
||||
let payload;
|
||||
if (envelope.ciphertext && typeof envelope.ciphertext === 'string' && envelope.ciphertext.startsWith('{')) {
|
||||
payload = JSON.parse(envelope.ciphertext);
|
||||
console.log('[Chat UI] Parsed V2 payload:', {
|
||||
hasSelfEncrypted: !!payload.selfEncrypted,
|
||||
hasCiphertext: !!payload.ciphertext,
|
||||
recipientDeviceId: payload.recipientDeviceId,
|
||||
senderDeviceId: payload.senderDeviceId
|
||||
});
|
||||
} else {
|
||||
// Old format or malformed
|
||||
payload = envelope;
|
||||
}
|
||||
// Libsodium format: {senderPublicKey, recipientDid, ciphertext, nonce}
|
||||
if (envelope.senderPublicKey && envelope.ciphertext && envelope.nonce) {
|
||||
// For decryption with crypto_box_open_easy:
|
||||
// - We need the OTHER party's public key
|
||||
// - We use OUR private key
|
||||
|
||||
// For messages we sent, try to decrypt the selfEncrypted copy
|
||||
if (msg.isSentByMe && payload.selfEncrypted && user?.did) {
|
||||
console.log('[Chat UI] Attempting to decrypt self-encrypted message');
|
||||
try {
|
||||
const localKeys = await loadDeviceKeys();
|
||||
if (localKeys) {
|
||||
const plaintext = await decryptForSelf(payload.selfEncrypted, localKeys.identity);
|
||||
console.log('[Chat UI] Self-decrypt successful:', plaintext.substring(0, 50));
|
||||
return { ...msg, decryptedContent: plaintext };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Chat UI] Self-decrypt failed:', e);
|
||||
}
|
||||
}
|
||||
// console.log('[Chat UI] Decrypting message:', {
|
||||
// isSentByMe: msg.isSentByMe,
|
||||
// recipientDid: envelope.recipientDid,
|
||||
// senderPublicKey: envelope.senderPublicKey?.substring(0, 20) + '...'
|
||||
// });
|
||||
|
||||
// For messages we received, decrypt normally
|
||||
if (!msg.isSentByMe) {
|
||||
console.log('[Chat UI] Attempting to decrypt received message');
|
||||
// Need to get the sender's DID
|
||||
let senderDid = msg.senderDid || envelope.did;
|
||||
|
||||
if (!senderDid && msg.senderHandle) {
|
||||
// Try to resolve DID from handle
|
||||
// If I sent this message, the "other party" is the recipient
|
||||
// If I received this message, the "other party" is the sender
|
||||
let otherPartyPublicKey = envelope.senderPublicKey;
|
||||
|
||||
if (msg.isSentByMe && envelope.recipientDid) {
|
||||
// I'm the sender, so I need the recipient's public key to decrypt my own message
|
||||
try {
|
||||
const userRes = await fetch(`/api/users/${encodeURIComponent(msg.senderHandle)}`);
|
||||
if (userRes.ok) {
|
||||
const userData = await userRes.json();
|
||||
senderDid = userData.user?.did;
|
||||
const keyRes = await fetch(`/api/chat/keys?did=${encodeURIComponent(envelope.recipientDid)}`);
|
||||
if (keyRes.ok) {
|
||||
const keyData = await keyRes.json();
|
||||
otherPartyPublicKey = keyData.publicKey;
|
||||
// console.log('[Chat UI] Fetched recipient public key:', otherPartyPublicKey?.substring(0, 20) + '...');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Chat UI] Failed to resolve sender DID:', e);
|
||||
console.error('[Chat UI] Failed to fetch recipient key:', e);
|
||||
}
|
||||
} else {
|
||||
// console.log('[Chat UI] Using sender public key from envelope');
|
||||
}
|
||||
|
||||
if (senderDid) {
|
||||
// Create the envelope structure that decryptMessage expects
|
||||
const decryptEnvelope = {
|
||||
did: senderDid,
|
||||
data: {
|
||||
ciphertext: envelope.ciphertext // Pass the inner payload JSON string
|
||||
}
|
||||
};
|
||||
const dec = await decryptMessage(decryptEnvelope);
|
||||
console.log('[Chat UI] Received decrypt result:', dec.substring(0, 50));
|
||||
if (!dec.startsWith('[')) {
|
||||
return { ...msg, decryptedContent: dec };
|
||||
}
|
||||
}
|
||||
const plaintext = await decryptMessage(
|
||||
envelope.ciphertext,
|
||||
envelope.nonce,
|
||||
otherPartyPublicKey
|
||||
);
|
||||
|
||||
decryptedCacheRef.current.set(cacheKey, plaintext);
|
||||
return { ...msg, decryptedContent: plaintext };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Chat UI] V2 decryption failed:', e);
|
||||
console.error('[Chat UI] Libsodium decryption failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: show encrypted indicator
|
||||
console.log('[Chat UI] Could not decrypt message, showing encrypted');
|
||||
return { ...msg, decryptedContent: '[Encrypted]' };
|
||||
// Fallback
|
||||
const fallback = '🔒 [Encrypted - refresh page]';
|
||||
decryptedCacheRef.current.set(cacheKey, fallback);
|
||||
return { ...msg, decryptedContent: fallback };
|
||||
} catch (err) {
|
||||
console.error('[Chat UI] Message processing error:', err);
|
||||
return { ...msg, decryptedContent: '[Error]' };
|
||||
@@ -290,8 +298,8 @@ export default function ChatPage() {
|
||||
}));
|
||||
|
||||
setMessages(decrypted);
|
||||
} catch (err) {
|
||||
console.error('[Chat UI] Load messages error:', err);
|
||||
} catch (err) {
|
||||
console.error('[Chat UI] Load messages error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -311,36 +319,42 @@ export default function ChatPage() {
|
||||
if (!newMessage.trim() || !selectedConversation) return;
|
||||
setSending(true);
|
||||
try {
|
||||
// Need Recipient DID.
|
||||
// conversation.participant2 might have valid handle.
|
||||
// We resolve DID first.
|
||||
// Get recipient DID
|
||||
let did = selectedConversation.participant2.did;
|
||||
// We need to support nodeDomain for existing chats too.
|
||||
// But Conversation interface might lack it.
|
||||
// We can try to resolve it from handle if needed, or if we stored it?
|
||||
// "participant2" comes from API.
|
||||
// Let's assume we re-fetch to be safe if it's remote?
|
||||
// Or just check handle structure?
|
||||
let nodeDomain = undefined;
|
||||
if (selectedConversation.participant2.handle.includes('@')) {
|
||||
const parts = selectedConversation.participant2.handle.split('@');
|
||||
if (parts.length === 2) nodeDomain = parts[1];
|
||||
}
|
||||
|
||||
if (!did) {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(selectedConversation.participant2.handle)}`);
|
||||
const data = await res.json();
|
||||
did = data.user?.did;
|
||||
nodeDomain = data.user?.nodeDomain || nodeDomain; // API is authoritative
|
||||
if (!did) throw new Error('User not found');
|
||||
}
|
||||
|
||||
await sendMessage(did, newMessage, nodeDomain, selectedConversation.participant2.handle);
|
||||
// Send using Signal Protocol
|
||||
await sendMessage(did, newMessage, selectedConversation.participant2.handle);
|
||||
|
||||
// Legacy UI expects message reload.
|
||||
setNewMessage('');
|
||||
await loadMessages(selectedConversation.id);
|
||||
loadConversations(false);
|
||||
|
||||
// If this was a new conversation, we need to refresh the conversation list and select the real one
|
||||
if (selectedConversation.id === 'new') {
|
||||
// Refresh conversations to get the new ID
|
||||
const res = await fetch('/api/swarm/chat/conversations');
|
||||
const data = await res.json();
|
||||
const updatedConversations = data.conversations || [];
|
||||
setConversations(updatedConversations);
|
||||
|
||||
// Find the real conversation
|
||||
const realConv = updatedConversations.find((c: Conversation) =>
|
||||
c.participant2.handle === selectedConversation.participant2.handle
|
||||
);
|
||||
|
||||
if (realConv) {
|
||||
setSelectedConversation(realConv);
|
||||
loadMessages(realConv.id);
|
||||
}
|
||||
} else {
|
||||
await loadMessages(selectedConversation.id);
|
||||
loadConversations(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Send] Error:', err);
|
||||
alert(`Failed: ${err.message}`);
|
||||
@@ -354,36 +368,66 @@ export default function ChatPage() {
|
||||
if (!newChatHandle.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const cleanHandle = newChatHandle.replace(/^@/, '');
|
||||
let cleanHandle = newChatHandle.replace(/^@/, '');
|
||||
|
||||
// If the handle includes a domain, check if it's our local domain
|
||||
if (cleanHandle.includes('@')) {
|
||||
const [handle, domain] = cleanHandle.split('@');
|
||||
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || window.location.host;
|
||||
|
||||
// If it's our local domain, strip it for the API call
|
||||
if (domain === localDomain) {
|
||||
cleanHandle = handle;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.user?.did) {
|
||||
alert('User not found or V2 not enabled.');
|
||||
alert('User not found or Olm encryption not enabled.');
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Chat UI] Starting chat with:', data.user);
|
||||
// Previously we auto-sent "👋" here.
|
||||
// Now we just setup the draft conversation.
|
||||
|
||||
// Send "Hello" to init session
|
||||
await sendMessage(data.user.did, '👋', data.user.nodeDomain, data.user.handle);
|
||||
// Check if existing conversation
|
||||
const existing = conversations.find(c =>
|
||||
c.participant2.handle.toLowerCase() === data.user.handle.toLowerCase()
|
||||
);
|
||||
|
||||
console.log('[Chat UI] Message sent, reloading conversations');
|
||||
if (existing) {
|
||||
setSelectedConversation(existing);
|
||||
} else {
|
||||
// Setup draft
|
||||
const draftConv: Conversation = {
|
||||
id: 'new',
|
||||
participant2: {
|
||||
handle: data.user.handle,
|
||||
displayName: data.user.displayName || data.user.handle,
|
||||
avatarUrl: data.user.avatarUrl,
|
||||
did: data.user.did
|
||||
},
|
||||
lastMessageAt: new Date().toISOString(),
|
||||
lastMessagePreview: 'New Conversation',
|
||||
unreadCount: 0
|
||||
};
|
||||
setSelectedConversation(draftConv);
|
||||
}
|
||||
|
||||
setShowNewChat(false);
|
||||
setNewChatHandle('');
|
||||
await loadConversations(false);
|
||||
// Select the new conversation (we might need to find it)
|
||||
// For now just reload list.
|
||||
} catch (e: any) {
|
||||
console.error('[Chat UI] Start chat failed:', e);
|
||||
if (e.message.includes('Recipient keys not found')) {
|
||||
if (e.message.includes('Recipient keys not found') || e.message.includes('Failed to fetch recipient keys')) {
|
||||
alert('This user has not set up secure chat yet. They need to log in to enable end-to-end encryption.');
|
||||
} else {
|
||||
alert('Failed to start chat: ' + e.message);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -410,6 +454,7 @@ export default function ChatPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const filteredConversations = conversations.filter((conv) =>
|
||||
conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -417,16 +462,21 @@ export default function ChatPage() {
|
||||
|
||||
if (user === null) return null;
|
||||
|
||||
// Locked State
|
||||
if (isLocked) {
|
||||
// Identity Locked State
|
||||
if (!isIdentityUnlocked) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', padding: '16px', textAlign: 'center' }}>
|
||||
<Lock size={48} style={{ color: 'var(--accent)', marginBottom: '16px' }} />
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Chat Locked</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', marginBottom: '24px' }}>
|
||||
Your end-to-end encrypted identity is locked. Please unlock it to view your messages.
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px', padding: '24px' }}>
|
||||
<Lock size={48} style={{ color: 'rgb(251, 191, 36)' }} />
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Identity Locked</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '400px', textAlign: 'center' }}>
|
||||
End-to-end encrypted chat requires your identity to be unlocked. Your private keys are needed to encrypt and decrypt messages.
|
||||
</p>
|
||||
<button onClick={() => setShowUnlockPrompt(true)} className="btn btn-primary">
|
||||
<button
|
||||
onClick={() => setShowUnlockPrompt(true)}
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
<Shield size={16} />
|
||||
Unlock Identity
|
||||
</button>
|
||||
</div>
|
||||
@@ -440,24 +490,23 @@ export default function ChatPage() {
|
||||
<Shield size={48} style={{ color: 'var(--destructive)' }} />
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Connection Failed</h2>
|
||||
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '300px', textAlign: 'center' }}>
|
||||
Unable to initialize secure chat. This might be a network issue or missing keys.
|
||||
Unable to initialize secure chat. Please refresh the page to try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => ensureReady('RETRY', user?.id || 'retry')}
|
||||
onClick={() => window.location.reload()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Retry Connection
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading State
|
||||
if ((!isReady && status !== 'error') || status === 'initializing' || status === 'generating_keys') {
|
||||
if (!isReady || status === 'initializing') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Loader2 className="animate-spin" size={32} />
|
||||
<p style={{ marginTop: 16 }}>Initializing Secure Encrypted Chat...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -673,7 +722,10 @@ export default function ChatPage() {
|
||||
<div
|
||||
key={conv.id}
|
||||
className="post"
|
||||
onClick={() => setSelectedConversation(conv)}
|
||||
onClick={() => {
|
||||
setMessages([]);
|
||||
setSelectedConversation(conv);
|
||||
}}
|
||||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }}
|
||||
>
|
||||
<div className="avatar">
|
||||
|
||||
Reference in New Issue
Block a user