From 08d507c1965f734e7b3438fb33c356007f76251d Mon Sep 17 00:00:00 2001 From: Christomatt Date: Mon, 26 Jan 2026 13:16:59 +0100 Subject: [PATCH] feat(swarm): Add remote like tracking and federated like status queries - Add remoteLikes table to track likes from federated nodes on local posts - Implement deduplication logic in like/unlike endpoints to prevent duplicate remote likes - Add GET endpoint at /api/swarm/posts/[id]/likes to query like status from origin nodes - Enhance POST feed endpoint to separate local and swarm posts for interaction checks - Query origin nodes in real-time to populate isLiked flag for federated posts in feeds - Add error handling and 3-second timeout for remote like status queries - Update schema to support remote like tracking with actor handle and domain --- src/app/api/posts/route.ts | 73 ++++++++++--- src/app/api/swarm/interactions/like/route.ts | 24 ++++- .../api/swarm/interactions/unlike/route.ts | 23 ++-- src/app/api/swarm/posts/[id]/likes/route.ts | 100 ++++++++++++++++++ src/db/schema.ts | 32 ++++++ 5 files changed, 232 insertions(+), 20 deletions(-) create mode 100644 src/app/api/swarm/posts/[id]/likes/route.ts diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 5a2e57d..53e74b2 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -686,31 +686,80 @@ export async function GET(request: Request) { if (session?.user && feedPosts && feedPosts.length > 0) { const viewer = session.user; - const postIds = feedPosts.map((p: { id: string }) => p.id).filter(Boolean); + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + + // Separate local and swarm posts + const localPostIds: string[] = []; + const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = []; + + for (const p of feedPosts as Array<{ id: string }>) { + if (p.id.startsWith('swarm:')) { + const parts = p.id.split(':'); + if (parts.length >= 3) { + swarmPosts.push({ + id: p.id, + domain: parts[1], + originalId: parts[2], + }); + } + } else { + localPostIds.push(p.id); + } + } - if (postIds.length > 0) { + // Check local likes + const likedPostIds = new Set(); + const repostedPostIds = new Set(); + + if (localPostIds.length > 0) { const viewerLikes = await db.query.likes.findMany({ where: and( eq(likes.userId, viewer.id), - inArray(likes.postId, postIds) + inArray(likes.postId, localPostIds) ), }); - const likedPostIds = new Set(viewerLikes.map(l => l.postId)); + viewerLikes.forEach(l => likedPostIds.add(l.postId)); const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, postIds) + inArray(posts.repostOfId, localPostIds) ), }); - const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); - - feedPosts = feedPosts.map((p: { id: string }) => ({ - ...p, - isLiked: likedPostIds.has(p.id), - isReposted: repostedPostIds.has(p.id), - })) as any; + viewerReposts.forEach(r => { if (r.repostOfId) repostedPostIds.add(r.repostOfId); }); } + + // Check swarm likes in real-time (query origin nodes) + if (swarmPosts.length > 0) { + const checkPromises = swarmPosts.map(async (sp) => { + try { + const protocol = sp.domain.includes('localhost') ? 'http' : 'https'; + const url = `${protocol}://${sp.domain}/api/swarm/posts/${sp.originalId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`; + + const res = await fetch(url, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(3000), + }); + + if (res.ok) { + const data = await res.json(); + if (data.isLiked) { + likedPostIds.add(sp.id); + } + } + } catch (err) { + // Timeout or error - just skip + } + }); + + await Promise.all(checkPromises); + } + + feedPosts = feedPosts.map((p: { id: string }) => ({ + ...p, + isLiked: likedPostIds.has(p.id), + isReposted: repostedPostIds.has(p.id), + })) as any; } } catch (error) { console.error('Error populating interaction flags:', error); diff --git a/src/app/api/swarm/interactions/like/route.ts b/src/app/api/swarm/interactions/like/route.ts index c39980f..bf2ea9f 100644 --- a/src/app/api/swarm/interactions/like/route.ts +++ b/src/app/api/swarm/interactions/like/route.ts @@ -5,8 +5,8 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts, users, notifications } from '@/db'; -import { eq } from 'drizzle-orm'; +import { db, posts, users, notifications, remoteLikes } from '@/db'; +import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; const swarmLikeSchema = z.object({ @@ -49,6 +49,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } + // Check if already liked by this remote user + const existingLike = await db.query.remoteLikes.findFirst({ + where: and( + eq(remoteLikes.postId, data.postId), + eq(remoteLikes.actorHandle, data.like.actorHandle), + eq(remoteLikes.actorNodeDomain, data.like.actorNodeDomain) + ), + }); + + if (existingLike) { + return NextResponse.json({ success: true, message: 'Already liked' }); + } + + // Track the remote like + await db.insert(remoteLikes).values({ + postId: data.postId, + actorHandle: data.like.actorHandle, + actorNodeDomain: data.like.actorNodeDomain, + }); + // Increment like count await db.update(posts) .set({ likesCount: post.likesCount + 1 }) diff --git a/src/app/api/swarm/interactions/unlike/route.ts b/src/app/api/swarm/interactions/unlike/route.ts index c5c99d8..559db73 100644 --- a/src/app/api/swarm/interactions/unlike/route.ts +++ b/src/app/api/swarm/interactions/unlike/route.ts @@ -5,8 +5,8 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts } from '@/db'; -import { eq } from 'drizzle-orm'; +import { db, posts, remoteLikes } from '@/db'; +import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; const swarmUnlikeSchema = z.object({ @@ -42,10 +42,21 @@ export async function POST(request: NextRequest) { 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)); + // Remove the remote like record + const deleted = await db.delete(remoteLikes) + .where(and( + eq(remoteLikes.postId, data.postId), + eq(remoteLikes.actorHandle, data.unlike.actorHandle), + eq(remoteLikes.actorNodeDomain, data.unlike.actorNodeDomain) + )) + .returning(); + + // Only decrement if we actually had a like record + if (deleted.length > 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}`); diff --git a/src/app/api/swarm/posts/[id]/likes/route.ts b/src/app/api/swarm/posts/[id]/likes/route.ts new file mode 100644 index 0000000..4d64832 --- /dev/null +++ b/src/app/api/swarm/posts/[id]/likes/route.ts @@ -0,0 +1,100 @@ +/** + * Swarm Post Likes Endpoint + * + * GET: Check who has liked a post (for real-time like status) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, likes, users, remoteLikes } from '@/db'; +import { eq, and } from 'drizzle-orm'; + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/swarm/posts/[id]/likes + * + * Returns like information for a post. + * Query params: + * - checkHandle: Check if a specific handle has liked this post + * - checkDomain: The domain of the user to check (required with checkHandle for remote users) + */ +export async function GET(request: NextRequest, context: RouteContext) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const { id: postId } = await context.params; + const { searchParams } = new URL(request.url); + const checkHandle = searchParams.get('checkHandle'); + const checkDomain = searchParams.get('checkDomain'); + + // Find the post + const post = await db.query.posts.findFirst({ + where: eq(posts.id, postId), + }); + + if (!post || post.isRemoved) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // If checking a specific handle + if (checkHandle) { + // If domain is provided, check remote likes + if (checkDomain) { + const remoteLike = await db.query.remoteLikes.findFirst({ + where: and( + eq(remoteLikes.postId, postId), + eq(remoteLikes.actorHandle, checkHandle), + eq(remoteLikes.actorNodeDomain, checkDomain) + ), + }); + + return NextResponse.json({ + postId, + likesCount: post.likesCount, + isLiked: !!remoteLike, + checkedHandle: checkHandle, + checkedDomain: checkDomain, + }); + } + + // No domain = local user + const localUser = await db.query.users.findFirst({ + where: eq(users.handle, checkHandle), + }); + + if (localUser) { + const liked = await db.query.likes.findFirst({ + where: and( + eq(likes.postId, postId), + eq(likes.userId, localUser.id) + ), + }); + + return NextResponse.json({ + postId, + likesCount: post.likesCount, + isLiked: !!liked, + checkedHandle: checkHandle, + }); + } + + return NextResponse.json({ + postId, + likesCount: post.likesCount, + isLiked: false, + checkedHandle: checkHandle, + }); + } + + // Return general like info + return NextResponse.json({ + postId, + likesCount: post.likesCount, + }); + } catch (error) { + console.error('[Swarm] Post likes error:', error); + return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 }); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index fea014c..c5a1b01 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -318,6 +318,38 @@ export const likesRelations = relations(likes, ({ one }) => ({ }), })); +// ============================================ +// REMOTE LIKES (likes from federated users on local posts) +// ============================================ + +export const remoteLikes = pgTable('remote_likes', { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + actorHandle: text('actor_handle').notNull(), // e.g., "user" + actorNodeDomain: text('actor_node_domain').notNull(), // e.g., "other.node" + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('remote_likes_post_idx').on(table.postId), + index('remote_likes_actor_idx').on(table.actorHandle, table.actorNodeDomain), + uniqueIndex('remote_likes_unique').on(table.postId, table.actorHandle, table.actorNodeDomain), +]); + +// ============================================ +// REMOTE REPOSTS (reposts from federated users on local posts) +// ============================================ + +export const remoteReposts = pgTable('remote_reposts', { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }), + actorHandle: text('actor_handle').notNull(), + actorNodeDomain: text('actor_node_domain').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('remote_reposts_post_idx').on(table.postId), + index('remote_reposts_actor_idx').on(table.actorHandle, table.actorNodeDomain), + uniqueIndex('remote_reposts_unique').on(table.postId, table.actorHandle, table.actorNodeDomain), +]); + // ============================================ // NOTIFICATIONS // ============================================