diff --git a/src/app/[handle]/posts/[id]/page.tsx b/src/app/[handle]/posts/[id]/page.tsx index 9f38d43..8b2ea1f 100644 --- a/src/app/[handle]/posts/[id]/page.tsx +++ b/src/app/[handle]/posts/[id]/page.tsx @@ -42,10 +42,23 @@ export default function PostDetailPage() { }, [id]); const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => { + // Check if we're replying to a swarm post + let swarmReplyTo: { postId: string; nodeDomain: string } | undefined; + let localReplyToId: string | undefined = replyToId; + + if (post?.isSwarm && post.nodeDomain && post.originalPostId) { + // This is a reply to a swarm post - send to the origin node + swarmReplyTo = { + postId: post.originalPostId, + nodeDomain: post.nodeDomain, + }; + localReplyToId = undefined; // Don't set local replyToId for swarm posts + } + const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }), + body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }), }); if (res.ok) { diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index bcb024d..31a9cd6 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -18,7 +18,11 @@ const buildWhere = (...conditions: Array) => { const createPostSchema = z.object({ content: z.string().min(1).max(POST_MAX_LENGTH), - replyToId: z.string().uuid().optional(), + replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid + swarmReplyTo: z.object({ + postId: z.string(), + nodeDomain: z.string(), + }).optional(), mediaIds: z.array(z.string().uuid()).max(4).optional(), isNsfw: z.boolean().optional(), linkPreview: z.object({ @@ -91,6 +95,45 @@ export async function POST(request: Request) { } } + // If this is a reply to a swarm post, deliver it to the origin node + if (data.swarmReplyTo) { + (async () => { + try { + const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`; + + const replyPayload = { + postId: data.swarmReplyTo!.postId, + reply: { + id: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + author: { + handle: user.handle, + displayName: user.displayName || user.handle, + avatarUrl: user.avatarUrl || undefined, + }, + nodeDomain, + mediaUrls: attachedMedia.map(m => m.url), + }, + }; + + const response = await fetch(targetUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(replyPayload), + }); + + if (response.ok) { + console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`); + } else { + console.error(`[Swarm] Failed to deliver reply: ${response.status}`); + } + } catch (err) { + console.error('[Swarm] Error delivering reply:', err); + } + })(); + } + // Federate the post to remote followers (non-blocking) (async () => { try { @@ -301,6 +344,7 @@ export async function GET(request: Request) { // Transform swarm posts to match local post format const swarmPosts = swarmResult.posts.map(sp => ({ id: `swarm:${sp.nodeDomain}:${sp.id}`, + originalPostId: sp.id, // Keep the original ID for replies content: sp.content, createdAt: new Date(sp.createdAt), likesCount: sp.likeCount, @@ -316,11 +360,16 @@ export async function GET(request: Request) { isSwarm: true, nodeDomain: sp.nodeDomain, }, - media: sp.mediaUrls?.map((url, idx) => ({ + media: sp.media?.map((m, idx) => ({ id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`, - url, - altText: null, + url: m.url, + altText: m.altText || null, + mimeType: m.mimeType || null, })) || [], + linkPreviewUrl: sp.linkPreviewUrl || null, + linkPreviewTitle: sp.linkPreviewTitle || null, + linkPreviewDescription: sp.linkPreviewDescription || null, + linkPreviewImage: sp.linkPreviewImage || null, replyTo: null, })); diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts new file mode 100644 index 0000000..0b1bac8 --- /dev/null +++ b/src/app/api/swarm/replies/route.ts @@ -0,0 +1,189 @@ +/** + * Swarm Replies Endpoint + * + * POST: Receive a reply from another node + * GET: Fetch replies to a post on this node + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, users, media } from '@/db'; +import { eq, desc, and } from 'drizzle-orm'; +import { z } from 'zod'; + +// Schema for incoming swarm reply +const swarmReplySchema = z.object({ + postId: z.string().uuid(), // The local post being replied to + reply: z.object({ + id: z.string(), // Original reply ID on the sender's node + content: z.string(), + createdAt: z.string(), + author: z.object({ + handle: z.string(), + displayName: z.string(), + avatarUrl: z.string().optional(), + }), + nodeDomain: z.string(), + mediaUrls: z.array(z.string()).optional(), + }), +}); + +/** + * POST /api/swarm/replies + * + * Receives a reply from another node in the swarm. + * The reply is stored as a remote reply linked to the local post. + */ +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 = swarmReplySchema.parse(body); + + // Verify the target post exists on this node + const targetPost = await db.query.posts.findFirst({ + where: eq(posts.id, data.postId), + }); + + if (!targetPost) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // Check if we already have this reply (by swarm ID) + const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`; + const existingReply = await db.query.posts.findFirst({ + where: eq(posts.apId, swarmReplyId), + }); + + if (existingReply) { + return NextResponse.json({ success: true, message: 'Reply already exists' }); + } + + // We need a system user to attribute swarm replies to + // For now, we'll store them with metadata in the apId/apUrl fields + // and create a virtual representation + + // Get or create a placeholder user for this remote author + let remoteUser = await db.query.users.findFirst({ + where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`), + }); + + if (!remoteUser) { + // Create a placeholder user for the remote author + const [newUser] = await db.insert(users).values({ + did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`, + handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`, + displayName: data.reply.author.displayName, + avatarUrl: data.reply.author.avatarUrl || null, + publicKey: 'swarm-remote-user', // Placeholder + }).returning(); + remoteUser = newUser; + } + + // Create the reply post + const [replyPost] = await db.insert(posts).values({ + userId: remoteUser.id, + content: data.reply.content, + replyToId: data.postId, + apId: swarmReplyId, + apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`, + createdAt: new Date(data.reply.createdAt), + }).returning(); + + // Update the parent post's reply count + await db.update(posts) + .set({ repliesCount: targetPost.repliesCount + 1 }) + .where(eq(posts.id, data.postId)); + + console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`); + + return NextResponse.json({ + success: true, + replyId: replyPost.id, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 }); + } + console.error('[Swarm] Reply error:', error); + return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 }); + } +} + +/** + * GET /api/swarm/replies?postId=xxx + * + * Returns replies to a specific post on this node. + * Used by other nodes to fetch reply threads. + */ +export async function GET(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ replies: [] }); + } + + const { searchParams } = new URL(request.url); + const postId = searchParams.get('postId'); + + if (!postId) { + return NextResponse.json({ error: 'postId required' }, { status: 400 }); + } + + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + + // Get replies to this post + const replies = await db + .select({ + id: posts.id, + content: posts.content, + createdAt: posts.createdAt, + likesCount: posts.likesCount, + repostsCount: posts.repostsCount, + repliesCount: posts.repliesCount, + authorHandle: users.handle, + authorDisplayName: users.displayName, + authorAvatarUrl: users.avatarUrl, + }) + .from(posts) + .innerJoin(users, eq(posts.userId, users.id)) + .where( + and( + eq(posts.replyToId, postId), + eq(posts.isRemoved, false) + ) + ) + .orderBy(desc(posts.createdAt)) + .limit(50); + + // Format replies for swarm consumption + const formattedReplies = replies.map(reply => ({ + id: reply.id, + content: reply.content, + createdAt: reply.createdAt.toISOString(), + author: { + handle: reply.authorHandle.includes('@') + ? reply.authorHandle.split('@')[0] + : reply.authorHandle, + displayName: reply.authorDisplayName || reply.authorHandle, + avatarUrl: reply.authorAvatarUrl || undefined, + }, + nodeDomain: reply.authorHandle.includes('@') + ? reply.authorHandle.split('@')[1] + : nodeDomain, + likeCount: reply.likesCount, + repostCount: reply.repostsCount, + replyCount: reply.repliesCount, + })); + + return NextResponse.json({ + postId, + replies: formattedReplies, + nodeDomain, + }); + } catch (error) { + console.error('[Swarm] Fetch replies error:', error); + return NextResponse.json({ error: 'Failed to fetch replies' }, { status: 500 }); + } +} diff --git a/src/app/api/swarm/timeline/route.ts b/src/app/api/swarm/timeline/route.ts index 7ffb605..8f3f949 100644 --- a/src/app/api/swarm/timeline/route.ts +++ b/src/app/api/swarm/timeline/route.ts @@ -24,7 +24,12 @@ export interface SwarmPost { likeCount: number; repostCount: number; replyCount: number; - mediaUrls?: string[]; + media?: { url: string; mimeType?: string; altText?: string }[]; + // Link preview + linkPreviewUrl?: string; + linkPreviewTitle?: string; + linkPreviewDescription?: string; + linkPreviewImage?: string; } /** @@ -60,6 +65,10 @@ export async function GET(request: NextRequest) { likesCount: posts.likesCount, repostsCount: posts.repostsCount, repliesCount: posts.repliesCount, + linkPreviewUrl: posts.linkPreviewUrl, + linkPreviewTitle: posts.linkPreviewTitle, + linkPreviewDescription: posts.linkPreviewDescription, + linkPreviewImage: posts.linkPreviewImage, authorHandle: users.handle, authorDisplayName: users.displayName, authorAvatarUrl: users.avatarUrl, @@ -82,7 +91,7 @@ export async function GET(request: NextRequest) { for (const post of recentPosts) { const postMedia = await db - .select({ url: media.url }) + .select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) .from(media) .where(eq(media.postId, post.id)); @@ -102,7 +111,15 @@ export async function GET(request: NextRequest) { likeCount: post.likesCount, repostCount: post.repostsCount, replyCount: post.repliesCount, - mediaUrls: postMedia.length > 0 ? postMedia.map(m => m.url) : undefined, + 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, }); } diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 01c479a..8d04dde 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -72,7 +72,11 @@ interface SwarmPost { likeCount: number; repostCount: number; replyCount: number; - mediaUrls?: string[]; + media?: { url: string; mimeType?: string; altText?: string }[]; + linkPreviewUrl?: string; + linkPreviewTitle?: string; + linkPreviewDescription?: string; + linkPreviewImage?: string; } export default function ExplorePage() { @@ -299,43 +303,45 @@ export default function ExplorePage() {
- {swarmPosts.map((post) => ( -
-
-
-
- {post.author.avatarUrl ? ( - {post.author.displayName} - ) : ( - post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() - )} -
-
- {post.author.displayName} - @{post.author.handle}@{post.nodeDomain} -
-
-
{post.content}
- {post.mediaUrls && post.mediaUrls.length > 0 && ( -
- {post.mediaUrls.map((url, i) => ( - - ))} -
- )} -
- - {new Date(post.createdAt).toLocaleString()} - - - {post.likeCount > 0 && `${post.likeCount} likes`} - {post.likeCount > 0 && post.repostCount > 0 && ' · '} - {post.repostCount > 0 && `${post.repostCount} reposts`} - -
-
-
- ))} + {swarmPosts.map((post) => { + // Transform swarm post to Post format for PostCard + const transformedPost: Post = { + id: `swarm:${post.nodeDomain}:${post.id}`, + originalPostId: post.id, + content: post.content, + createdAt: post.createdAt, + likesCount: post.likeCount, + repostsCount: post.repostCount, + repliesCount: post.replyCount, + isSwarm: true, + nodeDomain: post.nodeDomain, + author: { + id: `swarm:${post.nodeDomain}:${post.author.handle}`, + handle: post.author.handle, + displayName: post.author.displayName, + avatarUrl: post.author.avatarUrl, + }, + media: post.media?.map((m, idx) => ({ + id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`, + url: m.url, + altText: m.altText || null, + mimeType: m.mimeType || null, + })) || [], + linkPreviewUrl: post.linkPreviewUrl || null, + linkPreviewTitle: post.linkPreviewTitle || null, + linkPreviewDescription: post.linkPreviewDescription || null, + linkPreviewImage: post.linkPreviewImage || null, + }; + return ( + + ); + })}
) diff --git a/src/app/globals.css b/src/app/globals.css index a38191a..3b69b97 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -558,10 +558,11 @@ a.btn-primary:visited { } .compose-media-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + display: flex; + flex-wrap: wrap; gap: 8px; margin-top: 12px; + align-items: flex-start; } .compose-media-item { @@ -570,34 +571,33 @@ a.btn-primary:visited { overflow: hidden; border: 1px solid var(--border); background: var(--background-tertiary); + max-height: 80px; } .compose-media-item img { - width: 100%; - height: 120px; - object-fit: cover; + height: 80px; + width: auto; display: block; } .compose-media-item video { - width: 100%; - height: 120px; - object-fit: cover; + height: 80px; + width: auto; display: block; } .compose-media-remove { position: absolute; - top: 6px; - right: 6px; - width: 24px; - height: 24px; + top: 4px; + right: 4px; + width: 20px; + height: 20px; border-radius: var(--radius-full); border: none; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.7); color: #fff; cursor: pointer; - font-size: 16px; + font-size: 12px; line-height: 1; } diff --git a/src/app/page.tsx b/src/app/page.tsx index ee7a24d..97ba183 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -68,10 +68,23 @@ export default function Home() { }, [feedType]); const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => { + // Check if we're replying to a swarm post + let swarmReplyTo: { postId: string; nodeDomain: string } | undefined; + let localReplyToId: string | undefined = replyToId; + + if (replyingTo?.isSwarm && replyingTo.nodeDomain && replyingTo.originalPostId) { + // This is a reply to a swarm post - send to the origin node + swarmReplyTo = { + postId: replyingTo.originalPostId, + nodeDomain: replyingTo.nodeDomain, + }; + localReplyToId = undefined; // Don't set local replyToId for swarm posts + } + const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }), + body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }), }); if (res.ok) { diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index 565239d..9f509c4 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -30,10 +30,11 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What const [lastDetectedUrl, setLastDetectedUrl] = useState(null); const [isNsfw, setIsNsfw] = useState(false); const [canPostNsfw, setCanPostNsfw] = useState(false); + const [isNsfwNode, setIsNsfwNode] = useState(false); const maxLength = 400; const remaining = maxLength - content.length; - // Check if user can post NSFW content + // Check if user can post NSFW content and if node is NSFW useEffect(() => { fetch('/api/settings/nsfw') .then(res => res.ok ? res.json() : null) @@ -43,6 +44,15 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What } }) .catch(() => {}); + + fetch('/api/node') + .then(res => res.ok ? res.json() : null) + .then(data => { + if (data?.isNsfw) { + setIsNsfwNode(true); + } + }) + .catch(() => {}); }, []); // Detect URLs in content @@ -214,7 +224,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What {remaining} - {canPostNsfw && ( + {canPostNsfw && !isNsfwNode && (