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:
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' : ''}`}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user