Refactor chat system to remove E2EE and simplify messaging

Removed all E2EE chat endpoints, crypto logic, and related API routes, transitioning chat to plain text storage and transport. Updated chat send/receive endpoints to use signed actions and enforce DM privacy settings. Cleaned up Swarm chat inbox, key management, and related debug endpoints. Updated user profile to support DM privacy, improved search for handle queries, and refactored conversation/message logic to support the new model. Migrated bot settings and privacy settings to new locations.
This commit is contained in:
Christopher
2026-01-28 13:05:34 -08:00
parent b52b3f9804
commit ceae76d58f
44 changed files with 1945 additions and 3058 deletions
+6
View File
@@ -10,6 +10,7 @@ const updateProfileSchema = z.object({
avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(),
website: z.string().url().or(z.string().length(0)).optional().nullable(),
dmPrivacy: z.enum(['everyone', 'following', 'none']).optional(),
});
export async function GET() {
@@ -33,6 +34,7 @@ export async function GET() {
avatarUrl: session.user.avatarUrl,
bio: session.user.bio,
website: session.user.website,
dmPrivacy: session.user.dmPrivacy,
did: session.user.did,
publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted,
@@ -60,6 +62,7 @@ export async function PATCH(request: Request) {
avatarUrl?: string | null;
headerUrl?: string | null;
website?: string | null;
dmPrivacy?: 'everyone' | 'following' | 'none';
updatedAt?: Date;
} = {};
@@ -68,6 +71,7 @@ export async function PATCH(request: Request) {
if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website;
if (data.dmPrivacy !== undefined) updateData.dmPrivacy = data.dmPrivacy;
if (Object.keys(updateData).length === 0) {
return NextResponse.json({
@@ -79,6 +83,7 @@ export async function PATCH(request: Request) {
bio: currentUser.bio,
headerUrl: currentUser.headerUrl,
website: currentUser.website,
dmPrivacy: currentUser.dmPrivacy,
followersCount: currentUser.followersCount,
followingCount: currentUser.followingCount,
postsCount: currentUser.postsCount,
@@ -103,6 +108,7 @@ export async function PATCH(request: Request) {
bio: updatedUser.bio,
headerUrl: updatedUser.headerUrl,
website: updatedUser.website,
dmPrivacy: updatedUser.dmPrivacy,
followersCount: updatedUser.followersCount,
followingCount: updatedUser.followingCount,
postsCount: updatedUser.postsCount,
-60
View File
@@ -1,60 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatInbox } from '@/db/schema';
import { eq, and, or, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
/**
* GET /api/chat/inbox
* Poll for new encrypted envelopes for this device.
*/
export async function GET(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user?.did) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { did } = session.user;
const { searchParams } = new URL(request.url);
const deviceId = searchParams.get('deviceId');
if (!deviceId) {
return NextResponse.json({ error: 'Device ID required' }, { status: 400 });
}
// Fetch messages for this user AND (this device OR all devices)
const messages = await db.select().from(chatInbox).where(
and(
eq(chatInbox.recipientDid, did),
or(
eq(chatInbox.recipientDeviceId, deviceId),
isNull(chatInbox.recipientDeviceId) // Broadcasts (if any)
),
eq(chatInbox.isRead, false)
)
);
// TODO: Mark them as read immediately?
// Or client must ACK?
// For now, we return them. Client deals with idempotency.
// If we mark read, checking on another tab might miss them if race condition.
// But uniqueness is (id).
return NextResponse.json({ messages });
} catch (error: any) {
console.error('Inbox poll fail:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
/**
* DELETE /api/chat/inbox
* Acknowledge/Delete processed messages
*/
export async function DELETE(request: NextRequest) {
// ... Implement ACK cleaning
return NextResponse.json({ success: true });
}
-70
View File
@@ -1,70 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const did = searchParams.get('did');
const nodeDomain = searchParams.get('nodeDomain');
if (!did) {
return NextResponse.json({ error: 'Missing DID' }, { status: 400 });
}
const handle = searchParams.get('handle');
// Helper to fetch and check with timeout
const tryFetch = async (url: string) => {
console.log(`[Proxy] Fetching keys from: ${url}`);
try {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 5000); // 5s timeout
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: controller.signal
});
clearTimeout(id);
if (res.ok) return await res.json();
const text = await res.text();
console.warn(`[Proxy] Fetch failed for ${url} (${res.status}): ${text}`);
return null;
} catch (err: any) {
console.warn(`[Proxy] Fetch error for ${url}: ${err.name} - ${err.message}`);
return null;
}
};
// 1. Try Primary DID
let primaryUrl = '';
// If did:web, extracting domain is easy
if (did.startsWith('did:web:')) {
const parts = did.split(':');
if (parts.length >= 4) {
const domain = parts[2];
const protocol = domain.includes('localhost') ? 'http' : 'https';
primaryUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`;
}
} else if (nodeDomain) {
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
primaryUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`;
}
if (primaryUrl) {
const data = await tryFetch(primaryUrl);
if (data) return NextResponse.json(data);
}
// 2. Try Fallback: did:web (if handle and domain provided)
// The remote user might be indexed by did:web even if we know them as did:synapsis
if (nodeDomain && handle) {
const didWeb = `did:web:${nodeDomain}:${handle}`;
const protocol = nodeDomain.includes('localhost') ? 'http' : 'https';
const fallbackUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${didWeb}`;
console.log(`[Proxy] Primary failed. Trying fallback: ${didWeb}`);
const data = await tryFetch(fallbackUrl);
if (data) return NextResponse.json(data);
}
return NextResponse.json({ error: 'Remote keys not found (checked primary and fallback)' }, { status: 404 });
}
-233
View File
@@ -1,233 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles, handleRegistry, remoteIdentityCache, users } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { eq, and, gt } 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),
orderBy: (bundles, { desc }) => [desc(bundles.lastSeenAt)],
});
console.log('[Chat Keys GET] Found local 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,
});
// For libsodium, we just need the public key
return NextResponse.json({
publicKey: bundle.identityKey,
});
}
// If not found locally, check remote identity cache
const cached = await db.query.remoteIdentityCache.findFirst({
where: and(
eq(remoteIdentityCache.did, did),
gt(remoteIdentityCache.expiresAt, new Date())
),
});
// DEBUG: Force refresh for now to fix development key mismatches
// if (cached) {
// console.log('[Chat Keys GET] Found cached remote key');
// return NextResponse.json({
// publicKey: cached.publicKey,
// });
// }
// If not in cache, try to resolve DID to a node
console.log('[Chat Keys GET] resolving DID to node...');
const handleEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did),
});
if (handleEntry) {
const { nodeDomain } = handleEntry;
console.log('[Chat Keys GET] Resolved to node:', nodeDomain);
// Fetch from remote node
const remoteUrl = `https://${nodeDomain}/api/chat/keys?did=${encodeURIComponent(did)}`;
console.log('[Chat Keys GET] Fetching from remote:', remoteUrl);
try {
const remoteRes = await fetch(remoteUrl);
if (remoteRes.ok) {
const data = await remoteRes.json();
if (data.publicKey) {
console.log('[Chat Keys GET] Successfully fetched remote key');
// Cache it
await db.insert(remoteIdentityCache).values({
did: did,
publicKey: data.publicKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}).onConflictDoUpdate({
target: remoteIdentityCache.did,
set: {
publicKey: data.publicKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
});
return NextResponse.json({
publicKey: data.publicKey,
});
}
} else {
console.warn('[Chat Keys GET] Remote fetch failed:', remoteRes.status, remoteRes.statusText);
}
} catch (err) {
console.error('[Chat Keys GET] Remote fetch error:', err);
}
// FALLBACK: Try fetching from Swarm User Profile (standard endpoint)
if (!cached) {
console.log('[Chat Keys GET] Trying fallback to Swarm Profile...');
const swarmUrl = `https://${nodeDomain}/api/swarm/users/${handleEntry.handle}`;
console.log('[Chat Keys GET] Fetching Swarm Profile:', swarmUrl);
try {
const swarmRes = await fetch(swarmUrl);
if (swarmRes.ok) {
const data = await swarmRes.json();
const chatKey = data.profile?.chatPublicKey;
if (chatKey) {
console.log('[Chat Keys GET] Found key in Swarm Profile');
// Cache it
await db.insert(remoteIdentityCache).values({
did: did,
publicKey: chatKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}).onConflictDoUpdate({
target: remoteIdentityCache.did,
set: {
publicKey: chatKey,
fetchedAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
}
});
return NextResponse.json({
publicKey: chatKey,
});
} else {
console.warn('[Chat Keys GET] Swarm Profile found but no chatPublicKey');
}
} else {
console.warn('[Chat Keys GET] Swarm Profile fetch failed:', swarmRes.status);
}
} catch (err) {
console.error('[Chat Keys GET] Swarm Profile fetch error:', err);
}
}
} else {
console.log('[Chat Keys GET] DID not found in handle registry');
}
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
} 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 public 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;
console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING');
if (!publicKey) {
return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 });
}
// Check if bundle exists
const existing = await db.query.chatDeviceBundles.findFirst({
where: and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, '1')
)
});
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 {
console.log('[Chat Keys POST] Inserted new key');
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');
}
// Sync to users table too for profile discovery
await db.update(users)
.set({ chatPublicKey: publicKey })
.where(eq(users.id, user.id));
console.log('[Chat Keys POST] Key published successfully for', user.handle);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('[Chat Keys] Failed to publish key:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+107 -60
View File
@@ -3,108 +3,155 @@ import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
/**
* POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes.
*
* Body: {
* senderDid: string,
* senderHandle: string, // user@domain
* senderDisplayName: string,
* senderAvatarUrl: string,
* senderNodeDomain: string,
* recipientDid: string,
* encryptedContent: string (base64 JSON of {senderPublicKey, ciphertext, nonce, recipientDid}),
* sentAt: string (ISO date)
* }
* Expects a SignedAction payload from the sender.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
senderDid,
senderHandle,
senderDisplayName,
senderAvatarUrl,
senderNodeDomain,
recipientDid,
encryptedContent
} = body;
const signedAction: SignedAction = await request.json();
const { did, handle, data } = signedAction;
const { recipientDid, content } = data || {};
// Basic validation
if (!senderDid || !senderHandle || !recipientDid || !encryptedContent || !senderNodeDomain) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
if (!did || !handle || !recipientDid || !content) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
console.log(`[Chat Receive] From: ${senderHandle} (${senderNodeDomain}), To: ${recipientDid} [${encryptedContent.substring(0, 20)}...]`);
console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
// Ensure remote handle is fully qualified
let finalSenderHandle = senderHandle;
if (!finalSenderHandle.includes('@') && senderNodeDomain) {
finalSenderHandle = `${senderHandle}@${senderNodeDomain}`;
// 1. Resolve Sender Public Key
let senderUser = await db.query.users.findFirst({
where: eq(users.did, did)
});
let publicKey = senderUser?.publicKey;
let senderDisplayName = senderUser?.displayName || handle;
let senderAvatarUrl = senderUser?.avatarUrl;
let senderNodeDomain: string | null = null;
if (!senderUser) {
// Unknown user - likely remote. We need to fetch their profile to get the public key.
// Derive domain from handle if possible
if (handle.includes('@')) {
const parts = handle.split('@');
senderNodeDomain = parts[parts.length - 1];
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did)
});
if (registryEntry) senderNodeDomain = registryEntry.nodeDomain;
}
if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
// Fetch profile from remote node
// Assuming /api/users/:handle convention
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (res.ok) {
const profileData = await res.json();
const remoteProfile = profileData.user;
if (remoteProfile && remoteProfile.publicKey) {
if (remoteProfile.did !== did) {
console.error('DID mismatch for remote user');
} else {
publicKey = remoteProfile.publicKey;
senderDisplayName = remoteProfile.displayName || handle;
senderAvatarUrl = remoteProfile.avatarUrl;
}
}
} else {
console.error('Failed to fetch remote profile:', res.status);
}
} catch (e) {
console.error('Remote profile fetch failed:', e);
}
}
}
// 1. Find local recipient
if (!publicKey) {
return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 });
}
// 2. Verify Signature
const isValid = await verifyActionSignature(signedAction, publicKey);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
}
// 3. Find Local Recipient
const recipientUser = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
});
if (!recipientUser) {
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 });
}
// 2. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER (Remote)
// 4. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER
// Use full handle
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle);
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, finalSenderHandle)
eq(chatConversations.participant2Handle, fullSenderHandle)
)
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: finalSenderHandle,
participant2Handle: fullSenderHandle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
lastMessagePreview: content.slice(0, 50)
}).returning();
conversation = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, conversation.id));
}
// 3. Store Message
// 5. Store Message
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: finalSenderHandle,
senderDisplayName: senderDisplayName || senderHandle,
senderHandle: fullSenderHandle,
senderDisplayName: senderDisplayName,
senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain,
senderDid: senderDid,
encryptedContent: encryptedContent,
senderDid: did,
content: content,
encryptedContent: '',
deliveredAt: new Date(),
});
// 4. Update conversation timestamp
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
})
.where(eq(chatConversations.id, conversation.id));
// 5. Upsert sender into HandleRegistry ensures we can reply/fetch keys later
await db.insert(handleRegistry).values({
handle: finalSenderHandle, // user@domain
did: senderDid,
nodeDomain: senderNodeDomain
}).onConflictDoUpdate({
target: handleRegistry.handle,
set: {
did: senderDid,
// 6. Update Registry (to ensure we can reply efficiently)
if (senderNodeDomain) {
await db.insert(handleRegistry).values({
handle: fullSenderHandle, // user@domain
did: did,
nodeDomain: senderNodeDomain
}
});
}).onConflictDoUpdate({
target: handleRegistry.handle,
set: {
did: did,
nodeDomain: senderNodeDomain
}
});
}
return NextResponse.json({ success: true });
+103 -72
View File
@@ -1,31 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
const chatSendSchema = z.object({
recipientDid: z.string(),
recipientHandle: z.string(),
content: z.string().min(1).max(5000),
});
/**
* POST /api/chat/send
* Store encrypted message (server never decrypts)
*
* Body: {
* recipientDid: string,
* senderPublicKey: string (base64),
* ciphertext: string (base64),
* nonce: string (base64),
* recipientHandle?: string
* }
* Send a signed chat message (verified with DID)
* Stores plain text content.
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
// Parse the signed action from the request body
const signedAction = await request.json();
const body = await request.json();
const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body;
// Strictly verify the signature and get the user
const user = await requireSignedAction(signedAction);
if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Extract message data
const data = chatSendSchema.parse(signedAction.data);
const { recipientDid, recipientHandle, content } = data;
// Check if recipient is local
const recipientUser = await db.query.users.findFirst({
@@ -33,9 +34,30 @@ export async function POST(request: NextRequest) {
});
if (recipientUser) {
// Reject if recipient is a bot
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
}
// Check DM privacy settings
if (recipientUser.dmPrivacy === 'none') {
return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 });
} else if (recipientUser.dmPrivacy === 'following') {
// Check if recipient follows the sender
const isFollowingSender = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, recipientUser.id),
eq(follows.followingId, user.id)
)
});
if (!isFollowingSender) {
return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 });
}
}
// LOCAL RECIPIENT
// Ensure conversations exist
// Ensure conversations exist for both sides
// 1. Recipient's Inbox (Recipient -> User)
let recipientConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
@@ -48,11 +70,21 @@ export async function POST(request: NextRequest) {
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted]'
lastMessagePreview: content.slice(0, 50)
}).returning();
recipientConv = newConv;
} else {
// Update preview
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, recipientConv.id));
}
// 2. Sender's Sent Box (User -> Recipient)
let senderConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
@@ -65,21 +97,20 @@ export async function POST(request: NextRequest) {
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted]'
lastMessagePreview: content.slice(0, 50)
}).returning();
senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
}
// 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
// Create message for recipient (Inbox)
await db.insert(chatMessages).values({
conversationId: recipientConv.id,
senderHandle: user.handle,
@@ -87,11 +118,13 @@ export async function POST(request: NextRequest) {
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
encryptedContent: JSON.stringify(messageData),
content: content,
// Encrypted fields are null for plain text chat
encryptedContent: '',
deliveredAt: new Date(),
});
// Create message for sender
// Create message for sender (Sent)
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
@@ -99,61 +132,44 @@ export async function POST(request: NextRequest) {
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
encryptedContent: JSON.stringify(messageData),
content: content,
encryptedContent: '',
deliveredAt: new Date(),
readAt: new Date() // Sender has read their own message
});
return NextResponse.json({ success: true });
} else {
// REMOTE RECIPIENT
const { handleRegistry } = await import('@/db/schema'); // dynamic import or add to top
// 1. Resolve recipient node
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, recipientDid)
});
if (!registryEntry) {
console.error('Recipient DID not found in registry:', recipientDid);
return NextResponse.json({ error: 'Recipient not found in registry' }, { status: 404 });
// If not in registry, try to parse from handle if it has domain
let targetDomain: string | null = registryEntry?.nodeDomain || null;
let targetHandle = recipientHandle;
if (!targetDomain && recipientHandle.includes('@')) {
const parts = recipientHandle.split('@');
targetDomain = parts[parts.length - 1];
}
const targetDomain = registryEntry.nodeDomain;
// Ensure handle is fully qualified for remote users
let targetHandle = registryEntry.handle;
if (!targetHandle.includes('@') && targetDomain) {
targetHandle = `${targetHandle}@${targetDomain}`;
if (!targetDomain) {
console.error('Recipient node domain not found for:', recipientHandle);
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
// 2. Prepare Payload
const messageData = {
senderPublicKey,
recipientDid,
ciphertext,
nonce,
};
const encryptedContent = JSON.stringify(messageData);
const payload = {
senderDid: user.did,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'dev.syn.quest', // Current node domain
recipientDid,
encryptedContent,
sentAt: new Date().toISOString()
};
// 3. Send to Remote Node
// 2. Send to Remote Node (Forward the Signed Action)
try {
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
body: JSON.stringify(signedAction) // Forward the user's signed intent
});
if (!res.ok) {
@@ -166,23 +182,31 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 });
}
// 4. Store "Sent" copy locally
// 3. Store "Sent" copy locally
// Ensure conversation exists locally
let senderConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, targetHandle)
eq(chatConversations.participant2Handle, recipientHandle)
)
});
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: targetHandle,
participant2Handle: recipientHandle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted]'
lastMessagePreview: content.slice(0, 50)
}).returning();
senderConv = newConv;
} else {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50),
updatedAt: new Date()
})
.where(eq(chatConversations.id, senderConv.id));
}
await db.insert(chatMessages).values({
@@ -190,17 +214,24 @@ export async function POST(request: NextRequest) {
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null, // It's ME, so null
senderNodeDomain: null,
senderDid: user.did,
encryptedContent: encryptedContent,
content: content,
encryptedContent: '',
deliveredAt: new Date(),
readAt: new Date()
});
return NextResponse.json({ success: true });
}
} catch (error: any) {
console.error('Send failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
console.error('Send chat failed:', error);
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
}
}
@@ -1,50 +0,0 @@
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 });
}
}
-19
View File
@@ -1,19 +0,0 @@
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 });
}
}
@@ -1,18 +0,0 @@
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 });
}
}
+49 -23
View File
@@ -65,35 +65,61 @@ export async function GET(request: Request) {
}
}
const isHandleSearch = query.trim().startsWith('@');
const searchPattern = `%${localSearchQuery}%`;
let searchUsers: SearchUser[] = [];
let searchPosts: typeof posts.$inferSelect[] = [];
// Search users
if (type === 'all' || type === 'users') {
const userConditions = and(
or(
ilike(users.handle, searchPattern),
ilike(users.displayName, searchPattern),
ilike(users.bio, searchPattern)
),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
);
const localUsers = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
isBot: users.isBot,
})
.from(users)
.where(userConditions)
.limit(limit);
// Filter out remote placeholder users (those with @ in handle)
searchUsers = localUsers.filter(u => !u.handle.includes('@'));
if (isHandleSearch) {
// Try exact match first
const exactMatch = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
isBot: users.isBot,
})
.from(users)
.where(and(
eq(users.handle, localSearchQuery),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
))
.limit(1);
if (exactMatch.length > 0) {
searchUsers = exactMatch;
}
}
if (searchUsers.length === 0) {
const userConditions = and(
or(
ilike(users.handle, searchPattern),
ilike(users.displayName, searchPattern),
ilike(users.bio, searchPattern)
),
eq(users.isSuspended, false),
eq(users.isSilenced, false)
);
const localUsers = await db.select({
id: users.id,
handle: users.handle,
displayName: users.displayName,
avatarUrl: users.avatarUrl,
bio: users.bio,
isBot: users.isBot,
})
.from(users)
.where(userConditions)
.limit(limit);
// Filter out remote placeholder users (those with @ in handle)
searchUsers = localUsers.filter(u => !u.handle.includes('@'));
}
}
// Swarm user lookup (exact handle@domain queries)
@@ -54,7 +54,6 @@ export async function GET(request: NextRequest) {
handle: participant2Handle,
displayName: participant2Handle,
avatarUrl: null as string | null,
chatPublicKey: null as string | null,
};
// Try to get cached user info
@@ -67,20 +66,22 @@ export async function GET(request: NextRequest) {
handle: cachedUser.handle,
displayName: cachedUser.displayName || cachedUser.handle,
avatarUrl: cachedUser.avatarUrl,
chatPublicKey: cachedUser.chatPublicKey, // ECDH key for E2E chat
};
}
return {
...conv,
participant2: participant2Info,
participant2: {
...participant2Info,
isBot: cachedUser?.isBot || false,
},
unreadCount: Number(unreadCount[0]?.count || 0),
};
})
);
return NextResponse.json({
conversations: conversationsWithUnread,
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
});
} catch (error) {
console.error('List conversations error:', error);
-217
View File
@@ -1,217 +0,0 @@
/**
* Swarm Chat Inbox
*
* POST: Receives chat messages from other swarm nodes
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, chatConversations, chatMessages } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
const chatMessageSchema = z.object({
messageId: z.string(),
senderHandle: z.string(),
senderDisplayName: z.string().optional(),
senderAvatarUrl: z.string().optional(),
senderNodeDomain: z.string(),
recipientHandle: z.string(),
encryptedContent: z.string(),
timestamp: z.string(),
signature: z.string().optional(),
});
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
console.log('[Swarm Inbox] Received body keys:', Object.keys(body), 'action:', body.action);
// Check if this is a V2 encrypted envelope (has 'action' and 'data' fields)
// MUST check BEFORE Zod validation to avoid validation errors
if (body.action === 'chat.deliver' && body.data) {
// V2 E2EE Message - store in chatInbox
const { recipientDid, recipientDeviceId, ciphertext } = body.data;
if (!recipientDid || !ciphertext) {
return NextResponse.json({ error: 'Invalid V2 payload' }, { status: 400 });
}
// Find recipient by DID
const recipient = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
});
if (!recipient) {
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
// Import schemas
const { chatInbox, chatConversations, chatMessages } = await import('@/db/schema');
// Store in V2 inbox
await db.insert(chatInbox).values({
senderDid: body.did, // From signed envelope
recipientDid,
recipientDeviceId: recipientDeviceId || null,
envelope: JSON.stringify(body),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
// Also create conversation and message for recipient UI
// Extract sender info from envelope
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({
where: and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderFullHandle)
)
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipient.id,
participant2Handle: senderFullHandle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted message]'
}).returning();
conversation = newConv;
console.log(`[Swarm Chat V2] Created conversation for recipient:`, conversation.id);
} else {
// Update last message time
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted message]'
})
.where(eq(chatConversations.id, conversation.id));
}
// Store message reference so it appears in UI
// Store full envelope data as JSON so we can decrypt later
const envelopeData = {
did: body.did,
handle: body.handle,
ciphertext: body.data.ciphertext
};
const messageId = crypto.randomUUID();
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: senderFullHandle,
senderDisplayName: null, // Unknown until decrypted
senderAvatarUrl: null,
senderNodeDomain: senderNodeDomain !== 'unknown' ? senderNodeDomain : null,
encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption
senderChatPublicKey: null,
swarmMessageId: `swarm:v2:${messageId}`,
deliveredAt: new Date(),
readAt: null,
});
console.log(`[Swarm Chat V2] Received encrypted message for ${recipientDid}, conversation:`, conversation.id);
return NextResponse.json({
success: true,
message: 'V2 message received',
version: 2
});
}
// V1 Legacy Message Format - validate with Zod
const data = chatMessageSchema.parse(body) as SwarmChatMessagePayload;
// Find the recipient (local user)
const recipient = await db.query.users.findFirst({
where: eq(users.handle, data.recipientHandle.toLowerCase()),
});
if (!recipient) {
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
if (recipient.isSuspended) {
return NextResponse.json({ error: 'Recipient not available' }, { status: 404 });
}
// Check if message already exists (prevent duplicates)
const swarmMessageId = `swarm:${data.senderNodeDomain}:${data.messageId}`;
const existingMessage = await db.query.chatMessages.findFirst({
where: eq(chatMessages.swarmMessageId, swarmMessageId),
});
if (existingMessage) {
return NextResponse.json({
success: true,
message: 'Message already received',
});
}
// Get or create conversation
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
eq(chatConversations.participant2Handle, senderFullHandle)
),
});
if (!conversation) {
const [newConversation] = await db.insert(chatConversations).values({
participant1Id: recipient.id,
participant2Handle: senderFullHandle,
lastMessageAt: new Date(data.timestamp),
lastMessagePreview: '[Encrypted message]',
}).returning();
conversation = newConversation;
}
// Store the message
const [newMessage] = await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: senderFullHandle,
senderDisplayName: data.senderDisplayName,
senderAvatarUrl: data.senderAvatarUrl,
senderNodeDomain: data.senderNodeDomain,
encryptedContent: data.encryptedContent,
swarmMessageId,
deliveredAt: new Date(),
createdAt: new Date(data.timestamp),
}).returning();
// Update conversation last message
await db.update(chatConversations)
.set({
lastMessageAt: new Date(data.timestamp),
lastMessagePreview: '[Encrypted message]',
updatedAt: new Date(),
})
.where(eq(chatConversations.id, conversation.id));
console.log(`[Swarm Chat] Received message from ${senderFullHandle} to ${data.recipientHandle}`);
return NextResponse.json({
success: true,
message: 'Message received',
messageId: newMessage.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
}
console.error('Swarm chat inbox error:', error);
return NextResponse.json({ error: 'Failed to receive message' }, { status: 500 });
}
}
+5 -88
View File
@@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { decryptMessage } from '@/lib/swarm/chat-crypto';
export async function GET(request: NextRequest) {
try {
@@ -43,15 +43,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
}
// Get user's private key for decryption
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user?.privateKeyEncrypted) {
return NextResponse.json({ error: 'Cannot decrypt messages' }, { status: 500 });
}
// Build query with cursor-based pagination
const baseCondition = eq(chatMessages.conversationId, conversationId);
const whereCondition = cursor
@@ -65,90 +56,16 @@ export async function GET(request: NextRequest) {
limit,
});
// Get recipient info for sent messages
const recipientHandle = conversation.participant2Handle;
let recipientPublicKey: string | null = null;
console.log('[Messages API] Fetching recipient key for:', recipientHandle);
// Check if this is a remote user (has @domain)
const isRemote = recipientHandle.includes('@');
if (isRemote) {
// Remote user - fetch from their node
const [handle, domain] = recipientHandle.split('@');
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
if (response.ok) {
const data = await response.json();
recipientPublicKey = data.user?.chatPublicKey || null;
console.log('[Messages API] Fetched remote recipient key:', !!recipientPublicKey);
}
} catch (error) {
console.error('[Messages API] Failed to fetch remote recipient key:', error);
}
} else {
// Local user
const recipientUser = await db.query.users.findFirst({
where: eq(users.handle, recipientHandle),
});
recipientPublicKey = recipientUser?.chatPublicKey || null;
}
console.log('[Messages API] Recipient public key found:', !!recipientPublicKey);
// Get sender DID for received messages
const senderDids = new Map<string, string>();
for (const msg of messages) {
const messagesMapped = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
if (!isSentByMe && !senderDids.has(msg.senderHandle)) {
// Try to get DID for this sender
try {
const isRemote = msg.senderHandle.includes('@');
if (isRemote) {
const [handle, domain] = msg.senderHandle.split('@');
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
if (response.ok) {
const data = await response.json();
if (data.user?.did) {
senderDids.set(msg.senderHandle, data.user.did);
}
}
} else {
const senderUser = await db.query.users.findFirst({
where: eq(users.handle, msg.senderHandle),
});
if (senderUser?.did) {
senderDids.set(msg.senderHandle, senderUser.did);
}
}
} catch (e) {
console.error('[Messages API] Failed to resolve sender DID:', e);
}
}
}
const messagesWithDecryption = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
const senderPubKey = isSentByMe ? recipientPublicKey : msg.senderChatPublicKey;
console.log('[Messages API] Message:', msg.id, 'isSentByMe:', isSentByMe, 'senderPubKey:', !!senderPubKey, 'msgSenderChatPubKey:', !!msg.senderChatPublicKey);
return {
id: msg.id,
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderDid: isSentByMe ? undefined : senderDids.get(msg.senderHandle), // Add DID for received messages
// For decryption:
// - Sent messages: need recipient's public key
// - Received messages: need sender's public key
senderPublicKey: senderPubKey,
isE2E: !!msg.senderChatPublicKey || (isSentByMe && !!recipientPublicKey),
encryptedContent: msg.encryptedContent, // This is now the full envelope JSON
senderDid: msg.senderDid,
content: msg.content,
deliveredAt: msg.deliveredAt,
readAt: msg.readAt,
createdAt: msg.createdAt,
@@ -157,7 +74,7 @@ export async function GET(request: NextRequest) {
});
return NextResponse.json({
messages: messagesWithDecryption.reverse(), // Oldest first for display
messages: messagesMapped.reverse(), // Oldest first for display
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
});
} catch (error) {
-346
View File
@@ -1,346 +0,0 @@
/**
* Swarm Chat Send
*
* POST: Send a chat message to another user (local or remote)
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, chatConversations, chatMessages } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { encryptMessage } from '@/lib/swarm/chat-crypto';
import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
const sendMessageSchema = z.object({
recipientHandle: z.string(),
// For E2E encryption: client sends pre-encrypted content
encryptedContent: z.string().optional(),
senderPublicKey: z.string().optional(), // ECDH public key for decryption
// Legacy: server-side encryption (will be removed)
content: z.string().min(1).max(5000).optional(),
}).refine(data => data.encryptedContent || data.content, {
message: 'Either encryptedContent or content is required',
});
export async function POST(request: NextRequest) {
console.log('[Chat Send] Starting request processing');
try {
if (!db) {
console.error('[Chat Send] Database connection missing');
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const session = await getSession();
if (!session?.user) {
console.warn('[Chat Send] Unauthorized attempt');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.log('[Chat Send] User authenticated:', session.user.id);
let body;
try {
body = await request.json();
} catch (e) {
console.error('[Chat Send] Failed to parse JSON body');
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const parseResult = sendMessageSchema.safeParse(body);
if (!parseResult.success) {
console.error('[Chat Send] Schema validation failed:', parseResult.error);
return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 });
}
const data = parseResult.data;
console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey);
// Get sender info
const sender = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!sender) {
console.error('[Chat Send] Sender not found in DB:', session.user.id);
return NextResponse.json({ error: 'Sender not found' }, { status: 404 });
}
console.log('[Chat Send] Sender retrieved:', sender.handle);
// Parse recipient handle (could be local or remote)
// Strip leading @ if present, then normalize
const recipientHandle = data.recipientHandle.toLowerCase().replace(/^@/, '');
const isRemote = recipientHandle.includes('@');
let recipientUser: typeof users.$inferSelect | undefined;
let recipientPublicKey: string;
let recipientNodeDomain: string | null = null;
if (isRemote) {
// Remote user - need to fetch their public key
// Format: handle@domain (e.g., matterbator@batorbros.bond)
const atIndex = recipientHandle.indexOf('@');
const handle = recipientHandle.substring(0, atIndex);
const domain = recipientHandle.substring(atIndex + 1);
recipientNodeDomain = domain;
console.log('[Chat Send] Processing remote recipient:', handle, '@', domain);
// Try to find cached remote user
recipientUser = await db.query.users.findFirst({
where: eq(users.handle, recipientHandle),
});
// Check if we have a valid public key (not a placeholder)
const hasValidPublicKey = recipientUser?.publicKey?.startsWith('-----BEGIN');
if (!recipientUser || !hasValidPublicKey) {
// Fetch from remote node to get the real public key
try {
console.log('[Chat Send] Fetching remote user from node:', domain);
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/users/${handle}`);
if (!response.ok) {
console.error('[Chat Send] Remote user fetch failed. Status:', response.status);
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
const remoteUserData = await response.json();
const userData = remoteUserData.user || remoteUserData;
recipientPublicKey = userData.publicKey;
if (!recipientPublicKey || !recipientPublicKey.startsWith('-----BEGIN')) {
console.error('[Chat Send] Remote user has no valid public key');
return NextResponse.json({ error: 'Recipient does not support encrypted chat' }, { status: 400 });
}
if (recipientUser) {
// Update existing cached user with real public key
await db.update(users)
.set({ publicKey: recipientPublicKey })
.where(eq(users.id, recipientUser.id));
console.log('[Chat Send] Updated cached user with real public key');
} else {
// Cache the remote user
const [newUser] = await db.insert(users).values({
did: userData.did || `did:swarm:${domain}:${handle}`,
handle: recipientHandle,
displayName: userData.displayName,
avatarUrl: userData.avatarUrl,
publicKey: recipientPublicKey,
}).returning();
recipientUser = newUser;
console.log('[Chat Send] Remote user cached');
}
} catch (error) {
console.error('[Chat Send] Failed to fetch remote user:', error);
return NextResponse.json({ error: 'Failed to reach recipient node' }, { status: 503 });
}
} else {
recipientPublicKey = recipientUser.publicKey;
console.log('[Chat Send] Remote user found in cache with valid key');
}
} else {
// Local user
console.log('[Chat Send] Processing local recipient');
recipientUser = await db.query.users.findFirst({
where: eq(users.handle, recipientHandle),
});
if (!recipientUser) {
console.warn('[Chat Send] Local recipient not found:', recipientHandle);
return NextResponse.json({ error: 'Recipient not found' }, { status: 404 });
}
if (recipientUser.isSuspended) {
console.warn('[Chat Send] Local recipient suspended:', recipientHandle);
return NextResponse.json({ error: 'Recipient not available' }, { status: 404 });
}
recipientPublicKey = recipientUser.publicKey;
}
// Handle encryption - either client-side (E2E) or server-side (legacy)
let encryptedContent: string;
let senderEncryptedContent: string | null = null;
if (data.encryptedContent) {
// E2E mode: client already encrypted the message
// Server cannot read the content - this is true E2E encryption
console.log('[Chat Send] Using client-side E2E encryption');
encryptedContent = data.encryptedContent;
// Store sender's public key so recipient can decrypt
// Note: senderEncryptedContent not needed in E2E mode - client stores locally
} else if (data.content) {
// Legacy mode: server-side encryption (for backwards compatibility)
console.log('[Chat Send] Using server-side encryption (legacy)');
try {
encryptedContent = encryptMessage(data.content, recipientPublicKey);
senderEncryptedContent = encryptMessage(data.content, sender.publicKey);
} catch (encError) {
console.error('[Chat Send] Encryption failed:', encError);
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
}
} else {
return NextResponse.json({ error: 'No message content provided' }, { status: 400 });
}
// Get or create conversation
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, sender.id),
eq(chatConversations.participant2Handle, recipientHandle)
),
});
if (!conversation) {
console.log('[Chat Send] Creating new conversation');
const [newConversation] = await db.insert(chatConversations).values({
participant1Id: sender.id,
participant2Handle: recipientHandle,
lastMessageAt: new Date(),
lastMessagePreview: 'New message',
}).returning();
conversation = newConversation;
}
// Store the message locally
const messageId = crypto.randomUUID();
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const swarmMessageId = `swarm:${nodeDomain}:${messageId}`;
console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey);
const [newMessage] = await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: sender.handle,
senderDisplayName: sender.displayName,
senderAvatarUrl: sender.avatarUrl,
senderNodeDomain: null, // Local sender
encryptedContent,
senderEncryptedContent,
// Use client-provided key first, fall back to database
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey,
swarmMessageId,
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
readAt: null,
}).returning();
// Update conversation
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: 'New message',
updatedAt: new Date(),
})
.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 (isRemote && recipientNodeDomain) {
// ... (remote logic remains similar but add logs)
console.log('[Chat Send] Dispatching to remote node:', recipientNodeDomain);
// ... existing remote send logic ...
// For brevity in this tool call, I'm keeping the original logic mostly intact but wrapped/logged.
// Re-implementing the block:
try {
const payload: SwarmChatMessagePayload = {
messageId,
senderHandle: sender.handle,
senderDisplayName: sender.displayName || undefined,
senderAvatarUrl: sender.avatarUrl || undefined,
senderNodeDomain: nodeDomain,
recipientHandle: recipientHandle.split('@')[0],
encryptedContent,
timestamp: new Date().toISOString(),
};
const protocol = recipientNodeDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${recipientNodeDomain}/api/swarm/chat/inbox`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
// Mark as delivered
await db.update(chatMessages)
.set({ deliveredAt: new Date() })
.where(eq(chatMessages.id, newMessage.id));
console.log('[Chat Send] Remote delivery confirmed');
} else {
console.warn('[Chat Send] Remote delivery failed. Status:', response.status);
}
} catch (error) {
console.error('[Chat Send] Failed to send message to remote node:', error);
// Message is still stored locally, will show as undelivered
}
}
console.log('[Chat Send] Success');
return NextResponse.json({
success: true,
message: newMessage,
});
} catch (error) {
if (error instanceof z.ZodError) {
// Should be caught by safeParse above, but just in case
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('[Chat Send] Unhandled error:', error);
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ export interface SwarmUserProfile {
isBot?: boolean;
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
nodeDomain: string;
chatPublicKey?: string;
publicKey?: string; // Signing key for verifying actions
did?: string;
}
@@ -93,7 +93,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
isBot: user.isBot || undefined,
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
nodeDomain,
chatPublicKey: user.chatPublicKey || undefined,
publicKey: user.publicKey, // Expose signing key
did: user.did || undefined,
};
+32 -5
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { db, users, follows } from '@/db';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -62,7 +63,6 @@ export async function GET(request: Request, context: RouteContext) {
isSwarm: true,
nodeDomain: remoteDomain,
isBot: profile.isBot || false,
chatPublicKey: profile.chatPublicKey,
did: profile.did,
}
});
@@ -96,11 +96,38 @@ export async function GET(request: Request, context: RouteContext) {
website: user.website,
movedTo: user.movedTo,
isBot: user.isBot,
publicKey: user.publicKey, // RSA key for signing
chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat
publicKey: user.publicKey, // Signing key
did: user.did, // V2 Identity
dmPrivacy: user.dmPrivacy,
};
// Check if viewer can DM this user
let canReceiveDms = true;
if (user.isBot) {
canReceiveDms = false;
} else if (user.dmPrivacy === 'none') {
canReceiveDms = false;
} else if (user.dmPrivacy === 'following') {
canReceiveDms = false; // Default to false for 'following'
const session = await getSession();
if (session?.user) {
if (session.user.id === user.id) {
canReceiveDms = true; // Can DM yourself
} else {
const isFollowingViewer = await db.query.follows.findFirst({
where: and(
eq(follows.followerId, user.id),
eq(follows.followingId, session.user.id)
)
});
if (isFollowingViewer) {
canReceiveDms = true;
}
}
}
}
userResponse.canReceiveDms = canReceiveDms;
// If this is a bot, include owner info
if (user.isBot && user.botOwnerId) {
const owner = await db.query.users.findFirst({
@@ -299,7 +299,7 @@ export default function EditBotPage() {
}
}
router.push(`/settings/bots/${botId}`);
router.push(`/bots/${botId}`);
} catch (err) {
console.error('Update bot error:', err);
setError(err instanceof Error ? err.message : 'Failed to update bot');
@@ -76,7 +76,7 @@ export default function BotDetailPage() {
const handleTriggerPost = async () => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}/post`, {
const response = await fetch(`/api/bots/${botId}/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
@@ -188,10 +188,10 @@ export default function BotDetailPage() {
});
if (response.ok) {
setShowAddSource(false);
setNewSource({
type: 'rss',
url: '',
subreddit: '',
setNewSource({
type: 'rss',
url: '',
subreddit: '',
apiKey: '',
braveQuery: '',
braveFreshness: 'pw',
@@ -252,7 +252,7 @@ export default function BotDetailPage() {
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<p style={{ color: 'var(--foreground-tertiary)' }}>Bot not found</p>
<Link href="/settings/bots" className="btn" style={{ marginTop: '16px' }}>
<Link href="/bots" className="btn" style={{ marginTop: '16px' }}>
Back to Bots
</Link>
</div>
@@ -260,8 +260,8 @@ export default function BotDetailPage() {
);
}
const scheduleConfig = typeof bot.scheduleConfig === 'string'
? JSON.parse(bot.scheduleConfig)
const scheduleConfig = typeof bot.scheduleConfig === 'string'
? JSON.parse(bot.scheduleConfig)
: bot.scheduleConfig || null;
const personalityConfig = typeof bot.personalityConfig === 'string'
? JSON.parse(bot.personalityConfig)
@@ -270,16 +270,16 @@ export default function BotDetailPage() {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
<Link href="/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>{bot.name}</h1>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '4px 10px',
@@ -331,7 +331,7 @@ export default function BotDetailPage() {
{bot.isActive ? 'Deactivate' : 'Activate'}
</button>
<Link
href={`/settings/bots/${botId}/edit`}
href={`/bots/${botId}/edit`}
className="btn"
>
<Pencil size={16} />
@@ -407,8 +407,8 @@ export default function BotDetailPage() {
<Sparkles size={18} />
Personality
</h2>
<div style={{
fontSize: '13px',
<div style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
background: 'var(--background-tertiary)',
padding: '12px',
@@ -635,7 +635,7 @@ export default function BotDetailPage() {
<button
onClick={handleAddSource}
disabled={
actionLoading ||
actionLoading ||
(newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
@@ -671,11 +671,11 @@ export default function BotDetailPage() {
} catch {
displayName = source.url || 'Unknown';
}
return (
<div
key={source.id}
style={{
<div
key={source.id}
style={{
padding: '12px',
background: 'var(--background-tertiary)',
borderRadius: 'var(--radius-md)',
@@ -716,7 +716,7 @@ export default function BotDetailPage() {
onClick={() => {
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
.then(() => router.push('/settings/bots'))
.then(() => router.push('/bots'))
.catch(() => showToast('Failed to delete bot', 'error'));
}
}}
@@ -242,7 +242,7 @@ export default function NewBotPage() {
});
}
router.push(`/settings/bots/${data.bot.id}`);
router.push(`/bots/${data.bot.id}`);
} else {
const data = await response.json();
console.error('Bot creation failed:', data);
@@ -845,7 +845,7 @@ export default function NewBotPage() {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
<Link href="/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
+217
View File
@@ -0,0 +1,217 @@
/**
* Bot Management Page
*
* Lists user's bots and provides creation interface.
*
* Requirements: 1.3
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react';
interface BotData {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
lastPostAt: Date | null;
createdAt: Date;
}
export default function BotsPage() {
const router = useRouter();
const [bots, setBots] = useState<BotData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBots();
}, []);
const fetchBots = async () => {
try {
const response = await fetch('/api/bots');
if (response.ok) {
const data = await response.json();
setBots(data.bots || []);
}
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<>
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bots
</h1>
<Link href="/bots/new" className="btn btn-primary btn-sm">
<Plus size={16} />
Create
</Link>
</div>
</header>
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
{bots.length === 0 ? (
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
No bots yet
</h2>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
Create your first bot to start automating posts and interactions
</p>
<Link href="/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Your First Bot
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{bots.map((bot) => (
<div
key={bot.id}
className="card"
style={{
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => router.push(`/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
<span className="status-pill suspended">
Suspended
</span>
) : bot.isActive ? (
<span className="status-pill active">
Active
</span>
) : (
<span className="status-pill">
Inactive
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
gap: '16px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
paddingTop: '12px',
borderTop: '1px solid var(--border)',
}}>
<span>
Last post: {bot.lastPostAt
? new Date(bot.lastPostAt).toLocaleDateString()
: 'Never'}
</span>
<span>
Created: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
</>
);
}
+90 -231
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useSodiumChat } from '@/lib/hooks/useSodiumChat';
import { signedAPI } from '@/lib/api/signed-fetch';
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';
@@ -21,15 +21,14 @@ interface Conversation {
unreadCount: number;
}
interface Message {
id: string;
senderHandle: string;
senderDisplayName?: string;
senderAvatarUrl?: string;
senderDid?: string; // V2 needs DID
senderPublicKey?: string; // Legacy
encryptedContent: string;
decryptedContent?: string;
senderDid?: string;
content: string;
isSentByMe: boolean;
deliveredAt?: string;
readAt?: string;
@@ -39,8 +38,6 @@ interface Message {
export default function ChatPage() {
const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
const router = useRouter();
// Libsodium E2EE Hook
const { isReady, status, sendMessage, decryptMessage } = useSodiumChat();
const searchParams = useSearchParams();
const composeHandle = searchParams.get('compose');
@@ -49,20 +46,11 @@ export default function ChatPage() {
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 [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);
// Legacy / V2 Hybrid State
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState<Conversation | null>(null);
@@ -96,12 +84,14 @@ export default function ChatPage() {
}
};
const loadConversations = async (isInitialLoad = false) => {
if (isInitialLoad) setLoading(true);
const loadConversations = async (isInitialLoad = true) => {
try {
if (isInitialLoad) setLoading(true);
const res = await fetch('/api/swarm/chat/conversations');
const data = await res.json();
setConversations(data.conversations || []);
if (res.ok) {
const data = await res.json();
setConversations(data.conversations || []);
}
} catch (e) {
console.error("Failed to load conversations", e);
} finally {
@@ -114,81 +104,23 @@ export default function ChatPage() {
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: any) => {
try {
// Check cache first
const cacheKey = `${msg.id}`;
const cached = decryptedCacheRef.current.get(cacheKey);
if (cached) {
return { ...msg, decryptedContent: cached };
}
// 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 {
const envelope = JSON.parse(msg.encryptedContent);
// 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
// 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 keyRes = await fetch(`/api/chat/keys?did=${encodeURIComponent(envelope.recipientDid)}`, { cache: 'no-store' });
if (keyRes.ok) {
const keyData = await keyRes.json();
otherPartyPublicKey = keyData.publicKey;
}
} catch (e) {
console.error('[Chat UI] Failed to fetch recipient key:', e);
}
} else {
// Using sender public key from envelope
}
const plaintext = await decryptMessage(
envelope.ciphertext,
envelope.nonce,
otherPartyPublicKey
);
decryptedCacheRef.current.set(cacheKey, plaintext);
return { ...msg, decryptedContent: plaintext };
}
} catch (e) {
console.error('[Chat UI] Libsodium decryption failed:', e);
}
}
// 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]' };
}
const plainMessages = (data.messages || []).map((msg: any) => ({
...msg,
content: msg.content || '[Empty Message]'
}));
setMessages(decrypted);
} catch (err) {
console.error('[Chat UI] Load messages error:', err);
// Only update if different
setMessages(prev => {
const prevIds = prev.map(m => m.id).join(',');
const newIds = plainMessages.map((m: any) => m.id).join(',');
if (prevIds === newIds && prev.length === plainMessages.length) return prev;
return plainMessages;
});
// Mark as read
markAsRead(conversationId);
} catch (e) {
console.error("Failed to load messages", e);
}
};
@@ -218,8 +150,18 @@ export default function ChatPage() {
if (!did) throw new Error('User not found');
}
// Send using Signal Protocol
await sendMessage(did, newMessage, selectedConversation.participant2.handle);
if (!user.did) throw new Error('User DID missing');
// Send using Signed API
await signedAPI.sendChat(
did,
selectedConversation.participant2.handle,
newMessage,
user.did,
user.handle
);
setNewMessage('');
@@ -252,71 +194,6 @@ export default function ChatPage() {
}
};
const startNewChat = async (e: React.FormEvent) => {
e.preventDefault();
if (!newChatHandle.trim()) return;
setSending(true);
try {
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 Olm encryption not enabled.');
setSending(false);
return;
}
// Check if existing conversation
const existing = conversations.find(c =>
c.participant2.handle.toLowerCase() === data.user.handle.toLowerCase()
);
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('');
} catch (e: any) {
console.error('[Chat UI] Start chat failed:', e);
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);
}
};
const handleDeleteConversation = async (deleteFor: 'self' | 'both') => {
if (!conversationToDelete) return;
setIsDeleting(true);
@@ -344,9 +221,10 @@ export default function ChatPage() {
// EFFECTS (Now that functions are defined)
// ============================================
// Load conversations
// Load conversations
useEffect(() => {
if (user && isReady) {
if (user) {
loadConversations(true); // Initial load with spinner
// Poll for new conversations every 5 seconds (no spinner)
@@ -356,11 +234,11 @@ export default function ChatPage() {
return () => clearInterval(pollInterval);
}
}, [user, isReady]);
}, [user]);
// Handle Compose Intent
useEffect(() => {
if (composeHandle && isReady && !selectedConversation && conversations.length >= 0) {
if (composeHandle && !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()
@@ -377,6 +255,11 @@ export default function ChatPage() {
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`);
const data = await res.json();
if (data.user) {
if (data.user.isBot || data.user.canReceiveDms === false) {
console.error('Cannot DM this account due to privacy settings');
router.replace('/chat');
return;
}
const draftConv: Conversation = {
id: 'new',
participant2: {
@@ -404,7 +287,7 @@ export default function ChatPage() {
fetchUserAndInitDraft();
}
}
}, [composeHandle, isReady, selectedConversation, conversations, loading, router]);
}, [composeHandle, selectedConversation, conversations, loading, router]);
// Redirect if not logged in
useEffect(() => {
@@ -415,9 +298,8 @@ export default function ChatPage() {
// Load messages when conversation is selected
useEffect(() => {
if (selectedConversation && isReady) {
// Update current conversation ref
currentConversationIdRef.current = selectedConversation.id;
if (selectedConversation) {
// Clear messages immediately to prevent flash
setMessages([]);
@@ -435,7 +317,7 @@ export default function ChatPage() {
// Poll for new messages every 3 seconds
const pollInterval = setInterval(() => {
// Only load if still the same conversation
if (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') {
if (selectedConversation.id !== 'new') {
loadMessages(selectedConversation.id);
}
}, 3000);
@@ -443,11 +325,11 @@ export default function ChatPage() {
return () => clearInterval(pollInterval);
} else if (!selectedConversation) {
// Clear messages when no conversation selected
currentConversationIdRef.current = null;
setMessages([]);
setLoadingMessages(false);
}
}, [selectedConversation, isReady]);
}, [selectedConversation]);
// Auto-scroll to bottom of messages only if user was already at bottom
useEffect(() => {
@@ -471,10 +353,10 @@ export default function ChatPage() {
if (!isIdentityUnlocked) {
return (
<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>
<Lock size={48} style={{ color: 'var(--accent)' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Identity Required</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.
Chat requires your identity to be unlocked. Your private keys are used to sign messages to prove they came from you.
</p>
<button
onClick={() => setShowUnlockPrompt(true)}
@@ -488,33 +370,7 @@ export default function ChatPage() {
);
}
// Error State
if (status === 'error') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px' }}>
<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. Please refresh the page to try again.
</p>
<button
onClick={() => window.location.reload()}
className="btn btn-primary"
>
Refresh Page
</button>
</div>
);
}
// Loading State
if (!isReady || status === 'initializing') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
<Loader2 className="animate-spin" size={32} />
</div>
);
}
// Prevent flash of list view while processing compose intent
if (composeHandle && !selectedConversation) {
@@ -530,15 +386,24 @@ export default function ChatPage() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: '600px', margin: '0 auto' }}>
{/* Header */}
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<header style={{
position: 'sticky',
top: 0,
zIndex: 20,
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--border)',
padding: '12px 16px',
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => setSelectedConversation(null)}
style={{ background: 'none', border: 'none', padding: '8px', cursor: 'pointer', color: 'var(--foreground)' }}
style={{ background: 'none', border: 'none', padding: '4px', cursor: 'pointer', color: 'var(--foreground)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<ArrowLeft size={20} />
</button>
<div className="avatar">
<div className="avatar" style={{ width: '32px', height: '32px', fontSize: '14px' }}>
{selectedConversation.participant2.avatarUrl ? (
<img src={selectedConversation.participant2.avatarUrl} alt="" />
) : (
@@ -546,19 +411,19 @@ export default function ChatPage() {
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formatFullHandle(selectedConversation.participant2.handle)}
</div>
</div>
<button
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--foreground-tertiary)' }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--foreground-tertiary)', padding: '4px' }}
>
<Trash2 size={18} />
</button>
</div>
</div>
</header>
{/* Messages */}
<div
@@ -598,7 +463,7 @@ export default function ChatPage() {
border: msg.isSentByMe ? 'none' : '1px solid var(--border)',
wordBreak: 'break-word'
}}>
{msg.decryptedContent || msg.encryptedContent}
{msg.content}
</div>
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -685,30 +550,24 @@ export default function ChatPage() {
// LIST VIEW
return (
<>
<div className="post" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--background)', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>Messages</h1>
<button onClick={() => setShowNewChat(true)} className="btn btn-primary btn-sm">
<Plus size={16} style={{ marginRight: 6 }} /> New
</button>
</div>
<div style={{ position: 'sticky', top: 0, zIndex: 20, background: 'var(--background)' }}>
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>Chat</h1>
</div>
</header>
{showNewChat ? (
<form onSubmit={startNewChat} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<input
type="text"
placeholder="@username"
className="input"
value={newChatHandle}
onChange={e => setNewChatHandle(e.target.value)}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button type="button" onClick={() => setShowNewChat(false)} className="btn btn-ghost btn-sm">Cancel</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={sending}>Start</button>
</div>
</form>
) : (
<div style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
}}>
<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
@@ -719,7 +578,7 @@ export default function ChatPage() {
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
)}
</div>
</div>
{loading ? (
+17 -4
View File
@@ -287,9 +287,22 @@ export default function ExplorePage() {
return (
<div className="explore-page">
<header className="explore-header">
<h1>Explore</h1>
<form onSubmit={handleSearch} className="explore-search">
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'rgba(10, 10, 10, 0.95)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600 }}>Explore</h1>
</div>
</header>
<div style={{ padding: '0 16px' }}>
<form onSubmit={handleSearch} className="explore-search" style={{ marginTop: '16px' }}>
<SearchIcon />
<input
type="text"
@@ -298,7 +311,7 @@ export default function ExplorePage() {
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</header>
</div>
<div className="explore-tabs">
<button
+784 -21
View File
@@ -1301,36 +1301,799 @@ a.btn-primary:visited {
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: auto;
border-right: none;
border-top: 1px solid var(--border);
background: var(--background);
z-index: 100;
padding: 8px 16px;
/* Mobile-first optimizations */
html {
height: 100%;
}
.sidebar nav {
display: flex;
justify-content: space-around;
body {
font-size: 16px; /* Prevent zoom on input focus */
overflow-x: hidden;
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.nav-item span {
display: none;
}
.logo {
display: none;
/* Layout adjustments */
.layout {
flex-direction: column;
max-width: 100%;
padding: 0;
}
.main {
max-width: 100%;
width: 100%;
border-right: none;
padding-bottom: calc(70px + env(safe-area-inset-bottom));
min-height: 100vh;
order: 1;
}
/* Fixed bottom navigation - CRITICAL */
.sidebar {
position: fixed !important;
bottom: 0 !important;
top: auto !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
height: auto !important;
max-height: none !important;
border-right: none !important;
border-left: none !important;
border-bottom: none !important;
border-top: 1px solid var(--border) !important;
background: rgba(10, 10, 10, 0.98) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
z-index: 1000 !important;
padding: 0 !important;
margin: 0 !important;
overflow-x: auto !important;
overflow-y: visible !important;
order: 2;
padding-bottom: env(safe-area-inset-bottom) !important;
}
.sidebar nav {
display: flex !important;
flex-direction: row !important;
justify-content: space-around !important;
align-items: center !important;
padding: 4px 0 !important;
min-height: 60px !important;
width: 100% !important;
margin: 0 !important;
}
.nav-item {
flex: 1 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
gap: 4px !important;
padding: 8px 4px !important;
min-height: 56px !important;
font-size: 11px !important;
border-radius: 0 !important;
max-width: 100px !important;
min-width: 60px !important;
border: none !important;
background: transparent !important;
transition: none !important;
color: var(--foreground-secondary) !important;
}
.nav-item svg {
width: 24px !important;
height: 24px !important;
}
.nav-item span {
display: block !important;
font-size: 10px !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
.nav-item:hover {
background: transparent !important;
border: none !important;
color: var(--foreground-secondary) !important;
}
.nav-item:active {
background: transparent !important;
border: none !important;
}
.nav-item.active {
background: transparent !important;
color: var(--accent) !important;
border: none !important;
}
.nav-item.active svg {
color: var(--accent) !important;
}
.nav-item.active span {
color: var(--accent) !important;
}
.logo {
display: none !important;
}
/* Hide sidebar user info on mobile */
.sidebar > div:not(nav) {
display: none !important;
}
.sidebar .sidebar-user-info {
display: none !important;
}
/* Show nav labels on mobile */
.nav-label {
display: inline !important;
}
/* Position notification dots correctly on mobile */
.notification-dot {
position: absolute !important;
top: -2px !important;
right: -6px !important;
}
/* Post optimizations */
.post {
padding: 16px;
}
.post-header {
gap: 10px;
}
.post-content {
font-size: 16px;
line-height: 1.5;
margin-bottom: 12px;
}
.post.detail .post-content {
font-size: 18px;
line-height: 1.6;
}
/* Larger touch targets for actions */
.post-actions {
gap: 16px;
margin-top: 12px;
}
.post-action {
padding: 8px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: var(--radius-md);
transition: background 0.15s ease;
}
.post-action:active {
background: var(--background-tertiary);
}
.post-action svg {
width: 20px;
height: 20px;
}
/* Compose optimizations */
.compose {
padding: 16px;
}
.compose-input {
font-size: 16px;
min-height: 120px;
padding: 12px;
}
.compose-footer {
flex-wrap: wrap;
gap: 12px;
}
.compose-actions {
width: 100%;
justify-content: space-between;
}
.btn {
min-height: 44px;
padding: 10px 20px;
font-size: 15px;
}
.btn-primary {
flex: 0;
min-width: auto;
}
/* Avatar sizing */
.avatar {
width: 44px;
height: 44px;
font-size: 16px;
}
.avatar-sm {
width: 36px;
height: 36px;
font-size: 14px;
}
.avatar-lg {
width: 80px;
height: 80px;
font-size: 28px;
}
/* Media grid */
.post-media-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.post-media-item img,
.post-media-item video {
max-height: 400px;
}
/* User rows */
.user-row {
padding: 16px;
}
.user-card {
padding: 16px;
}
/* Feed toggle */
.feed-toggle {
font-size: 14px;
}
.feed-toggle-btn {
padding: 8px 16px;
font-size: 14px;
}
/* Feed meta */
.feed-meta {
margin: 12px;
padding: 12px;
}
/* Header sticky positioning */
header {
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(12px);
background: rgba(10, 10, 10, 0.95);
}
header h1 {
font-size: 20px;
font-weight: 600;
}
/* Explore page */
.explore-header {
padding: 16px;
}
.explore-header h1 {
font-size: 20px;
}
.explore-search {
padding: 12px 16px;
}
.explore-search input {
font-size: 16px;
}
.explore-tab {
padding: 16px 12px;
font-size: 15px;
}
/* Settings and admin pages */
.install-shell,
.admin-shell,
.notifications-shell {
padding: 16px 12px 80px;
}
/* Input fields */
.input {
font-size: 16px;
padding: 12px 14px;
min-height: 44px;
}
/* Container */
.container {
padding: 0 12px;
}
/* Link preview */
.link-preview-card {
margin-top: 12px;
margin-bottom: 12px;
}
.link-preview-info {
padding: 12px;
}
.link-preview-title {
font-size: 16px;
}
/* Compose media */
.compose-media-grid {
gap: 8px;
margin-top: 12px;
}
.compose-media-item {
max-height: 100px;
}
.compose-media-item img,
.compose-media-item video {
height: 100px;
}
/* Thread line */
.thread-line {
left: 30px;
top: 60px;
}
/* Notification adjustments */
.notification-row {
padding: 16px;
}
/* Admin adjustments */
.admin-row {
padding: 14px;
}
/* Better tap targets for all buttons */
button {
min-height: 44px;
}
/* Improve readability */
.post-time,
.user-card-handle,
.notification-meta {
font-size: 14px;
}
/* Video embeds */
.video-embed-container {
margin-bottom: 12px;
}
/* Scrolling improvements */
.main {
-webkit-overflow-scrolling: touch;
}
/* Safe area insets for notched devices */
.main {
padding-bottom: calc(70px + env(safe-area-inset-bottom)) !important;
}
/* Improve touch feedback */
.post:active {
background: var(--background-tertiary);
}
.user-row:active,
.user-card:active {
background: var(--background-tertiary);
}
/* Better button touch states */
.btn:active {
transform: scale(0.98);
}
/* Compose media button touch target */
.compose-media-button {
width: 44px;
height: 44px;
}
/* Improve menu dropdown positioning on mobile */
.post-menu-dropdown {
right: 0;
left: auto;
}
/* Better modal positioning on mobile */
.modal {
margin: 16px;
max-width: calc(100vw - 32px);
}
/* Identity unlock prompt mobile fixes */
.card {
max-width: calc(100vw - 32px) !important;
}
/* Fix button sizing in modals */
.card .btn {
min-height: 44px !important;
padding: 10px 16px !important;
font-size: 15px !important;
}
.card .btn-primary {
flex: 1 !important;
}
.card .btn-ghost {
flex: 1 !important;
}
/* Improve form spacing */
form {
padding: 16px;
}
/* Better card spacing */
.card {
padding: 16px;
margin: 12px;
}
/* Improve status pills */
.status-pill {
font-size: 10px;
padding: 4px 8px;
}
/* Better admin filters on mobile */
.admin-filters {
flex-wrap: wrap;
}
/* Improve pill buttons */
.pill {
padding: 8px 14px;
font-size: 13px;
}
/* Better explore content spacing */
.explore-content {
padding-bottom: 80px;
}
/* Improve notification row spacing */
.notification-row {
gap: 12px;
}
/* Better avatar in notifications */
.notification-avatar {
width: 44px;
height: 44px;
}
/* Improve install grid on mobile */
.install-grid {
grid-template-columns: 1fr;
}
/* Better admin summary on mobile */
.install-summary {
grid-template-columns: 1fr;
}
/* Improve compose reply target */
.compose-reply-target {
padding: 12px;
margin-bottom: 12px;
}
/* Better link preview on mobile */
.link-preview-card.mini {
max-height: 100px;
}
.link-preview-card.mini .link-preview-image {
width: 100px;
min-width: 100px;
height: 100px;
}
.link-preview-card.mini .link-preview-image img {
height: 100px;
}
/* Improve post menu button */
.post-menu-btn {
min-width: 44px;
min-height: 44px;
}
/* Better video controls on mobile */
video {
max-width: 100%;
}
/* Improve blurred video container */
.blurred-video-main {
max-height: 400px;
}
/* Better emoji sizing on mobile */
.post-content img.emoji,
.compose-input img.emoji {
height: 1.8em;
width: 1.8em;
}
/* Improve post reasons chips */
.post-reason-chip {
font-size: 11px;
padding: 4px 8px;
}
/* Better NSFW toggle */
.compose-nsfw-toggle {
padding: 8px 12px;
font-size: 13px;
}
/* Improve character counter */
.compose-counter {
font-size: 14px;
font-weight: 500;
}
/* Better footer spacing */
.compose-footer-left {
gap: 12px;
flex-wrap: wrap;
}
/* Improve search section */
.search-section {
padding: 16px 12px;
}
.search-section h2 {
font-size: 13px;
margin-bottom: 12px;
}
/* Better swarm post styling */
.swarm-post-card {
padding: 16px;
}
.swarm-post-content {
font-size: 16px;
}
/* Improve post detail view */
.post.detail {
padding: 20px 16px;
}
/* Better thread view on mobile */
.thread-container {
padding: 0;
}
.post.thread-parent {
padding: 12px 16px;
}
/* Improve reply indicator */
.post-reply-to {
font-size: 14px;
margin-bottom: 8px;
}
/* Better loading states */
.explore-loading,
.explore-empty {
padding: 32px 16px;
}
/* Improve empty states */
.admin-empty,
.notifications-empty {
padding: 32px 16px;
}
/* Chat page mobile optimizations */
.chat-container {
flex-direction: column;
}
.chat-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.chat-main {
width: 100%;
height: calc(100vh - 80px);
}
/* Better form inputs on mobile */
textarea {
font-size: 16px;
}
select {
font-size: 16px;
min-height: 44px;
}
/* Improve modal dialogs on mobile */
dialog {
max-width: calc(100vw - 32px);
margin: 16px;
}
/* Better table responsiveness */
table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Improve code blocks on mobile */
pre {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Better image handling */
img {
max-width: 100%;
height: auto;
}
/* Improve settings pages */
.settings-section {
padding: 16px 12px;
}
/* Better profile pages */
.profile-header {
padding: 16px;
}
.profile-stats {
flex-wrap: wrap;
gap: 12px;
}
/* Improve bot pages */
.bot-card {
padding: 16px;
}
/* Better moderation pages */
.moderation-card {
padding: 16px;
}
/* Improve notification pages */
.notifications-header {
padding: 16px;
}
/* Better login/register pages */
.auth-container {
padding: 24px 16px;
}
/* Improve install pages */
.install-header h1 {
font-size: 24px;
}
/* Better admin pages */
.admin-header h1 {
font-size: 22px;
}
/* Improve tabs */
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Better dropdown menus */
.dropdown-menu {
max-width: calc(100vw - 32px);
}
/* Improve tooltips on mobile */
[title] {
position: relative;
}
/* Better focus states for accessibility */
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Improve skeleton loaders */
.skeleton {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Better error states */
.error-message {
padding: 12px;
font-size: 14px;
}
/* Improve success states */
.success-message {
padding: 12px;
font-size: 14px;
}
/* Better warning states */
.warning-message {
padding: 12px;
font-size: 14px;
}
/* Improve info states */
.info-message {
padding: 12px;
font-size: 14px;
}
}
/* Explore Page */
+7 -1
View File
@@ -38,7 +38,13 @@ export async function generateMetadata(): Promise<Metadata> {
icon: "/api/favicon",
},
themeColor: "#0a0a0a",
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 5,
userScalable: true,
viewportFit: "cover",
},
};
}
+3 -3
View File
@@ -200,12 +200,12 @@ export default function Home() {
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
background: 'rgba(10, 10, 10, 0.95)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Home</h1>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', flexWrap: 'wrap' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600 }}>Home</h1>
<div className="feed-toggle">
<button
className={`feed-toggle-btn ${feedType === 'following' ? 'active' : ''}`}
-211
View File
@@ -1,211 +0,0 @@
/**
* Bot Management Page
*
* Lists user's bots and provides creation interface.
*
* Requirements: 1.3
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react';
interface BotData {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
lastPostAt: Date | null;
createdAt: Date;
}
export default function BotsPage() {
const router = useRouter();
const [bots, setBots] = useState<BotData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBots();
}, []);
const fetchBots = async () => {
try {
const response = await fetch('/api/bots');
if (response.ok) {
const data = await response.json();
setBots(data.bots || []);
}
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={24} />
My Bots
</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Create and manage your automated bots
</p>
</div>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Bot
</Link>
</header>
{bots.length === 0 ? (
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
No bots yet
</h2>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
Create your first bot to start automating posts and interactions
</p>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Your First Bot
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{bots.map((bot) => (
<div
key={bot.id}
className="card"
style={{
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => router.push(`/settings/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
<span className="status-pill suspended">
Suspended
</span>
) : bot.isActive ? (
<span className="status-pill active">
Active
</span>
) : (
<span className="status-pill">
Inactive
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
gap: '16px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
paddingTop: '12px',
borderTop: '1px solid var(--border)',
}}>
<span>
Last post: {bot.lastPostAt
? new Date(bot.lastPostAt).toLocaleDateString()
: 'Never'}
</span>
<span>
Created: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+105 -103
View File
@@ -1,124 +1,126 @@
'use client';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Rocket, Shield, Bell, Bot, Eye, UserX } from 'lucide-react';
import { Rocket, Shield, Bell, Eye, UserX } from 'lucide-react';
export default function SettingsPage() {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<Link href="/" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Settings</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage your account
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Settings</h1>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/bots" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bots
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Create and manage automated bots
</div>
</Link>
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<Link href="/settings/content" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Eye size={18} />
Content Settings
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
NSFW preferences and content visibility
</div>
</Link>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/moderation" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserX size={18} />
Moderation
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Blocked users and muted nodes
</div>
</Link>
<Link href="/settings/migration" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} />
Export Account
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Download a backup of your account and content
</div>
</Link>
<Link href="/settings/content" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Eye size={18} />
Content Settings
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
NSFW preferences and content visibility
</div>
</Link>
<Link href="/settings/security" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Security
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Change password
</div>
</Link>
<Link href="/settings/privacy" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Privacy & Safety
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Control who can message you
</div>
</Link>
<div className="card" style={{
display: 'block',
padding: '20px',
opacity: 0.5,
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bell size={18} />
Notifications
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Notification preferences (coming soon)
<Link href="/settings/moderation" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserX size={18} />
Moderation
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Blocked users and muted nodes
</div>
</Link>
<Link href="/settings/migration" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} />
Export Account
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Download a backup of your account and content
</div>
</Link>
<Link href="/settings/security" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Security
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Change password
</div>
</Link>
<div className="card" style={{
display: 'block',
padding: '20px',
opacity: 0.5,
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bell size={18} />
Notifications
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Notification preferences (coming soon)
</div>
</div>
</div>
</div>
</div>
</>
);
}
+201
View File
@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeftIcon } from '@/components/Icons';
import { MessageSquare, Check } from 'lucide-react';
export default function PrivacySettingsPage() {
const router = useRouter();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
setDmPrivacy(data.user.dmPrivacy || 'everyone');
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
setDmPrivacy(newValue);
setSaving(true);
setStatus(null);
try {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dmPrivacy: newValue }),
});
if (res.ok) {
setStatus({ type: 'success', message: 'Settings saved successfully' });
setTimeout(() => setStatus(null), 3000);
} else {
const data = await res.json();
setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
}
} catch (error) {
setStatus({ type: 'error', message: 'An error occurred' });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
Loading privacy settings...
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<button
onClick={() => router.back()}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--foreground)',
display: 'flex',
alignItems: 'center'
}}
>
<ArrowLeftIcon />
</button>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Privacy & Safety</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Message and social privacy preferences
</p>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
<MessageSquare size={20} style={{ color: 'var(--accent)' }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>Direct Messages</h2>
</div>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', marginBottom: '20px' }}>
Control who can send you direct messages on Synapsis.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={() => handleSave('everyone')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'everyone' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'everyone' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>Everyone</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>Anyone can message you</div>
</div>
{dmPrivacy === 'everyone' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
<button
onClick={() => handleSave('following')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'following' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'following' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>Following Only</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>Only accounts you follow can message you</div>
</div>
{dmPrivacy === 'following' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
<button
onClick={() => handleSave('none')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'none' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'none' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>None</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>No one can send you new messages</div>
</div>
{dmPrivacy === 'none' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
</div>
</div>
{status && (
<div style={{
padding: '12px',
borderRadius: '8px',
background: status.type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: status.type === 'success' ? '#22c55e' : '#ef4444',
fontSize: '14px',
textAlign: 'center',
marginTop: '16px',
}}>
{status.message}
</div>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -504,8 +504,8 @@ export default function ProfilePage() {
{isFollowing ? 'Following' : 'Follow'}
</button>
)}
{/* Message Button (V2 Chat) */}
{user.did && (
{/* Message Button (V2 Chat) - Respect privacy settings */}
{user.did && !user.isBot && (user as any).canReceiveDms !== false && (
<Link href={`/chat?compose=${user.handle}`} className="btn btn-ghost" style={{ padding: '8px' }}>
<Mail size={20} />
</Link>