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
This commit is contained in:
+61
-12
@@ -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<string>();
|
||||
const repostedPostIds = new Set<string>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user