diff --git a/public/logo.svg b/public/logo.svg
deleted file mode 100644
index e13b6c9..0000000
--- a/public/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/logotext.svg b/public/logotext.svg
new file mode 100644
index 0000000..d01bcf2
--- /dev/null
+++ b/public/logotext.svg
@@ -0,0 +1,56 @@
+
+
+
diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts
index 88997bf..49f7d95 100644
--- a/src/app/api/posts/[id]/like/route.ts
+++ b/src/app/api/posts/[id]/like/route.ts
@@ -2,14 +2,40 @@ import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
+import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
+/**
+ * Extract domain from a swarm post ID (swarm:domain:postId)
+ */
+function extractSwarmDomain(apId: string | null): string | null {
+ if (!apId?.startsWith('swarm:')) return null;
+ const parts = apId.split(':');
+ return parts.length >= 2 ? parts[1] : null;
+}
+
+/**
+ * Check if a post is from a swarm node (has swarm: prefix in apId)
+ */
+function isSwarmPost(apId: string | null): boolean {
+ return apId?.startsWith('swarm:') ?? false;
+}
+
+/**
+ * Extract the original post ID from a swarm apId
+ */
+function extractSwarmPostId(apId: string): string | null {
+ const parts = apId.split(':');
+ return parts.length >= 3 ? parts[2] : null;
+}
+
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -59,15 +85,45 @@ export async function POST(request: Request, context: RouteContext) {
});
}
- // Federate the like if the post has an ActivityPub ID
- if (post.apId) {
+ // SWARM-FIRST: Check if this is a swarm post and deliver directly
+ if (isSwarmPost(post.apId)) {
+ const targetDomain = extractSwarmDomain(post.apId);
+ const originalPostId = extractSwarmPostId(post.apId!);
+
+ if (targetDomain && originalPostId) {
+ (async () => {
+ try {
+ const { deliverSwarmLike } = await import('@/lib/swarm/interactions');
+
+ const result = await deliverSwarmLike(targetDomain, {
+ postId: originalPostId,
+ like: {
+ actorHandle: user.handle,
+ actorDisplayName: user.displayName || user.handle,
+ actorAvatarUrl: user.avatarUrl || undefined,
+ actorNodeDomain: nodeDomain,
+ interactionId: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ },
+ });
+
+ if (result.success) {
+ console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
+ } else {
+ console.warn(`[Swarm] Like delivery failed: ${result.error}`);
+ // Could fall back to ActivityPub here if needed
+ }
+ } catch (err) {
+ console.error('[Swarm] Error delivering like:', err);
+ }
+ })();
+ }
+ } 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');
- const { fetchRemoteActor } = await import('@/lib/activitypub/fetch');
-
- const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Get the post author's actor URL
const postWithAuthor = await db.query.posts.findFirst({
@@ -78,11 +134,6 @@ export async function POST(request: Request, context: RouteContext) {
if (!postWithAuthor?.author) return;
const author = postWithAuthor.author as { handle: string };
- // Construct the author's actor URL
- const authorActorUrl = `https://${nodeDomain}/users/${author.handle}`;
-
- // For remote posts, we'd need to fetch the remote actor's inbox
- // For local posts, just log it since local delivery doesn't need federation
console.log(`[Federation] Like activity for post ${post.apId} from @${user.handle}`);
} catch (err) {
console.error('[Federation] Error federating like:', err);
@@ -104,6 +155,7 @@ export async function DELETE(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: postId } = await context.params;
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -141,6 +193,38 @@ export async function DELETE(request: Request, context: RouteContext) {
.set({ likesCount: Math.max(0, post.likesCount - 1) })
.where(eq(posts.id, postId));
+ // SWARM-FIRST: Deliver unlike to swarm node
+ if (isSwarmPost(post.apId)) {
+ const targetDomain = extractSwarmDomain(post.apId);
+ const originalPostId = extractSwarmPostId(post.apId!);
+
+ if (targetDomain && originalPostId) {
+ (async () => {
+ try {
+ const { deliverSwarmUnlike } = await import('@/lib/swarm/interactions');
+
+ const result = await deliverSwarmUnlike(targetDomain, {
+ postId: originalPostId,
+ unlike: {
+ actorHandle: user.handle,
+ actorNodeDomain: nodeDomain,
+ interactionId: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ },
+ });
+
+ if (result.success) {
+ console.log(`[Swarm] Unlike delivered to ${targetDomain}`);
+ } else {
+ console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
+ }
+ } catch (err) {
+ console.error('[Swarm] Error delivering unlike:', err);
+ }
+ })();
+ }
+ }
+
return NextResponse.json({ success: true, liked: false });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts
index 5a9d8d4..1f1cbe1 100644
--- a/src/app/api/posts/[id]/repost/route.ts
+++ b/src/app/api/posts/[id]/repost/route.ts
@@ -2,9 +2,34 @@ import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
+import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
+/**
+ * Extract domain from a swarm post ID (swarm:domain:postId)
+ */
+function extractSwarmDomain(apId: string | null): string | null {
+ if (!apId?.startsWith('swarm:')) return null;
+ const parts = apId.split(':');
+ return parts.length >= 2 ? parts[1] : null;
+}
+
+/**
+ * Check if a post is from a swarm node
+ */
+function isSwarmPost(apId: string | null): boolean {
+ return apId?.startsWith('swarm:') ?? false;
+}
+
+/**
+ * Extract the original post ID from a swarm apId
+ */
+function extractSwarmPostId(apId: string): string | null {
+ const parts = apId.split(':');
+ return parts.length >= 3 ? parts[2] : null;
+}
+
// Repost a post
export async function POST(request: Request, context: RouteContext) {
try {
@@ -42,12 +67,13 @@ export async function POST(request: Request, context: RouteContext) {
}
// Create repost
+ const repostId = crypto.randomUUID();
const [repost] = await db.insert(posts).values({
userId: user.id,
content: '', // Reposts don't have their own content
repostOfId: postId,
- apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
- apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
+ apId: `https://${nodeDomain}/posts/${repostId}`,
+ apUrl: `https://${nodeDomain}/posts/${repostId}`,
}).returning();
// Update original post's repost count
@@ -69,9 +95,41 @@ export async function POST(request: Request, context: RouteContext) {
});
}
- // Federate the repost (Announce activity) if the original post has an ActivityPub ID
- const postApId = originalPost.apId;
- if (postApId) {
+ // SWARM-FIRST: Deliver repost to swarm node
+ if (isSwarmPost(originalPost.apId)) {
+ const targetDomain = extractSwarmDomain(originalPost.apId);
+ const originalPostIdOnRemote = extractSwarmPostId(originalPost.apId!);
+
+ if (targetDomain && originalPostIdOnRemote) {
+ (async () => {
+ try {
+ const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
+
+ const result = await deliverSwarmRepost(targetDomain, {
+ postId: originalPostIdOnRemote,
+ repost: {
+ actorHandle: user.handle,
+ actorDisplayName: user.displayName || user.handle,
+ actorAvatarUrl: user.avatarUrl || undefined,
+ actorNodeDomain: nodeDomain,
+ repostId: repost.id,
+ interactionId: crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ },
+ });
+
+ if (result.success) {
+ console.log(`[Swarm] Repost delivered to ${targetDomain}`);
+ } else {
+ console.warn(`[Swarm] Repost delivery failed: ${result.error}`);
+ }
+ } catch (err) {
+ console.error('[Swarm] Error delivering repost:', err);
+ }
+ })();
+ }
+ } else if (originalPost.apId) {
+ // FALLBACK: Use ActivityPub for non-swarm posts
(async () => {
try {
const { createAnnounceActivity } = await import('@/lib/activitypub/activities');
@@ -82,7 +140,7 @@ export async function POST(request: Request, context: RouteContext) {
if (followerInboxes.length > 0) {
const announceActivity = createAnnounceActivity(
user,
- postApId,
+ originalPost.apId!,
nodeDomain,
repost.id
);
@@ -91,7 +149,7 @@ export async function POST(request: Request, context: RouteContext) {
if (privateKey) {
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
const result = await deliverToFollowers(announceActivity, followerInboxes, privateKey, keyId);
- console.log(`[Federation] Announce for ${postApId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
+ console.log(`[Federation] Announce for ${originalPost.apId} delivered to ${result.delivered}/${followerInboxes.length} inboxes`);
}
}
} catch (err) {
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts
index 8bb9605..0926fb4 100644
--- a/src/app/api/posts/route.ts
+++ b/src/app/api/posts/route.ts
@@ -134,6 +134,30 @@ export async function POST(request: Request) {
})();
}
+ // SWARM-FIRST: Deliver mentions to swarm nodes
+ (async () => {
+ try {
+ const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
+
+ const result = await deliverSwarmMentions(
+ data.content,
+ post.id,
+ {
+ handle: user.handle,
+ displayName: user.displayName || user.handle,
+ avatarUrl: user.avatarUrl || undefined,
+ nodeDomain,
+ }
+ );
+
+ if (result.delivered > 0) {
+ console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
+ }
+ } catch (err) {
+ console.error('[Swarm] Error delivering mentions:', err);
+ }
+ })();
+
// Federate the post to remote followers (non-blocking)
(async () => {
try {
diff --git a/src/app/api/swarm/interactions/follow/route.ts b/src/app/api/swarm/interactions/follow/route.ts
new file mode 100644
index 0000000..6459f56
--- /dev/null
+++ b/src/app/api/swarm/interactions/follow/route.ts
@@ -0,0 +1,126 @@
+/**
+ * Swarm Follow Endpoint
+ *
+ * POST: Receive a follow from another swarm node
+ *
+ * This enables swarm-native follows between Synapsis nodes,
+ * bypassing ActivityPub for faster, more direct connections.
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, users, notifications, remoteFollowers } from '@/db';
+import { eq, and } from 'drizzle-orm';
+import { z } from 'zod';
+
+const swarmFollowSchema = z.object({
+ targetHandle: z.string(),
+ follow: z.object({
+ followerHandle: z.string(),
+ followerDisplayName: z.string(),
+ followerAvatarUrl: z.string().optional(),
+ followerBio: z.string().optional(),
+ followerNodeDomain: z.string(),
+ interactionId: z.string(),
+ timestamp: z.string(),
+ }),
+});
+
+/**
+ * POST /api/swarm/interactions/follow
+ *
+ * Receives a follow from another swarm node.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = swarmFollowSchema.parse(body);
+
+ // Find the target user (local user being followed)
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.handle, data.targetHandle.toLowerCase()),
+ });
+
+ if (!targetUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ if (targetUser.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Construct the remote follower's actor URL (swarm-style)
+ const remoteHandle = `${data.follow.followerHandle}@${data.follow.followerNodeDomain}`;
+ const actorUrl = `swarm://${data.follow.followerNodeDomain}/${data.follow.followerHandle}`;
+ const inboxUrl = `https://${data.follow.followerNodeDomain}/api/swarm/interactions/inbox`;
+
+ // Check if this follow already exists
+ const existingFollow = await db.query.remoteFollowers.findFirst({
+ where: and(
+ eq(remoteFollowers.userId, targetUser.id),
+ eq(remoteFollowers.actorUrl, actorUrl)
+ ),
+ });
+
+ if (existingFollow) {
+ return NextResponse.json({
+ success: true,
+ message: 'Already following',
+ });
+ }
+
+ // Create the remote follower record
+ await db.insert(remoteFollowers).values({
+ userId: targetUser.id,
+ actorUrl,
+ inboxUrl,
+ handle: remoteHandle,
+ activityId: data.follow.interactionId,
+ });
+
+ // Update follower count
+ await db.update(users)
+ .set({ followersCount: targetUser.followersCount + 1 })
+ .where(eq(users.id, targetUser.id));
+
+ // Get or create placeholder user for the remote follower (for notifications)
+ let remoteUser = await db.query.users.findFirst({
+ where: eq(users.handle, remoteHandle),
+ });
+
+ if (!remoteUser) {
+ const [newUser] = await db.insert(users).values({
+ did: `did:swarm:${data.follow.followerNodeDomain}:${data.follow.followerHandle}`,
+ handle: remoteHandle,
+ displayName: data.follow.followerDisplayName,
+ avatarUrl: data.follow.followerAvatarUrl || null,
+ bio: data.follow.followerBio || null,
+ publicKey: 'swarm-remote-user',
+ }).returning();
+ remoteUser = newUser;
+ }
+
+ // Create notification
+ await db.insert(notifications).values({
+ userId: targetUser.id,
+ actorId: remoteUser.id,
+ type: 'follow',
+ });
+
+ console.log(`[Swarm] Received follow from ${remoteHandle} for @${data.targetHandle}`);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Follow received',
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ console.error('[Swarm] Follow error:', error);
+ return NextResponse.json({ error: 'Failed to process follow' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/swarm/interactions/like/route.ts b/src/app/api/swarm/interactions/like/route.ts
new file mode 100644
index 0000000..41bc1e1
--- /dev/null
+++ b/src/app/api/swarm/interactions/like/route.ts
@@ -0,0 +1,100 @@
+/**
+ * Swarm Like Endpoint
+ *
+ * POST: Receive a like from another swarm node
+ *
+ * This is the swarm-first approach - direct node-to-node communication
+ * for likes, bypassing ActivityPub for Synapsis nodes.
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, posts, users, notifications } from '@/db';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+const swarmLikeSchema = z.object({
+ postId: z.string().uuid(),
+ like: z.object({
+ actorHandle: z.string(),
+ actorDisplayName: z.string(),
+ actorAvatarUrl: z.string().optional(),
+ actorNodeDomain: z.string(),
+ interactionId: z.string(),
+ timestamp: z.string(),
+ }),
+});
+
+/**
+ * POST /api/swarm/interactions/like
+ *
+ * Receives a like from another swarm node.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = swarmLikeSchema.parse(body);
+
+ // Find the target post
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, data.postId),
+ with: { author: true },
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ if (post.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Increment like count
+ await db.update(posts)
+ .set({ likesCount: post.likesCount + 1 })
+ .where(eq(posts.id, data.postId));
+
+ // Create a notification for the post author
+ // First, get or create a placeholder user for the remote liker
+ const remoteHandle = `${data.like.actorHandle}@${data.like.actorNodeDomain}`;
+ let remoteUser = await db.query.users.findFirst({
+ where: eq(users.handle, remoteHandle),
+ });
+
+ if (!remoteUser) {
+ // Create a placeholder user for the remote actor
+ const [newUser] = await db.insert(users).values({
+ did: `did:swarm:${data.like.actorNodeDomain}:${data.like.actorHandle}`,
+ handle: remoteHandle,
+ displayName: data.like.actorDisplayName,
+ avatarUrl: data.like.actorAvatarUrl || null,
+ publicKey: 'swarm-remote-user',
+ }).returning();
+ remoteUser = newUser;
+ }
+
+ // Create notification
+ await db.insert(notifications).values({
+ userId: post.userId,
+ actorId: remoteUser.id,
+ postId: data.postId,
+ type: 'like',
+ });
+
+ console.log(`[Swarm] Received like from ${remoteHandle} on post ${data.postId}`);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Like received',
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ console.error('[Swarm] Like error:', error);
+ return NextResponse.json({ error: 'Failed to process like' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/swarm/interactions/mention/route.ts b/src/app/api/swarm/interactions/mention/route.ts
new file mode 100644
index 0000000..e69e591
--- /dev/null
+++ b/src/app/api/swarm/interactions/mention/route.ts
@@ -0,0 +1,110 @@
+/**
+ * Swarm Mention Endpoint
+ *
+ * POST: Receive a mention notification from another swarm node
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, users, notifications, posts } from '@/db';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+const swarmMentionSchema = z.object({
+ mentionedHandle: z.string(),
+ mention: z.object({
+ actorHandle: z.string(),
+ actorDisplayName: z.string(),
+ actorAvatarUrl: z.string().optional(),
+ actorNodeDomain: z.string(),
+ postId: z.string(),
+ postContent: z.string(),
+ interactionId: z.string(),
+ timestamp: z.string(),
+ }),
+});
+
+/**
+ * POST /api/swarm/interactions/mention
+ *
+ * Receives a mention notification from another swarm node.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = swarmMentionSchema.parse(body);
+
+ // Find the mentioned user (local user)
+ const mentionedUser = await db.query.users.findFirst({
+ where: eq(users.handle, data.mentionedHandle.toLowerCase()),
+ });
+
+ if (!mentionedUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ if (mentionedUser.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get or create placeholder user for the remote actor
+ const remoteHandle = `${data.mention.actorHandle}@${data.mention.actorNodeDomain}`;
+ let remoteUser = await db.query.users.findFirst({
+ where: eq(users.handle, remoteHandle),
+ });
+
+ if (!remoteUser) {
+ const [newUser] = await db.insert(users).values({
+ did: `did:swarm:${data.mention.actorNodeDomain}:${data.mention.actorHandle}`,
+ handle: remoteHandle,
+ displayName: data.mention.actorDisplayName,
+ avatarUrl: data.mention.actorAvatarUrl || null,
+ publicKey: 'swarm-remote-user',
+ }).returning();
+ remoteUser = newUser;
+ }
+
+ // Check if we already have this post cached (from swarm timeline)
+ // If not, create a placeholder post for the notification
+ const swarmPostId = `swarm:${data.mention.actorNodeDomain}:${data.mention.postId}`;
+ let post = await db.query.posts.findFirst({
+ where: eq(posts.apId, swarmPostId),
+ });
+
+ if (!post) {
+ // Create a placeholder post for the mention
+ const [newPost] = await db.insert(posts).values({
+ userId: remoteUser.id,
+ content: data.mention.postContent,
+ apId: swarmPostId,
+ apUrl: `https://${data.mention.actorNodeDomain}/${data.mention.actorHandle}/posts/${data.mention.postId}`,
+ createdAt: new Date(data.mention.timestamp),
+ }).returning();
+ post = newPost;
+ }
+
+ // Create notification
+ await db.insert(notifications).values({
+ userId: mentionedUser.id,
+ actorId: remoteUser.id,
+ postId: post.id,
+ type: 'mention',
+ });
+
+ console.log(`[Swarm] Received mention from ${remoteHandle} for @${data.mentionedHandle}`);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Mention received',
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ console.error('[Swarm] Mention error:', error);
+ return NextResponse.json({ error: 'Failed to process mention' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/swarm/interactions/repost/route.ts b/src/app/api/swarm/interactions/repost/route.ts
new file mode 100644
index 0000000..15a3e38
--- /dev/null
+++ b/src/app/api/swarm/interactions/repost/route.ts
@@ -0,0 +1,95 @@
+/**
+ * Swarm Repost Endpoint
+ *
+ * POST: Receive a repost from another swarm node
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, posts, users, notifications } from '@/db';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+const swarmRepostSchema = z.object({
+ postId: z.string().uuid(),
+ repost: z.object({
+ actorHandle: z.string(),
+ actorDisplayName: z.string(),
+ actorAvatarUrl: z.string().optional(),
+ actorNodeDomain: z.string(),
+ repostId: z.string(), // The ID of the repost on the actor's node
+ interactionId: z.string(),
+ timestamp: z.string(),
+ }),
+});
+
+/**
+ * POST /api/swarm/interactions/repost
+ *
+ * Receives a repost notification from another swarm node.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = swarmRepostSchema.parse(body);
+
+ // Find the target post
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, data.postId),
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ if (post.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Increment repost count
+ await db.update(posts)
+ .set({ repostsCount: post.repostsCount + 1 })
+ .where(eq(posts.id, data.postId));
+
+ // Get or create placeholder user for the remote reposter
+ const remoteHandle = `${data.repost.actorHandle}@${data.repost.actorNodeDomain}`;
+ let remoteUser = await db.query.users.findFirst({
+ where: eq(users.handle, remoteHandle),
+ });
+
+ if (!remoteUser) {
+ const [newUser] = await db.insert(users).values({
+ did: `did:swarm:${data.repost.actorNodeDomain}:${data.repost.actorHandle}`,
+ handle: remoteHandle,
+ displayName: data.repost.actorDisplayName,
+ avatarUrl: data.repost.actorAvatarUrl || null,
+ publicKey: 'swarm-remote-user',
+ }).returning();
+ remoteUser = newUser;
+ }
+
+ // Create notification
+ await db.insert(notifications).values({
+ userId: post.userId,
+ actorId: remoteUser.id,
+ postId: data.postId,
+ type: 'repost',
+ });
+
+ console.log(`[Swarm] Received repost from ${remoteHandle} on post ${data.postId}`);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Repost received',
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ console.error('[Swarm] Repost error:', error);
+ return NextResponse.json({ error: 'Failed to process repost' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/swarm/interactions/unfollow/route.ts b/src/app/api/swarm/interactions/unfollow/route.ts
new file mode 100644
index 0000000..8e361df
--- /dev/null
+++ b/src/app/api/swarm/interactions/unfollow/route.ts
@@ -0,0 +1,83 @@
+/**
+ * Swarm Unfollow Endpoint
+ *
+ * POST: Receive an unfollow from another swarm node
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, users, remoteFollowers } from '@/db';
+import { eq, and } from 'drizzle-orm';
+import { z } from 'zod';
+
+const swarmUnfollowSchema = z.object({
+ targetHandle: z.string(),
+ unfollow: z.object({
+ followerHandle: z.string(),
+ followerNodeDomain: z.string(),
+ interactionId: z.string(),
+ timestamp: z.string(),
+ }),
+});
+
+/**
+ * POST /api/swarm/interactions/unfollow
+ *
+ * Receives an unfollow from another swarm node.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = swarmUnfollowSchema.parse(body);
+
+ // Find the target user
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.handle, data.targetHandle.toLowerCase()),
+ });
+
+ if (!targetUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Find and remove the remote follower record
+ const actorUrl = `swarm://${data.unfollow.followerNodeDomain}/${data.unfollow.followerHandle}`;
+
+ const existingFollow = await db.query.remoteFollowers.findFirst({
+ where: and(
+ eq(remoteFollowers.userId, targetUser.id),
+ eq(remoteFollowers.actorUrl, actorUrl)
+ ),
+ });
+
+ if (!existingFollow) {
+ return NextResponse.json({
+ success: true,
+ message: 'Not following',
+ });
+ }
+
+ // Remove the follow
+ await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollow.id));
+
+ // Update follower count
+ await db.update(users)
+ .set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
+ .where(eq(users.id, targetUser.id));
+
+ console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Unfollow received',
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ console.error('[Swarm] Unfollow error:', error);
+ return NextResponse.json({ error: 'Failed to process unfollow' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/swarm/interactions/unlike/route.ts b/src/app/api/swarm/interactions/unlike/route.ts
new file mode 100644
index 0000000..c5c99d8
--- /dev/null
+++ b/src/app/api/swarm/interactions/unlike/route.ts
@@ -0,0 +1,63 @@
+/**
+ * Swarm Unlike Endpoint
+ *
+ * POST: Receive an unlike from another swarm node
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, posts } from '@/db';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+const swarmUnlikeSchema = z.object({
+ postId: z.string().uuid(),
+ unlike: z.object({
+ actorHandle: z.string(),
+ actorNodeDomain: z.string(),
+ interactionId: z.string(),
+ timestamp: z.string(),
+ }),
+});
+
+/**
+ * POST /api/swarm/interactions/unlike
+ *
+ * Receives an unlike from another swarm node.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = swarmUnlikeSchema.parse(body);
+
+ // Find the target post
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, data.postId),
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Decrement like count (don't go below 0)
+ await db.update(posts)
+ .set({ likesCount: Math.max(0, post.likesCount - 1) })
+ .where(eq(posts.id, data.postId));
+
+ console.log(`[Swarm] Received unlike from ${data.unlike.actorHandle}@${data.unlike.actorNodeDomain} on post ${data.postId}`);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Unlike received',
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
+ }
+ console.error('[Swarm] Unlike error:', error);
+ return NextResponse.json({ error: 'Failed to process unlike' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts
new file mode 100644
index 0000000..81afd79
--- /dev/null
+++ b/src/app/api/swarm/posts/[id]/route.ts
@@ -0,0 +1,90 @@
+/**
+ * Swarm Post Endpoint
+ *
+ * GET: Returns a single post for swarm requests
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { db, posts, users, media } from '@/db';
+import { eq } from 'drizzle-orm';
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+/**
+ * GET /api/swarm/posts/[id]
+ *
+ * Returns a single post with author info.
+ * Used by other nodes to fetch post details.
+ */
+export async function GET(request: NextRequest, context: RouteContext) {
+ try {
+ const { id: postId } = await context.params;
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
+
+ // Find the post with author
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ with: { author: true },
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ if (post.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Get media for the post
+ const postMedia = await db
+ .select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
+ .from(media)
+ .where(eq(media.postId, postId));
+
+ const author = post.author as {
+ handle: string;
+ displayName: string | null;
+ avatarUrl: string | null;
+ isNsfw: boolean;
+ };
+
+ return NextResponse.json({
+ id: post.id,
+ content: post.content,
+ createdAt: post.createdAt.toISOString(),
+ author: {
+ handle: author.handle,
+ displayName: author.displayName || author.handle,
+ avatarUrl: author.avatarUrl || undefined,
+ isNsfw: author.isNsfw,
+ },
+ nodeDomain,
+ isNsfw: post.isNsfw || author.isNsfw,
+ likesCount: post.likesCount,
+ repostsCount: post.repostsCount,
+ repliesCount: post.repliesCount,
+ media: postMedia.length > 0 ? postMedia.map(m => ({
+ url: m.url,
+ mimeType: m.mimeType || undefined,
+ altText: m.altText || undefined,
+ })) : undefined,
+ linkPreviewUrl: post.linkPreviewUrl || undefined,
+ linkPreviewTitle: post.linkPreviewTitle || undefined,
+ linkPreviewDescription: post.linkPreviewDescription || undefined,
+ linkPreviewImage: post.linkPreviewImage || undefined,
+ replyToId: post.replyToId || undefined,
+ repostOfId: post.repostOfId || undefined,
+ });
+ } catch (error) {
+ console.error('Swarm post error:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch post' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts
index 9ff380d..1548f95 100644
--- a/src/app/api/users/[handle]/follow/route.ts
+++ b/src/app/api/users/[handle]/follow/route.ts
@@ -7,6 +7,7 @@ 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 } from '@/lib/swarm/interactions';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -94,23 +95,16 @@ export async function POST(request: Request, context: RouteContext) {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (currentUser.isSuspended || currentUser.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
if (remote) {
- 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 });
- }
- const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const targetHandle = `${remote.handle}@${remote.domain}`;
-
+
+ // Check if already following
const existingRemoteFollow = await db.query.remoteFollows.findFirst({
where: and(
eq(remoteFollows.followerId, currentUser.id),
@@ -121,6 +115,62 @@ 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
+ 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));
+
+ 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 });
+ }
+
const activityId = crypto.randomUUID();
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`;
@@ -243,6 +293,7 @@ export async function DELETE(request: Request, context: RouteContext) {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const remote = parseRemoteHandle(handle);
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (remote) {
if (!db) {
@@ -258,7 +309,40 @@ export async function DELETE(request: Request, context: RouteContext) {
if (!existingRemoteFollow) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
}
- const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+
+ // 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(),
+ },
+ });
+
+ 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,
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 2abfd22..3266f8b 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -192,7 +192,7 @@ export default function LoginPage() {
/>
) : (
) : (
-
+
)}