refactor: Migrate from ActivityPub federation to native Swarm network
- Remove ActivityPub infrastructure (webfinger, nodeinfo, inbox, activities, signatures) - Implement native peer-to-peer Swarm network with gossip protocol - Replace federated timeline with Swarm timeline aggregating posts from all nodes - Migrate direct messaging to end-to-end encrypted Swarm Chat system - Update user interactions (follow, like, repost) to use Swarm protocol - Add cryptographic key management for DID-based identity system - Remove server-bound identity model in favor of portable DID identities - Update documentation to reflect Swarm architecture and capabilities - Add debug utilities and chat testing tools for Swarm network - Refactor database schema to support distributed user directory - Update bot framework to work with Swarm network interactions - Simplify API routes to use Swarm protocol instead of ActivityPub - This transition enables true peer-to-peer communication, instant interactions, and encrypted messaging while maintaining sovereign identity through DIDs
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
|
||||
const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
|
||||
|
||||
return NextResponse.json({
|
||||
links: [
|
||||
{
|
||||
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
||||
href: `https://${nodeDomain}/nodeinfo/2.1`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { generateWebFingerResponse, parseWebFingerResource } from '@/lib/activitypub/webfinger';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const resource = searchParams.get('resource');
|
||||
|
||||
if (!resource) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing resource parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseWebFingerResource(resource);
|
||||
|
||||
if (!parsed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid resource format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
// Check if this is our domain
|
||||
if (parsed.domain !== nodeDomain && parsed.domain !== nodeDomain.replace(/:\d+$/, '')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Resource not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, parsed.handle.toLowerCase()),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const response = generateWebFingerResponse(user.handle, nodeDomain);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Content-Type': 'application/jrd+json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,15 +2,13 @@
|
||||
* Account Moved Notification API
|
||||
*
|
||||
* Called by the new node to notify the old node that an account has migrated.
|
||||
* The old node then marks the account as moved and broadcasts Move activity to followers.
|
||||
* The old node then marks the account as moved.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, follows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { createMoveActivity } from '@/lib/activitypub/activities';
|
||||
import { signRequest } from '@/lib/activitypub/signatures';
|
||||
|
||||
interface MoveNotification {
|
||||
oldHandle: string;
|
||||
@@ -75,37 +73,12 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const oldActorUrl = `https://${nodeDomain}/users/${user.handle}`;
|
||||
|
||||
// Create Move activity with DID extension
|
||||
const moveActivity = createMoveActivity(
|
||||
user,
|
||||
oldActorUrl,
|
||||
newActorUrl,
|
||||
nodeDomain
|
||||
);
|
||||
|
||||
// Broadcast Move activity to all followers
|
||||
// In a production system, this would be queued
|
||||
let notifiedCount = 0;
|
||||
for (const follow of userFollowers) {
|
||||
try {
|
||||
// For local Synapsis followers, we could update directly
|
||||
// For remote followers, we'd send the Move activity
|
||||
// For now, we'll just count them
|
||||
notifiedCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to notify follower:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Account ${oldHandle} marked as moved to ${newActorUrl}. Notified ${notifiedCount} followers.`);
|
||||
console.log(`Account ${oldHandle} marked as moved to ${newActorUrl}. ${userFollowers.length} followers.`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Account marked as moved',
|
||||
followersNotified: notifiedCount,
|
||||
followersNotified: userFollowers.length,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -179,26 +179,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
})();
|
||||
}
|
||||
} else if (post.apId) {
|
||||
// FALLBACK: Use ActivityPub for non-swarm posts
|
||||
(async () => {
|
||||
try {
|
||||
const { createLikeActivity } = await import('@/lib/activitypub/activities');
|
||||
const { deliverActivity } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
// Get the post author's actor URL
|
||||
const postWithAuthor = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, postId),
|
||||
with: { author: true },
|
||||
});
|
||||
|
||||
if (!postWithAuthor?.author) return;
|
||||
|
||||
const author = postWithAuthor.author as { handle: string };
|
||||
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
|
||||
} catch (err) {
|
||||
console.error('[Federation] Error federating like:', err);
|
||||
}
|
||||
})();
|
||||
// Non-swarm posts with apId are legacy - no federation needed
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, liked: true });
|
||||
|
||||
@@ -191,33 +191,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
})();
|
||||
}
|
||||
} else if (originalPost.apId) {
|
||||
// FALLBACK: Use ActivityPub for non-swarm posts
|
||||
(async () => {
|
||||
try {
|
||||
const { createAnnounceActivity } = await import('@/lib/activitypub/activities');
|
||||
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
// Send Announce to our followers
|
||||
const followerInboxes = await getFollowerInboxes(user.id);
|
||||
if (followerInboxes.length > 0) {
|
||||
const announceActivity = createAnnounceActivity(
|
||||
user,
|
||||
originalPost.apId!,
|
||||
nodeDomain,
|
||||
repost.id
|
||||
);
|
||||
|
||||
const privateKey = user.privateKeyEncrypted;
|
||||
if (privateKey) {
|
||||
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
|
||||
const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId);
|
||||
console.log(`[Federation] Announce for ${originalPost.apId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Federation] Error federating repost:', err);
|
||||
}
|
||||
})();
|
||||
// Non-swarm posts with apId are legacy - no federation needed
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, repost, reposted: true });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remotePosts } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { fetchRemotePost } from '@/lib/activitypub/fetchRemotePost';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -219,12 +218,8 @@ export async function GET(
|
||||
isReposted: false,
|
||||
};
|
||||
} else {
|
||||
const postUrl = `https://${nodeDomain}/posts/${id}`;
|
||||
const result = await fetchRemotePost(postUrl, nodeDomain);
|
||||
|
||||
if (result.post) {
|
||||
mainPost = result.post;
|
||||
}
|
||||
// Remote posts are no longer supported outside of swarm
|
||||
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+52
-75
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, notInArray, or, lt } from 'drizzle-orm';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -252,29 +252,8 @@ export async function POST(request: Request) {
|
||||
if (swarmResult.delivered > 0) {
|
||||
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
||||
}
|
||||
|
||||
// FALLBACK: Deliver to ActivityPub followers
|
||||
const { createCreateActivity } = await import('@/lib/activitypub/activities');
|
||||
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
|
||||
|
||||
const followerInboxes = await getFollowerInboxes(user.id);
|
||||
if (followerInboxes.length === 0) {
|
||||
return; // No remote followers to notify
|
||||
}
|
||||
|
||||
const createActivity = createCreateActivity(post, user, nodeDomain);
|
||||
|
||||
const privateKey = user.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
console.error('[Federation] User has no private key for signing');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
|
||||
const result = await deliverToFollowers(createActivity, followerInboxes, privateKey, keyId);
|
||||
console.log(`[Federation] Post ${post.id} delivered to ${result.delivered}/${followerInboxes.length} inboxes (${result.failed} failed)`);
|
||||
} catch (err) {
|
||||
console.error('[Federation] Error federating post:', err);
|
||||
console.error('[Swarm] Error delivering post:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -397,7 +376,7 @@ export async function GET(request: Request) {
|
||||
);
|
||||
|
||||
if (type === 'local') {
|
||||
// Local node posts only - no fediverse content
|
||||
// Local node posts only
|
||||
let whereCondition = baseFilter;
|
||||
|
||||
// Apply cursor-based pagination
|
||||
@@ -506,7 +485,7 @@ export async function GET(request: Request) {
|
||||
limit,
|
||||
});
|
||||
} else if (type === 'curated') {
|
||||
// Curated feed - swarm posts only (no fediverse)
|
||||
// Curated feed - swarm posts only
|
||||
let viewer = null;
|
||||
let includeNsfw = false;
|
||||
try {
|
||||
@@ -523,6 +502,12 @@ export async function GET(request: Request) {
|
||||
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
||||
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
|
||||
|
||||
console.log('[Curated Feed] Swarm result:', {
|
||||
postsCount: swarmResult.posts.length,
|
||||
sources: swarmResult.sources,
|
||||
includeNsfw,
|
||||
});
|
||||
|
||||
// Transform swarm posts to match local post format
|
||||
const swarmPosts = swarmResult.posts.map(sp => ({
|
||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||
@@ -620,6 +605,13 @@ export async function GET(request: Request) {
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
console.log('[Curated Feed] After ranking:', {
|
||||
swarmPostsCount: swarmPosts.length,
|
||||
afterMuteFilter: swarmPosts.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)).length,
|
||||
rankedPostsCount: rankedPosts.length,
|
||||
limit,
|
||||
});
|
||||
|
||||
feedPosts = rankedPosts;
|
||||
} else {
|
||||
// Home timeline - need auth
|
||||
@@ -671,7 +663,6 @@ export async function GET(request: Request) {
|
||||
let liveRemotePosts: any[] = [];
|
||||
if (followedRemoteUsers.length > 0) {
|
||||
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
|
||||
const { resolveRemoteUser } = await import('@/lib/activitypub/fetch');
|
||||
|
||||
// Wrap each fetch with a timeout to prevent slow nodes from blocking
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||
@@ -689,58 +680,44 @@ export async function GET(request: Request) {
|
||||
const handle = follow.targetHandle.slice(0, atIndex);
|
||||
const domain = follow.targetHandle.slice(atIndex + 1);
|
||||
|
||||
// Check if swarm node - use swarm API (faster)
|
||||
// Only fetch from swarm nodes
|
||||
const isSwarm = await isSwarmNode(domain);
|
||||
if (!isSwarm) return [];
|
||||
|
||||
if (isSwarm) {
|
||||
const profileData = await withTimeout(
|
||||
fetchSwarmUserProfile(handle, domain, limit),
|
||||
5000 // 5s timeout per node
|
||||
);
|
||||
if (!profileData?.posts) return [];
|
||||
const profileData = await withTimeout(
|
||||
fetchSwarmUserProfile(handle, domain, limit),
|
||||
5000 // 5s timeout per node
|
||||
);
|
||||
if (!profileData?.posts) return [];
|
||||
|
||||
return profileData.posts.map(post => ({
|
||||
id: `swarm:${domain}:${post.id}`,
|
||||
content: post.content,
|
||||
createdAt: new Date(post.createdAt),
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
return profileData.posts.map(post => ({
|
||||
id: `swarm:${domain}:${post.id}`,
|
||||
content: post.content,
|
||||
createdAt: new Date(post.createdAt),
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
isRemote: true,
|
||||
isNsfw: post.isNsfw,
|
||||
linkPreviewUrl: post.linkPreviewUrl,
|
||||
linkPreviewTitle: post.linkPreviewTitle,
|
||||
linkPreviewDescription: post.linkPreviewDescription,
|
||||
linkPreviewImage: post.linkPreviewImage,
|
||||
author: {
|
||||
id: `swarm:${domain}:${handle}`,
|
||||
handle: follow.targetHandle,
|
||||
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||
isRemote: true,
|
||||
isNsfw: post.isNsfw,
|
||||
linkPreviewUrl: post.linkPreviewUrl,
|
||||
linkPreviewTitle: post.linkPreviewTitle,
|
||||
linkPreviewDescription: post.linkPreviewDescription,
|
||||
linkPreviewImage: post.linkPreviewImage,
|
||||
author: {
|
||||
id: `swarm:${domain}:${handle}`,
|
||||
handle: follow.targetHandle,
|
||||
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||
isRemote: true,
|
||||
isBot: profileData.profile?.isBot,
|
||||
},
|
||||
media: post.media?.map((m: any, idx: number) => ({
|
||||
id: `swarm:${domain}:${post.id}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})) || [],
|
||||
replyTo: null,
|
||||
}));
|
||||
} else {
|
||||
// ActivityPub - fetch from outbox
|
||||
const remoteProfile = await resolveRemoteUser(handle, domain);
|
||||
if (!remoteProfile?.outbox) return [];
|
||||
|
||||
// For AP, fall back to cached posts (live outbox fetch is slower)
|
||||
const cachedPosts = await db.query.remotePosts.findMany({
|
||||
where: eq(remotePosts.authorHandle, follow.targetHandle),
|
||||
orderBy: [desc(remotePosts.publishedAt)],
|
||||
limit: limit,
|
||||
});
|
||||
|
||||
return transformRemotePosts(cachedPosts);
|
||||
}
|
||||
isBot: profileData.profile?.isBot,
|
||||
},
|
||||
media: post.media?.map((m: any, idx: number) => ({
|
||||
id: `swarm:${domain}:${post.id}:media:${idx}`,
|
||||
url: m.url,
|
||||
altText: m.altText || null,
|
||||
})) || [],
|
||||
replyTo: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
|
||||
return [];
|
||||
|
||||
+26
-47
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, posts, likes } from '@/db';
|
||||
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
|
||||
type SearchUser = {
|
||||
id: string;
|
||||
@@ -14,23 +14,6 @@ type SearchUser = {
|
||||
isBot?: boolean;
|
||||
};
|
||||
|
||||
const decodeEntities = (value: string) =>
|
||||
value
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/&#(\\d+);/g, (_, num) => String.fromCharCode(Number(num)))
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const sanitizeText = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const withoutTags = value.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
return decoded.replace(/\\s+/g, ' ').trim() || null;
|
||||
};
|
||||
|
||||
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
||||
let trimmed = query.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -47,30 +30,6 @@ const parseRemoteHandleQuery = (query: string): { handle: string; domain: string
|
||||
return { handle: handle.toLowerCase(), domain: domain.toLowerCase() };
|
||||
};
|
||||
|
||||
const buildRemoteUser = (
|
||||
profile: Awaited<ReturnType<typeof resolveRemoteUser>>,
|
||||
handle: string,
|
||||
domain: string,
|
||||
): SearchUser | null => {
|
||||
if (!profile) return null;
|
||||
const displayName = sanitizeText(profile.name) || sanitizeText(profile.preferredUsername) || null;
|
||||
const username = profile.preferredUsername || handle;
|
||||
if (!username) return null;
|
||||
const fullHandle = `${username}@${domain}`.replace(/^@/, '');
|
||||
const iconUrl = typeof profile.icon === 'string' ? profile.icon : profile.icon?.url;
|
||||
const profileUrl = typeof profile.url === 'string' ? profile.url : profile.id;
|
||||
|
||||
return {
|
||||
id: profile.id || profileUrl || `remote:${fullHandle}`,
|
||||
handle: fullHandle,
|
||||
displayName,
|
||||
avatarUrl: iconUrl ?? null,
|
||||
bio: sanitizeText(profile.summary),
|
||||
profileUrl: profileUrl ?? null,
|
||||
isRemote: true,
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -122,14 +81,34 @@ export async function GET(request: Request) {
|
||||
searchUsers = localUsers.filter(u => !u.handle.includes('@'));
|
||||
}
|
||||
|
||||
// Federated user lookup (exact handle@domain queries)
|
||||
// Swarm user lookup (exact handle@domain queries)
|
||||
if ((type === 'all' || type === 'users') && searchUsers.length < limit) {
|
||||
const parsedRemote = parseRemoteHandleQuery(query);
|
||||
if (parsedRemote) {
|
||||
const remoteProfile = await resolveRemoteUser(parsedRemote.handle, parsedRemote.domain);
|
||||
const remoteUser = buildRemoteUser(remoteProfile, parsedRemote.handle, parsedRemote.domain);
|
||||
if (remoteUser && !searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
|
||||
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
|
||||
// Only lookup on swarm nodes
|
||||
const isSwarm = await isSwarmNode(parsedRemote.domain);
|
||||
if (isSwarm) {
|
||||
try {
|
||||
const profileData = await fetchSwarmUserProfile(parsedRemote.handle, parsedRemote.domain, 0);
|
||||
if (profileData?.profile) {
|
||||
const fullHandle = `${parsedRemote.handle}@${parsedRemote.domain}`;
|
||||
const remoteUser: SearchUser = {
|
||||
id: `swarm:${parsedRemote.domain}:${parsedRemote.handle}`,
|
||||
handle: fullHandle,
|
||||
displayName: profileData.profile.displayName || parsedRemote.handle,
|
||||
avatarUrl: profileData.profile.avatarUrl || null,
|
||||
bio: profileData.profile.bio || null,
|
||||
profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`,
|
||||
isRemote: true,
|
||||
isBot: profileData.profile.isBot,
|
||||
};
|
||||
if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
|
||||
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Search] Error fetching swarm user ${parsedRemote.handle}@${parsedRemote.domain}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, lt } from 'drizzle-orm';
|
||||
import { eq, desc, and, lt, isNull } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { decryptMessage } from '@/lib/swarm/chat-crypto';
|
||||
|
||||
@@ -53,10 +53,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Build query with cursor-based pagination
|
||||
let whereCondition = eq(chatMessages.conversationId, conversationId);
|
||||
if (cursor) {
|
||||
whereCondition = and(whereCondition, lt(chatMessages.createdAt, new Date(cursor)));
|
||||
}
|
||||
const baseCondition = eq(chatMessages.conversationId, conversationId);
|
||||
const whereCondition = cursor
|
||||
? and(baseCondition, lt(chatMessages.createdAt, new Date(cursor)))!
|
||||
: baseCondition;
|
||||
|
||||
// Get messages
|
||||
const messages = await db.query.chatMessages.findMany({
|
||||
@@ -124,7 +124,7 @@ export async function PATCH(request: NextRequest) {
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessages.conversationId, conversationId),
|
||||
eq(chatMessages.readAt, null)
|
||||
isNull(chatMessages.readAt)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -18,18 +18,35 @@ const sendMessageSchema = z.object({
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const body = await request.json();
|
||||
const data = sendMessageSchema.parse(body);
|
||||
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);
|
||||
|
||||
// Get sender info
|
||||
const sender = await db.query.users.findFirst({
|
||||
@@ -37,13 +54,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
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)
|
||||
const recipientHandle = data.recipientHandle.toLowerCase();
|
||||
const isRemote = recipientHandle.includes('@');
|
||||
|
||||
|
||||
let recipientUser: typeof users.$inferSelect | undefined;
|
||||
let recipientPublicKey: string;
|
||||
let recipientNodeDomain: string | null = null;
|
||||
@@ -52,6 +71,7 @@ export async function POST(request: NextRequest) {
|
||||
// Remote user - need to fetch their public key
|
||||
const [handle, domain] = recipientHandle.split('@');
|
||||
recipientNodeDomain = domain;
|
||||
console.log('[Chat Send] Processing remote recipient:', handle, '@', domain);
|
||||
|
||||
// Try to find cached remote user
|
||||
recipientUser = await db.query.users.findFirst({
|
||||
@@ -61,10 +81,12 @@ export async function POST(request: NextRequest) {
|
||||
if (!recipientUser) {
|
||||
// Fetch from remote node
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -80,24 +102,29 @@ export async function POST(request: NextRequest) {
|
||||
publicKey: recipientPublicKey,
|
||||
}).returning();
|
||||
recipientUser = newUser;
|
||||
console.log('[Chat Send] Remote user cached');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch remote user:', 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');
|
||||
}
|
||||
} 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 });
|
||||
}
|
||||
|
||||
@@ -105,7 +132,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Encrypt the message with recipient's public key
|
||||
const encryptedContent = encryptMessage(data.content, recipientPublicKey);
|
||||
console.log('[Chat Send] Encrypting message...');
|
||||
let encryptedContent: string;
|
||||
try {
|
||||
encryptedContent = encryptMessage(data.content, recipientPublicKey);
|
||||
} catch (encError) {
|
||||
console.error('[Chat Send] Encryption failed:', encError);
|
||||
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get or create conversation
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
@@ -116,6 +150,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
console.log('[Chat Send] Creating new conversation');
|
||||
const [newConversation] = await db.insert(chatConversations).values({
|
||||
participant1Id: sender.id,
|
||||
participant2Handle: recipientHandle,
|
||||
@@ -130,6 +165,7 @@ export async function POST(request: NextRequest) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
const swarmMessageId = `swarm:${nodeDomain}:${messageId}`;
|
||||
|
||||
console.log('[Chat Send] Inserting message into DB');
|
||||
const [newMessage] = await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: sender.handle,
|
||||
@@ -153,6 +189,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 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,
|
||||
@@ -177,22 +218,27 @@ export async function POST(request: NextRequest) {
|
||||
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('Failed to send message to remote node:', 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) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
|
||||
// Should be caught by safeParse above, but just in case
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Send message error:', error);
|
||||
console.error('[Chat Send] Unhandled error:', error);
|
||||
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* POST: Receive posts from users on other swarm nodes that local users follow
|
||||
*
|
||||
* This is the swarm equivalent of ActivityPub inbox - when a user on another
|
||||
* Synapsis node creates a post, it gets pushed here for their followers.
|
||||
* When a user on another Synapsis node creates a post, it gets pushed here
|
||||
* for their followers on this node.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* POST: Receive a follow from another swarm node
|
||||
*
|
||||
* This enables swarm-native follows between Synapsis nodes,
|
||||
* bypassing ActivityPub for faster, more direct connections.
|
||||
* This enables swarm-native follows between Synapsis nodes
|
||||
* with instant delivery and real-time updates.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
@@ -60,10 +60,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Use query builder for better conditional logic
|
||||
// Only return posts from local users (not remote placeholder users)
|
||||
// Local posts may have apId if they've been federated, so we check nodeId instead
|
||||
let whereCondition = and(
|
||||
isNull(posts.replyToId), // Not a reply
|
||||
eq(posts.isRemoved, false), // Not removed
|
||||
isNull(posts.apId) // Not a federated/swarm post (local posts only)
|
||||
isNull(users.nodeId) // Local user (not from another swarm node)
|
||||
);
|
||||
|
||||
if (cursor) {
|
||||
@@ -77,7 +78,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Get recent public posts (not replies, local users only, not removed)
|
||||
// Filter out remote placeholder users by checking handle doesn't contain @
|
||||
const recentPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
@@ -100,10 +100,12 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(and(whereCondition, sql`${users.handle} NOT LIKE '%@%'`))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmPost[] = [];
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ import crypto from 'crypto';
|
||||
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities';
|
||||
import { deliverActivity } from '@/lib/activitypub/outbox';
|
||||
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
@@ -20,12 +16,6 @@ const parseRemoteHandle = (handle: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
|
||||
const stripHtml = (html: string | null | undefined): string | null => {
|
||||
if (!html) return null;
|
||||
return html.replace(/<[^>]*>/g, '').trim() || null;
|
||||
};
|
||||
|
||||
// Check follow status
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
@@ -115,98 +105,42 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Already following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a Synapsis swarm node
|
||||
// Only allow following swarm nodes
|
||||
const isSwarm = await isSwarmNode(remote.domain);
|
||||
|
||||
if (isSwarm) {
|
||||
// Use swarm protocol for Synapsis nodes
|
||||
const activityId = crypto.randomUUID();
|
||||
|
||||
const result = await deliverSwarmFollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
follow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerDisplayName: currentUser.displayName || currentUser.handle,
|
||||
followerAvatarUrl: currentUser.avatarUrl || undefined,
|
||||
followerBio: currentUser.bio || undefined,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: activityId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
|
||||
// Fall through to ActivityPub below
|
||||
} else {
|
||||
// Swarm follow succeeded - store the follow locally
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: currentUser.id,
|
||||
targetHandle,
|
||||
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
|
||||
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
|
||||
activityId,
|
||||
displayName: null, // Will be fetched later
|
||||
bio: null,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
|
||||
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
|
||||
|
||||
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
||||
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
|
||||
if (!targetInbox) {
|
||||
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
|
||||
if (!isSwarm) {
|
||||
return NextResponse.json({ error: 'Can only follow users on Synapsis swarm nodes' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use swarm protocol
|
||||
const activityId = crypto.randomUUID();
|
||||
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
|
||||
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
||||
const privateKey = currentUser.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
|
||||
}
|
||||
const result = await deliverActivity(followActivity, targetInbox, privateKey, keyId);
|
||||
|
||||
const result = await deliverSwarmFollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
follow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerDisplayName: currentUser.displayName || currentUser.handle,
|
||||
followerAvatarUrl: currentUser.avatarUrl || undefined,
|
||||
followerBio: currentUser.bio || undefined,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: activityId,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Extract avatar URL from remote profile
|
||||
let avatarUrl: string | null = null;
|
||||
if (remoteProfile.icon) {
|
||||
if (typeof remoteProfile.icon === 'string') {
|
||||
avatarUrl = remoteProfile.icon;
|
||||
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
|
||||
avatarUrl = remoteProfile.icon.url;
|
||||
}
|
||||
return NextResponse.json({ error: result.error || 'Failed to follow user' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Store the follow locally
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: currentUser.id,
|
||||
targetHandle,
|
||||
targetActorUrl: remoteProfile.id,
|
||||
inboxUrl: targetInbox,
|
||||
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
|
||||
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
|
||||
activityId,
|
||||
displayName: remoteProfile.name || null,
|
||||
bio: stripHtml(remoteProfile.summary),
|
||||
avatarUrl,
|
||||
displayName: null,
|
||||
bio: null,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
@@ -215,12 +149,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
const origin = new URL(request.url).origin;
|
||||
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
|
||||
.then(result => console.log(`Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('Error caching remote posts:', err));
|
||||
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
|
||||
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
|
||||
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
|
||||
|
||||
return NextResponse.json({ success: true, following: true, remote: true });
|
||||
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
|
||||
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
@@ -263,14 +197,14 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
});
|
||||
|
||||
if (currentUser.id !== targetUser.id) {
|
||||
// Create notification with actor info stored directly
|
||||
// Create notification
|
||||
await db.insert(notifications).values({
|
||||
userId: targetUser.id,
|
||||
actorId: currentUser.id,
|
||||
actorHandle: currentUser.handle,
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null, // Local user
|
||||
actorNodeDomain: null,
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
@@ -297,8 +231,6 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
@@ -333,55 +265,23 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Not following' }, { status: 400 });
|
||||
}
|
||||
|
||||
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
|
||||
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
|
||||
|
||||
if (isSwarmFollow) {
|
||||
// Use swarm protocol for unfollow
|
||||
const result = await deliverSwarmUnfollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
unfollow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
// Use swarm protocol for unfollow
|
||||
const result = await deliverSwarmUnfollow(remote.domain, {
|
||||
targetHandle: remote.handle,
|
||||
unfollow: {
|
||||
followerHandle: currentUser.handle,
|
||||
followerNodeDomain: nodeDomain,
|
||||
interactionId: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
|
||||
// Continue anyway - remove local record
|
||||
}
|
||||
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
// FALLBACK: Use ActivityPub for non-swarm follows
|
||||
const originalFollow = createFollowActivity(
|
||||
currentUser,
|
||||
existingRemoteFollow.targetActorUrl,
|
||||
nodeDomain,
|
||||
existingRemoteFollow.activityId
|
||||
);
|
||||
const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID());
|
||||
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
|
||||
const privateKey = currentUser.privateKeyEncrypted;
|
||||
if (!privateKey) {
|
||||
return NextResponse.json({ error: 'Missing signing key' }, { status: 500 });
|
||||
}
|
||||
const result = await deliverActivity(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId);
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
|
||||
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
|
||||
// Continue anyway - remove local record
|
||||
}
|
||||
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
@@ -389,7 +289,8 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: false, remote: true });
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
|
||||
}
|
||||
|
||||
if (!db) {
|
||||
@@ -432,8 +333,6 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// TODO: Send ActivityPub Undo Follow activity
|
||||
|
||||
return NextResponse.json({ success: true, following: false });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}));
|
||||
return NextResponse.json({ following, nextCursor: null });
|
||||
}
|
||||
// If swarm fetch fails, return empty (could add ActivityPub fallback later)
|
||||
// If swarm fetch fails, return empty
|
||||
return NextResponse.json({ following: [], nextCursor: null });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* ActivityPub User Inbox Endpoint
|
||||
*
|
||||
* Receives incoming activities from remote servers for a specific user.
|
||||
* POST /users/{handle}/inbox
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
|
||||
// Verify the target user exists
|
||||
if (!db) {
|
||||
console.error('[Inbox] Database not available');
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
|
||||
}
|
||||
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
console.error(`[Inbox] User not found: ${cleanHandle}`);
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the activity
|
||||
let activity: IncomingActivity;
|
||||
try {
|
||||
activity = await request.json();
|
||||
} catch (e) {
|
||||
console.error('[Inbox] Invalid JSON body:', e);
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[Inbox] Received ${activity.type} activity for @${cleanHandle} from ${activity.actor}`);
|
||||
|
||||
// Extract headers for signature verification
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
// Get the request path
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Process the activity
|
||||
const result = await processIncomingActivity(activity, headers, path, targetUser);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[Inbox] Activity processing failed: ${result.error}`);
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Return 202 Accepted (standard for ActivityPub)
|
||||
return new NextResponse(null, { status: 202 });
|
||||
} catch (error) {
|
||||
console.error('[Inbox] Error processing activity:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityPub requires the inbox to be discoverable
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
const { handle } = await context.params;
|
||||
return NextResponse.json(
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
summary: `Inbox for @${handle}`,
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,90 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, likes } from '@/db';
|
||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { eq, desc, and, inArray, lt } from 'drizzle-orm';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const decodeEntities = (value: string) =>
|
||||
value
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)))
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const sanitizeText = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const withoutTags = value.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
return decoded.replace(/\s+/g, ' ').trim() || null;
|
||||
};
|
||||
|
||||
const extractTextAndUrls = (value?: string | null) => {
|
||||
if (!value) return { text: '', urls: [] as string[] };
|
||||
let html = value;
|
||||
// Replace <br> with spaces to avoid words running together.
|
||||
html = html.replace(/<br\s*\/?>/gi, ' ');
|
||||
// Replace anchor tags with their hrefs (preferred) or inner text.
|
||||
html = html.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, text) => {
|
||||
const cleanedHref = decodeEntities(String(href));
|
||||
const cleanedText = decodeEntities(String(text)).replace(/<[^>]*>/g, ' ').trim();
|
||||
return cleanedHref || cleanedText;
|
||||
});
|
||||
const withoutTags = html.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
const text = decoded.replace(/\s+/g, ' ').trim();
|
||||
const urls = Array.from(text.matchAll(/https?:\/\/[^\s]+/gi)).map((match) => match[0]);
|
||||
return { text, urls };
|
||||
};
|
||||
|
||||
const normalizeUrl = (value: string) => value.replace(/[)\].,!?]+$/, '');
|
||||
|
||||
const fetchLinkPreview = async (url: string, origin: string) => {
|
||||
try {
|
||||
const previewUrl = new URL('/api/media/preview', origin);
|
||||
previewUrl.searchParams.set('url', url);
|
||||
const res = await fetch(previewUrl.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return {
|
||||
url: data?.url || url,
|
||||
title: data?.title || null,
|
||||
description: data?.description || null,
|
||||
image: data?.image || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const stripFirstUrl = (text: string, url: string) => {
|
||||
const idx = text.indexOf(url);
|
||||
if (idx === -1) return text;
|
||||
const before = text.slice(0, idx).trimEnd();
|
||||
const after = text.slice(idx + url.length).trimStart();
|
||||
return `${before} ${after}`.trim();
|
||||
};
|
||||
|
||||
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
|
||||
const normalizeForDedup = (content: string): string => {
|
||||
return content
|
||||
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
|
||||
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like ‘
|
||||
.replace(/&#\d+;/g, '') // Remove numeric entities
|
||||
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
|
||||
.replace(/[^\w\s]/g, '') // Remove punctuation
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.slice(0, 50); // Compare first 50 chars (article title)
|
||||
};
|
||||
|
||||
const parseRemoteHandle = (handle: string) => {
|
||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
||||
const parts = clean.split('@').filter(Boolean);
|
||||
@@ -94,52 +14,6 @@ const parseRemoteHandle = (handle: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch remote user posts via Swarm API (preferred for Synapsis nodes)
|
||||
*/
|
||||
const fetchSwarmUserPosts = async (handle: string, domain: string, limit: number) => {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const url = `${protocol}://${domain}/api/swarm/users/${handle}?limit=${limit}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.profile || !data.posts) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOutboxItems = async (outboxUrl: string, limit: number) => {
|
||||
const res = await fetch(outboxUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const first = data?.first;
|
||||
if (first) {
|
||||
if (typeof first === 'string') {
|
||||
const pageRes = await fetch(first, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
});
|
||||
if (!pageRes.ok) return [];
|
||||
const page = await pageRes.json();
|
||||
return page?.orderedItems || page?.items || [];
|
||||
}
|
||||
return first?.orderedItems || first?.items || [];
|
||||
}
|
||||
const items = data?.orderedItems || data?.items || [];
|
||||
return items.slice(0, limit);
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
@@ -155,10 +29,15 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.posts) {
|
||||
const profile = swarmData.profile;
|
||||
// Only fetch from swarm nodes
|
||||
const isSwarm = await isSwarmNode(remote.domain);
|
||||
if (!isSwarm) {
|
||||
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
|
||||
}
|
||||
|
||||
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
|
||||
if (profileData?.posts) {
|
||||
const profile = profileData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
@@ -167,7 +46,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = swarmData.posts.map((post: any) => ({
|
||||
const posts = profileData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
@@ -188,72 +67,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile?.outbox) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
|
||||
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: remoteProfile.id || `remote:${authorHandle}`,
|
||||
handle: authorHandle,
|
||||
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
|
||||
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
|
||||
bio: sanitizeText(remoteProfile.summary),
|
||||
};
|
||||
const posts = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenContentKeys = new Set<string>(); // For content-based dedup
|
||||
const origin = new URL(request.url).origin;
|
||||
for (const item of outboxItems) {
|
||||
const activity = item?.type === 'Create' ? item : null;
|
||||
const object = activity?.object;
|
||||
if (!object || typeof object === 'string' || object.type !== 'Note') {
|
||||
continue;
|
||||
}
|
||||
// Deduplicate by object ID
|
||||
const postId = object.id || activity.id;
|
||||
if (seenIds.has(postId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-based dedup: similar content = skip
|
||||
const contentKey = normalizeForDedup(object.content || '');
|
||||
if (seenContentKeys.has(contentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(postId);
|
||||
seenContentKeys.add(contentKey);
|
||||
|
||||
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
|
||||
const { text, urls } = extractTextAndUrls(object.content);
|
||||
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
|
||||
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
|
||||
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
|
||||
posts.push({
|
||||
id: postId,
|
||||
content: contentText || '',
|
||||
createdAt: object.published || activity.published || new Date().toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author,
|
||||
media: attachments
|
||||
.filter((attachment: any) => attachment?.url)
|
||||
.map((attachment: any, index: number) => ({
|
||||
id: `${postId || 'media'}-${index}`,
|
||||
url: attachment.url,
|
||||
altText: sanitizeText(attachment.name) || null,
|
||||
})),
|
||||
linkPreviewUrl: linkPreview?.url || normalizedUrl,
|
||||
linkPreviewTitle: linkPreview?.title || null,
|
||||
linkPreviewDescription: linkPreview?.description || null,
|
||||
linkPreviewImage: linkPreview?.image || null,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
|
||||
// Find the user
|
||||
@@ -266,10 +80,15 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.posts) {
|
||||
const profile = swarmData.profile;
|
||||
// Only fetch from swarm nodes
|
||||
const isSwarm = await isSwarmNode(remote.domain);
|
||||
if (!isSwarm) {
|
||||
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
|
||||
}
|
||||
|
||||
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
|
||||
if (profileData?.posts) {
|
||||
const profile = profileData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
@@ -278,7 +97,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = swarmData.posts.map((post: any) => ({
|
||||
const posts = profileData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
@@ -299,85 +118,14 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile?.outbox) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
|
||||
const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit);
|
||||
const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: remoteProfile.id || `remote:${authorHandle}`,
|
||||
handle: authorHandle,
|
||||
displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle,
|
||||
avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url,
|
||||
bio: sanitizeText(remoteProfile.summary),
|
||||
};
|
||||
const posts = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenContentKeys = new Set<string>(); // For content-based dedup
|
||||
const origin = new URL(request.url).origin;
|
||||
for (const item of outboxItems) {
|
||||
const activity = item?.type === 'Create' ? item : null;
|
||||
const object = activity?.object;
|
||||
if (!object || typeof object === 'string' || object.type !== 'Note') {
|
||||
continue;
|
||||
}
|
||||
// Deduplicate by object ID
|
||||
const postId = object.id || activity.id;
|
||||
if (seenIds.has(postId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content-based dedup: similar content = skip
|
||||
const contentKey = normalizeForDedup(object.content || '');
|
||||
if (seenContentKeys.has(contentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(postId);
|
||||
seenContentKeys.add(contentKey);
|
||||
|
||||
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
|
||||
const { text, urls } = extractTextAndUrls(object.content);
|
||||
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
|
||||
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
|
||||
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
|
||||
posts.push({
|
||||
id: postId,
|
||||
content: contentText || '',
|
||||
createdAt: object.published || activity.published || new Date().toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author,
|
||||
media: attachments
|
||||
.filter((attachment: any) => attachment?.url)
|
||||
.map((attachment: any, index: number) => ({
|
||||
id: `${postId || 'media'}-${index}`,
|
||||
url: attachment.url,
|
||||
altText: sanitizeText(attachment.name) || null,
|
||||
})),
|
||||
linkPreviewUrl: linkPreview?.url || normalizedUrl,
|
||||
linkPreviewTitle: linkPreview?.title || null,
|
||||
linkPreviewDescription: linkPreview?.description || null,
|
||||
linkPreviewImage: linkPreview?.image || null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts,
|
||||
nextCursor: null,
|
||||
});
|
||||
return NextResponse.json({ posts: [] });
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get user's posts with cursor-based pagination
|
||||
const { lt } = await import('drizzle-orm');
|
||||
|
||||
let whereConditions = and(eq(posts.userId, user.id), eq(posts.isRemoved, false));
|
||||
|
||||
// If cursor provided, get posts older than the cursor
|
||||
|
||||
@@ -1,65 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { userToActor } from '@/lib/activitypub/actor';
|
||||
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
|
||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const decodeEntities = (value: string) =>
|
||||
value
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)))
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const sanitizeText = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
const withoutTags = value.replace(/<[^>]*>/g, ' ');
|
||||
const decoded = decodeEntities(withoutTags);
|
||||
return decoded.replace(/\s+/g, ' ').trim() || null;
|
||||
};
|
||||
|
||||
const fetchCollectionCount = async (url?: string | null) => {
|
||||
if (!url) return 0;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
if (typeof data?.totalItems === 'number') return data.totalItems;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch remote user profile via Swarm API (preferred for Synapsis nodes)
|
||||
*/
|
||||
const fetchSwarmProfile = async (handle: string, domain: string) => {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const url = `${protocol}://${domain}/api/swarm/users/${handle}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.profile) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
@@ -93,72 +38,37 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (remoteHandle && remoteDomain) {
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmProfile(remoteHandle, remoteDomain);
|
||||
if (swarmData?.profile) {
|
||||
const profile = swarmData.profile;
|
||||
|
||||
// Build botOwner object if this is a bot with an owner
|
||||
let botOwner = undefined;
|
||||
if (profile.isBot && profile.botOwnerHandle) {
|
||||
botOwner = {
|
||||
id: `swarm:${remoteDomain}:${profile.botOwnerHandle}`,
|
||||
handle: `${profile.botOwnerHandle}@${remoteDomain}`,
|
||||
};
|
||||
// Only fetch from swarm nodes
|
||||
const isSwarm = await isSwarmNode(remoteDomain);
|
||||
if (isSwarm) {
|
||||
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
|
||||
if (profileData?.profile) {
|
||||
const profile = profileData.profile;
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: `swarm:${remoteDomain}:${profile.handle}`,
|
||||
handle: `${profile.handle}@${remoteDomain}`,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
headerUrl: profile.headerUrl || null,
|
||||
followersCount: profile.followersCount,
|
||||
followingCount: profile.followingCount,
|
||||
postsCount: profile.postsCount,
|
||||
website: profile.website || null,
|
||||
createdAt: profile.createdAt,
|
||||
isRemote: true,
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: `swarm:${remoteDomain}:${profile.handle}`,
|
||||
handle: `${profile.handle}@${remoteDomain}`,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
headerUrl: profile.headerUrl || null,
|
||||
followersCount: profile.followersCount,
|
||||
followingCount: profile.followingCount,
|
||||
postsCount: profile.postsCount,
|
||||
website: profile.website || null,
|
||||
createdAt: profile.createdAt,
|
||||
isRemote: true,
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
botOwner,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain);
|
||||
if (remoteProfile) {
|
||||
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
|
||||
const iconUrl = typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url;
|
||||
const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url;
|
||||
const profileUrl = typeof remoteProfile.url === 'string' ? remoteProfile.url : remoteProfile.id;
|
||||
const [followersCount, followingCount, postsCount] = await Promise.all([
|
||||
fetchCollectionCount(remoteProfile.followers),
|
||||
fetchCollectionCount(remoteProfile.following),
|
||||
fetchCollectionCount(remoteProfile.outbox),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`,
|
||||
handle: `${remoteProfile.preferredUsername || remoteHandle}@${remoteDomain}`,
|
||||
displayName,
|
||||
bio: sanitizeText(remoteProfile.summary),
|
||||
avatarUrl: iconUrl ?? null,
|
||||
headerUrl: headerUrl ?? null,
|
||||
followersCount,
|
||||
followingCount,
|
||||
postsCount,
|
||||
website: profileUrl ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
isRemote: true,
|
||||
profileUrl: profileUrl ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Non-swarm nodes are no longer supported
|
||||
return NextResponse.json({ error: 'User not found. Only Synapsis swarm nodes are supported.' }, { status: 404 });
|
||||
}
|
||||
// Only return 404 if this wasn't a remote placeholder we were trying to refresh
|
||||
if (!isRemotePlaceholder) {
|
||||
@@ -169,20 +79,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if ActivityPub request
|
||||
const accept = request.headers.get('accept') || '';
|
||||
if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const actor = userToActor(user, nodeDomain);
|
||||
return NextResponse.json(actor, {
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return user profile (without sensitive data)
|
||||
// Include bot info if this is a bot account
|
||||
const userResponse: Record<string, unknown> = {
|
||||
id: user.id,
|
||||
handle: user.handle,
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/* Chat Page Styles */
|
||||
|
||||
.chat-page {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.chat-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.chat-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-sidebar-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.chat-search svg {
|
||||
color: var(--foreground-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-search input::placeholder {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
/* Conversations List */
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--background-tertiary);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.conversation-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.conversation-avatar span {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conversation-handle {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main Chat Area */
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-header-avatar span {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header-info h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 2px 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-header-info p {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message.sent {
|
||||
flex-direction: row-reverse;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-avatar span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message.sent .message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--border);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.sent .message-bubble {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.encrypted-indicator {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.encrypted-preview {
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--foreground-tertiary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.delivery-status {
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
/* Chat Input */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-input input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input input::placeholder {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.chat-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.chat-empty-state svg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.chat-empty-state h2,
|
||||
.chat-empty-state h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-empty-state p {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-tertiary);
|
||||
margin: 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-secondary);
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.modal-content form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-content input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-content input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--background-tertiary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--background-tertiary);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
grid-template-columns: 1fr;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
+87
-388
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { MessageCircle, Send, ArrowLeft } from 'lucide-react';
|
||||
import { MessageCircle, Send, ArrowLeft, Search, Plus } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import './chat.css';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
@@ -39,6 +40,7 @@ export default function ChatPage() {
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,7 +89,6 @@ export default function ChatPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ conversationId }),
|
||||
});
|
||||
// Update unread count locally
|
||||
setConversations(prev =>
|
||||
prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c)
|
||||
);
|
||||
@@ -115,7 +116,7 @@ export default function ChatPage() {
|
||||
const data = await res.json();
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
setNewMessage('');
|
||||
loadConversations(); // Refresh to update last message
|
||||
loadConversations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
@@ -151,12 +152,18 @@ export default function ChatPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConversations = conversations.filter(conv =>
|
||||
conv.participant2.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<div style={{ padding: '48px', textAlign: 'center' }}>
|
||||
<MessageCircle size={48} style={{ margin: '0 auto 16px', opacity: 0.5 }} />
|
||||
<p>Sign in to use Swarm Chat</p>
|
||||
<div className="chat-empty-state">
|
||||
<MessageCircle size={64} />
|
||||
<h2>Sign in to use Swarm Chat</h2>
|
||||
<p>End-to-end encrypted messaging across the Synapsis network</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -165,34 +172,43 @@ export default function ChatPage() {
|
||||
return (
|
||||
<div className="chat-page">
|
||||
<div className="chat-container">
|
||||
{/* Conversations List */}
|
||||
{/* Sidebar */}
|
||||
<div className={`chat-sidebar ${selectedConversation ? 'mobile-hidden' : ''}`}>
|
||||
<div className="chat-sidebar-header">
|
||||
<h2>Swarm Chat</h2>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => setShowNewChat(true)}
|
||||
style={{ padding: '8px 16px', fontSize: '14px' }}
|
||||
>
|
||||
New Chat
|
||||
<h1>Messages</h1>
|
||||
<button className="btn-icon" onClick={() => setShowNewChat(true)} title="New chat">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chat-search">
|
||||
<Search size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
|
||||
Loading...
|
||||
<div className="chat-loading">
|
||||
<div className="spinner" />
|
||||
<p>Loading conversations...</p>
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
|
||||
<MessageCircle size={32} style={{ margin: '0 auto 12px' }} />
|
||||
<p>No conversations yet</p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px' }}>
|
||||
Start a chat with anyone on the swarm
|
||||
</p>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="chat-empty-state">
|
||||
<MessageCircle size={48} />
|
||||
<h3>No conversations yet</h3>
|
||||
<p>Start a chat with anyone on the swarm</p>
|
||||
<button className="btn-primary" onClick={() => setShowNewChat(true)}>
|
||||
<Plus size={18} />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversations-list">
|
||||
{conversations.map((conv) => (
|
||||
{filteredConversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
className={`conversation-item ${selectedConversation?.id === conv.id ? 'active' : ''}`}
|
||||
@@ -202,19 +218,17 @@ export default function ChatPage() {
|
||||
{conv.participant2.avatarUrl ? (
|
||||
<img src={conv.participant2.avatarUrl} alt={conv.participant2.displayName} />
|
||||
) : (
|
||||
conv.participant2.displayName.charAt(0).toUpperCase()
|
||||
<span>{conv.participant2.displayName.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="conversation-info">
|
||||
<div className="conversation-name">
|
||||
{conv.participant2.displayName}
|
||||
<div className="conversation-content">
|
||||
<div className="conversation-header">
|
||||
<span className="conversation-name">{conv.participant2.displayName}</span>
|
||||
{conv.unreadCount > 0 && (
|
||||
<span className="unread-badge">{conv.unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="conversation-handle">
|
||||
{formatFullHandle(conv.participant2.handle)}
|
||||
</div>
|
||||
<div className="conversation-handle">{formatFullHandle(conv.participant2.handle)}</div>
|
||||
<div className="conversation-preview">{conv.lastMessagePreview}</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -223,60 +237,48 @@ export default function ChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages View */}
|
||||
{/* Main Chat Area */}
|
||||
<div className={`chat-main ${!selectedConversation ? 'mobile-hidden' : ''}`}>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
<div className="chat-header">
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => setSelectedConversation(null)}
|
||||
>
|
||||
<button className="btn-icon back-button" onClick={() => setSelectedConversation(null)}>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="chat-header-avatar">
|
||||
{selectedConversation.participant2.avatarUrl ? (
|
||||
<img src={selectedConversation.participant2.avatarUrl} alt="" />
|
||||
) : (
|
||||
selectedConversation.participant2.displayName.charAt(0).toUpperCase()
|
||||
<span>{selectedConversation.participant2.displayName.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-header-info">
|
||||
<div className="chat-header-name">
|
||||
{selectedConversation.participant2.displayName}
|
||||
</div>
|
||||
<div className="chat-header-handle">
|
||||
{formatFullHandle(selectedConversation.participant2.handle)}
|
||||
</div>
|
||||
<h2>{selectedConversation.participant2.displayName}</h2>
|
||||
<p>{formatFullHandle(selectedConversation.participant2.handle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`message ${msg.isSentByMe ? 'sent' : 'received'}`}
|
||||
>
|
||||
<div key={msg.id} className={`message ${msg.isSentByMe ? 'sent' : 'received'}`}>
|
||||
{!msg.isSentByMe && (
|
||||
<div className="message-avatar">
|
||||
{msg.senderAvatarUrl ? (
|
||||
<img src={msg.senderAvatarUrl} alt="" />
|
||||
) : (
|
||||
(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()
|
||||
<span>{(msg.senderDisplayName || msg.senderHandle).charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-content">
|
||||
<div className="message-bubble">
|
||||
{/* Note: In production, decrypt client-side */}
|
||||
<div style={{ opacity: 0.5, fontSize: '12px' }}>
|
||||
[Encrypted: {msg.encryptedContent.substring(0, 20)}...]
|
||||
</div>
|
||||
<div className="encrypted-indicator">🔒 Encrypted</div>
|
||||
<div className="encrypted-preview">{msg.encryptedContent.substring(0, 40)}...</div>
|
||||
</div>
|
||||
<div className="message-meta">
|
||||
{new Date(msg.createdAt).toLocaleTimeString()}
|
||||
<span>{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
{msg.isSentByMe && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7 }}>
|
||||
<span className="delivery-status">
|
||||
{msg.readAt ? '✓✓' : msg.deliveredAt ? '✓' : '○'}
|
||||
</span>
|
||||
)}
|
||||
@@ -295,350 +297,47 @@ export default function ChatPage() {
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button type="submit" disabled={sending || !newMessage.trim()}>
|
||||
<button type="submit" className="btn-icon send-button" disabled={sending || !newMessage.trim()}>
|
||||
<Send size={20} />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="chat-empty">
|
||||
<MessageCircle size={64} style={{ opacity: 0.3, marginBottom: '16px' }} />
|
||||
<p>Select a conversation to start chatting</p>
|
||||
<div className="chat-empty-state">
|
||||
<MessageCircle size={64} />
|
||||
<h2>Select a conversation</h2>
|
||||
<p>Choose a conversation from the sidebar to start chatting</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Chat Modal */}
|
||||
{showNewChat && (
|
||||
<div className="modal-overlay" onClick={() => setShowNewChat(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Start New Chat</h3>
|
||||
<form onSubmit={startNewChat}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter handle (e.g., user@node.domain)"
|
||||
value={newChatHandle}
|
||||
onChange={(e) => setNewChatHandle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowNewChat(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={sending || !newChatHandle.trim()}
|
||||
>
|
||||
Start Chat
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.chat-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-name {
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-handle {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-header-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-header-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-header-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-header-handle {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message.sent {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message.sent .message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--background-secondary);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.sent .message-bubble {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 11px;
|
||||
color: var(--foreground-tertiary);
|
||||
margin-top: 4px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
{/* New Chat Modal */}
|
||||
{showNewChat && (
|
||||
<div className="modal-overlay" onClick={() => setShowNewChat(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Start New Chat</h2>
|
||||
<p>Enter the handle of the person you want to message</p>
|
||||
<form onSubmit={startNewChat}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="user@node.domain or localuser"
|
||||
value={newChatHandle}
|
||||
onChange={(e) => setNewChatHandle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowNewChat(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={sending || !newChatHandle.trim()}>
|
||||
Start Chat
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+21
-5
@@ -259,6 +259,11 @@ a.btn-primary:visited {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.layout.hide-right-sidebar .main {
|
||||
flex: 0 1 920px !important;
|
||||
max-width: 920px !important;
|
||||
}
|
||||
|
||||
.aside {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
@@ -276,12 +281,12 @@ a.btn-primary:visited {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.thread-container > .post {
|
||||
.thread-container>.post {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.thread-container > .post .post-actions {
|
||||
.thread-container>.post .post-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -309,7 +314,7 @@ a.btn-primary:visited {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.post.thread-parent + .post {
|
||||
.post.thread-parent+.post {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -584,11 +589,11 @@ a.btn-primary:visited {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compose-nsfw-toggle input:checked + svg {
|
||||
.compose-nsfw-toggle input:checked+svg {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.compose-nsfw-toggle input:checked ~ span {
|
||||
.compose-nsfw-toggle input:checked~span {
|
||||
color: var(--warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1809,3 +1814,14 @@ a.btn-primary:visited {
|
||||
margin: 0 0.05em 0 0.1em;
|
||||
vertical-align: -0.5em;
|
||||
}
|
||||
|
||||
/* Hide right sidebar layout adjustment */
|
||||
.layout.hide-right-sidebar {
|
||||
max-width: 1600px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.layout.hide-right-sidebar .main {
|
||||
max-width: none;
|
||||
border-right: none;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* ActivityPub Shared Inbox Endpoint
|
||||
*
|
||||
* Receives incoming activities from remote servers.
|
||||
* This is used for batch delivery and public activities.
|
||||
* POST /inbox
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse the activity
|
||||
let activity: IncomingActivity;
|
||||
try {
|
||||
activity = await request.json();
|
||||
} catch (e) {
|
||||
console.error('[SharedInbox] Invalid JSON body:', e);
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[SharedInbox] Received ${activity.type} activity from ${activity.actor}`);
|
||||
|
||||
if (!db) {
|
||||
console.error('[SharedInbox] Database not available');
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Extract headers for signature verification
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
// Get the request path
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// For shared inbox, we need to determine the target user from the activity object
|
||||
let targetUser = null;
|
||||
|
||||
// Try to extract target from the activity object
|
||||
const objectTarget = typeof activity.object === 'string'
|
||||
? activity.object
|
||||
: (activity.object as { id?: string })?.id;
|
||||
|
||||
if (objectTarget) {
|
||||
// Extract handle from target URL (e.g., https://domain.com/users/handle)
|
||||
const handleMatch = objectTarget.match(/\/users\/([^\/]+)/);
|
||||
if (handleMatch) {
|
||||
const handle = handleMatch[1].toLowerCase();
|
||||
targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, handle),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process the activity
|
||||
const result = await processIncomingActivity(activity, headers, path, targetUser ?? null);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[SharedInbox] Activity processing failed: ${result.error}`);
|
||||
// Don't return error for shared inbox - just log and accept
|
||||
// This is because shared inbox receives activities for multiple users
|
||||
}
|
||||
|
||||
// Return 202 Accepted (standard for ActivityPub)
|
||||
return new NextResponse(null, { status: 202 });
|
||||
} catch (error) {
|
||||
console.error('[SharedInbox] Error processing activity:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityPub requires the inbox to be discoverable
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
summary: 'Shared inbox',
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/activity+json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, users, posts } from '@/db';
|
||||
import { count } from 'drizzle-orm';
|
||||
|
||||
export async function GET() {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
|
||||
const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
|
||||
|
||||
// Get stats
|
||||
const [userCount] = await db.select({ count: count() }).from(users);
|
||||
const [postCount] = await db.select({ count: count() }).from(posts);
|
||||
|
||||
return NextResponse.json({
|
||||
version: '2.1',
|
||||
software: {
|
||||
name: 'synapsis',
|
||||
version: '0.1.0',
|
||||
homepage: 'https://github.com/synapsis',
|
||||
},
|
||||
protocols: ['activitypub'],
|
||||
usage: {
|
||||
users: {
|
||||
total: userCount?.count || 0,
|
||||
activeMonth: userCount?.count || 0,
|
||||
activeHalfyear: userCount?.count || 0,
|
||||
},
|
||||
localPosts: postCount?.count || 0,
|
||||
},
|
||||
openRegistrations: true,
|
||||
metadata: {
|
||||
nodeName,
|
||||
nodeDescription,
|
||||
},
|
||||
});
|
||||
}
|
||||
+14
-2
@@ -211,8 +211,20 @@ export default function Home() {
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No posts yet</p>
|
||||
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
|
||||
{feedType === 'curated' ? (
|
||||
<>
|
||||
<p>No posts from the swarm yet</p>
|
||||
<p style={{ fontSize: '13px', marginTop: '8px' }}>
|
||||
The curated feed shows posts from other nodes in the Synapsis network.
|
||||
Check back later as nodes are discovered, or switch to Latest to see posts from people you follow.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>No posts yet</p>
|
||||
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user