From 9581fd281004c35e8946f6038cfc42d4fa27fcdf Mon Sep 17 00:00:00 2001 From: Christomatt Date: Mon, 26 Jan 2026 13:25:54 +0100 Subject: [PATCH] feat(swarm): Add real-time post fetching and reply aggregation for federated nodes - Add swarm post ID parsing (format: swarm:domain:uuid) in local post endpoint - Implement real-time fetching from origin node with 10s timeout - Transform remote post data to match local format with swarm metadata - Add reply aggregation from remote node with proper ID transformation - Implement like status checking for authenticated users on remote posts - Refactor swarm post endpoint to include reply data in response - Add media transformation with swarm-prefixed IDs for remote posts - Improve error handling with fallback to 404 for unreachable nodes - Enable seamless cross-node post viewing with federated author information --- src/app/api/posts/[id]/route.ts | 105 +++++++++++++++++++++ src/app/api/swarm/posts/[id]/route.ts | 130 ++++++++++++++------------ 2 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index c4aeae4..6b9cb7a 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -15,6 +15,111 @@ export async function GET( let mainPost: any = null; let replyPosts: any[] = []; + // Handle swarm post IDs (format: swarm:domain:uuid) + if (id.startsWith('swarm:')) { + const parts = id.split(':'); + if (parts.length >= 3) { + const originDomain = parts[1]; + const originalPostId = parts[2]; + + // Fetch from origin node in real-time + try { + const protocol = originDomain.includes('localhost') ? 'http' : 'https'; + const res = await fetch(`${protocol}://${originDomain}/api/swarm/posts/${originalPostId}`, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + + if (res.ok) { + const data = await res.json(); + + // Transform to match expected format + mainPost = { + id: id, + originalPostId: originalPostId, + content: data.post.content, + createdAt: data.post.createdAt, + likesCount: data.post.likesCount || 0, + repostsCount: data.post.repostsCount || 0, + repliesCount: data.post.repliesCount || 0, + isSwarm: true, + nodeDomain: originDomain, + author: { + id: `swarm:${originDomain}:${data.post.author.handle}`, + handle: data.post.author.handle, + displayName: data.post.author.displayName, + avatarUrl: data.post.author.avatarUrl, + isSwarm: true, + nodeDomain: originDomain, + }, + media: data.post.media?.map((m: any, idx: number) => ({ + id: `swarm:${originDomain}:${originalPostId}:media:${idx}`, + url: m.url, + altText: m.altText || null, + })) || [], + linkPreviewUrl: data.post.linkPreviewUrl, + linkPreviewTitle: data.post.linkPreviewTitle, + linkPreviewDescription: data.post.linkPreviewDescription, + linkPreviewImage: data.post.linkPreviewImage, + }; + + // Transform replies + replyPosts = (data.replies || []).map((r: any) => ({ + id: `swarm:${originDomain}:${r.id}`, + originalPostId: r.id, + content: r.content, + createdAt: r.createdAt, + likesCount: r.likesCount || 0, + repostsCount: r.repostsCount || 0, + repliesCount: r.repliesCount || 0, + isSwarm: true, + nodeDomain: originDomain, + author: { + id: `swarm:${originDomain}:${r.author.handle}`, + handle: r.author.handle, + displayName: r.author.displayName, + avatarUrl: r.author.avatarUrl, + isSwarm: true, + nodeDomain: originDomain, + }, + media: r.media?.map((m: any, idx: number) => ({ + id: `swarm:${originDomain}:${r.id}:media:${idx}`, + url: m.url, + altText: m.altText || null, + })) || [], + })); + + // Check if current user has liked this post + try { + const { requireAuth } = await import('@/lib/auth'); + const viewer = await requireAuth(); + + const likeCheckRes = await fetch( + `${protocol}://${originDomain}/api/swarm/posts/${originalPostId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`, + { signal: AbortSignal.timeout(3000) } + ); + + if (likeCheckRes.ok) { + const likeData = await likeCheckRes.json(); + mainPost.isLiked = likeData.isLiked; + } + } catch { + // Not logged in or timeout + } + + return NextResponse.json({ + post: mainPost, + replies: replyPosts, + }); + } + } catch (err) { + console.error(`[Swarm] Failed to fetch post from ${originDomain}:`, err); + } + + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + } + const post = await db.query.posts.findFirst({ where: eq(posts.id, id), with: { diff --git a/src/app/api/swarm/posts/[id]/route.ts b/src/app/api/swarm/posts/[id]/route.ts index 81afd79..5de6972 100644 --- a/src/app/api/swarm/posts/[id]/route.ts +++ b/src/app/api/swarm/posts/[id]/route.ts @@ -1,90 +1,102 @@ /** - * Swarm Post Endpoint + * Swarm Post Detail Endpoint * - * GET: Returns a single post for swarm requests + * GET: Get a post's details for other swarm nodes */ import { NextRequest, NextResponse } from 'next/server'; -import { db, posts, users, media } from '@/db'; -import { eq } from 'drizzle-orm'; +import { db, posts } from '@/db'; +import { eq, desc, and } 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. + * Returns post details including replies for swarm federation. */ 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'; + const { id: postId } = await context.params; - // Find the post with author + // Find the post const post = await db.query.posts.findFirst({ where: eq(posts.id, postId), - with: { author: true }, + with: { + author: true, + media: true, + }, }); - if (!post) { + if (!post || post.isRemoved) { 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, + // Get replies + const replies = await db.query.posts.findMany({ + where: and( + eq(posts.replyToId, postId), + eq(posts.isRemoved, false) + ), + with: { + author: true, + media: true, }, - 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, + orderBy: [desc(posts.createdAt)], + limit: 50, + }); + + const author = post.author as any; + + return NextResponse.json({ + post: { + id: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + likesCount: post.likesCount, + repostsCount: post.repostsCount, + repliesCount: post.repliesCount, + author: { + handle: author.handle, + displayName: author.displayName, + avatarUrl: author.avatarUrl, + }, + media: (post.media as any[])?.map(m => ({ + url: m.url, + altText: m.altText, + })) || [], + linkPreviewUrl: post.linkPreviewUrl, + linkPreviewTitle: post.linkPreviewTitle, + linkPreviewDescription: post.linkPreviewDescription, + linkPreviewImage: post.linkPreviewImage, + }, + replies: replies.map(r => { + const replyAuthor = r.author as any; + return { + id: r.id, + content: r.content, + createdAt: r.createdAt.toISOString(), + likesCount: r.likesCount, + repostsCount: r.repostsCount, + repliesCount: r.repliesCount, + author: { + handle: replyAuthor.handle, + displayName: replyAuthor.displayName, + avatarUrl: replyAuthor.avatarUrl, + }, + media: (r.media as any[])?.map(m => ({ + url: m.url, + altText: m.altText, + })) || [], + }; + }), }); } catch (error) { - console.error('Swarm post error:', error); - return NextResponse.json( - { error: 'Failed to fetch post' }, - { status: 500 } - ); + console.error('[Swarm] Post detail error:', error); + return NextResponse.json({ error: 'Failed to get post' }, { status: 500 }); } }