diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 523df99..97533db 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; import { db, notifications } from '@/db'; import { requireAuth } from '@/lib/auth'; -import { requireSignedAction } from '@/lib/auth/verify-signature'; import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; import { z } from 'zod'; @@ -120,15 +119,14 @@ export async function GET(request: Request) { export async function PATCH(request: Request) { try { - const signedAction = await request.json(); - const user = await requireSignedAction(signedAction); + const user = await requireAuth(); if (!db) { return NextResponse.json({ error: 'Database not available' }, { status: 503 }); } - // We trust the signed action 'data' for the IDs - const body = signedAction.data; + const rawBody = await request.json(); + const body = rawBody?.data && typeof rawBody.data === 'object' ? rawBody.data : rawBody; const data = markSchema.parse(body); if (!data.all && (!data.ids || data.ids.length === 0)) { diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 4aab45a..55e56d2 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { db, posts, likes, users, notifications } from '@/db'; -import { requireAuth } from '@/lib/auth'; +import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db'; import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; import { eq, and, sql } from 'drizzle-orm'; import { z } from 'zod'; @@ -41,6 +40,44 @@ function extractSwarmPostId(apId: string): string | null { return apId.substring(lastColonIndex + 1); } +async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) { + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + return null; + } + + const data = await res.json(); + const post = data.post; + if (!post) { + return null; + } + + return { + authorHandle: post.author?.handle || 'unknown', + authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown', + authorAvatarUrl: post.author?.avatarUrl || null, + content: post.content || '', + postCreatedAt: new Date(post.createdAt || new Date().toISOString()), + likesCount: post.likesCount || 0, + repostsCount: post.repostsCount || 0, + repliesCount: post.repliesCount || 0, + linkPreviewUrl: post.linkPreviewUrl || null, + linkPreviewTitle: post.linkPreviewTitle || null, + linkPreviewDescription: post.linkPreviewDescription || null, + linkPreviewImage: post.linkPreviewImage || null, + mediaJson: post.media ? JSON.stringify(post.media) : null, + }; + } catch { + return null; + } +} + // Like a post export async function POST(request: Request, context: RouteContext) { try { @@ -89,6 +126,23 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Failed to deliver like to remote node' }, { status: 502 }); } + const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId); + if (snapshot) { + await db.insert(userSwarmLikes).values({ + userId: user.id, + nodeDomain: targetDomain, + originalPostId, + ...snapshot, + likedAt: new Date(), + }).onConflictDoUpdate({ + target: [userSwarmLikes.userId, userSwarmLikes.nodeDomain, userSwarmLikes.originalPostId], + set: { + ...snapshot, + likedAt: new Date(), + }, + }); + } + console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, liked: true }); } @@ -242,7 +296,7 @@ export async function DELETE(request: Request, context: RouteContext) { // Handle swarm posts (format: swarm:domain:uuid) if (postId.startsWith('swarm:')) { const targetDomain = extractSwarmDomain(postId); - const originalPostId = postId.split(':')[2]; + const originalPostId = extractSwarmPostId(postId); if (!targetDomain || !originalPostId) { return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 }); @@ -266,6 +320,12 @@ export async function DELETE(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Failed to deliver unlike to remote node' }, { status: 502 }); } + await db.delete(userSwarmLikes).where(and( + eq(userSwarmLikes.userId, user.id), + eq(userSwarmLikes.nodeDomain, targetDomain), + eq(userSwarmLikes.originalPostId, originalPostId) + )); + console.log(`[Swarm] Unlike delivered to ${targetDomain} for post ${originalPostId}`); return NextResponse.json({ success: true, liked: false }); } diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index f2d37ad..e85a2c1 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -9,6 +9,7 @@ import { db, posts } from '@/db'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { getSession } from '@/lib/auth'; import { and, eq, inArray, sql } from 'drizzle-orm'; +import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; /** * GET /api/posts/swarm @@ -52,10 +53,26 @@ export async function GET(request: NextRequest) { .map(row => [row.swarmReplyToId as string, row.count]) ); + const session = await getSession().catch(() => null); + const viewer = session?.user; + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const likedPostIds = viewer + ? await getViewerSwarmLikedPostIds( + timeline.posts.map(post => ({ + id: `swarm:${post.nodeDomain}:${post.id}`, + nodeDomain: post.nodeDomain, + originalPostId: post.id, + })), + viewer.handle, + nodeDomain + ) + : new Set(); + return NextResponse.json({ posts: timeline.posts.map(post => ({ ...post, replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0), + isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`), })), sources: timeline.sources, cached: false, diff --git a/src/app/api/users/[handle]/likes/route.ts b/src/app/api/users/[handle]/likes/route.ts index 90cf6e2..718cef6 100644 --- a/src/app/api/users/[handle]/likes/route.ts +++ b/src/app/api/users/[handle]/likes/route.ts @@ -1,9 +1,21 @@ import { NextResponse } from 'next/server'; -import { db, likes, posts, users } from '@/db'; +import { db, likes, posts, users, userSwarmLikes } from '@/db'; import { eq, desc, and, inArray } from 'drizzle-orm'; type RouteContext = { params: Promise<{ handle: string }> }; +const parseMediaJson = (mediaJson: string | null) => { + if (!mediaJson) { + return []; + } + + try { + return JSON.parse(mediaJson); + } catch { + return []; + } +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; @@ -41,11 +53,51 @@ export async function GET(request: Request, context: RouteContext) { limit, }); - // Filter out any likes where the post was removed and format response - let likedPosts = userLikes + const localLikedPosts = userLikes .filter(like => like.post && !like.post.isRemoved) .map(like => like.post); + const swarmLikedRows = await db.query.userSwarmLikes.findMany({ + where: eq(userSwarmLikes.userId, user.id), + orderBy: [desc(userSwarmLikes.likedAt)], + limit, + }); + + const swarmLikedPosts = swarmLikedRows.map((like) => ({ + id: `swarm:${like.nodeDomain}:${like.originalPostId}`, + originalPostId: like.originalPostId, + content: like.content, + createdAt: like.postCreatedAt.toISOString(), + likesCount: like.likesCount, + repostsCount: like.repostsCount, + repliesCount: like.repliesCount, + author: { + id: `swarm:${like.nodeDomain}:${like.authorHandle}`, + handle: `${like.authorHandle}@${like.nodeDomain}`, + displayName: like.authorDisplayName || like.authorHandle, + avatarUrl: like.authorAvatarUrl, + }, + media: parseMediaJson(like.mediaJson), + linkPreviewUrl: like.linkPreviewUrl, + linkPreviewTitle: like.linkPreviewTitle, + linkPreviewDescription: like.linkPreviewDescription, + linkPreviewImage: like.linkPreviewImage, + isSwarm: true, + nodeDomain: like.nodeDomain, + likedAt: like.likedAt.toISOString(), + isLiked: false, + })); + + let likedPosts: any[] = [ + ...localLikedPosts.map((post) => ({ + ...post, + likedAt: userLikes.find((like) => like.post?.id === post.id)?.createdAt?.toISOString() || post.createdAt.toISOString(), + })), + ...swarmLikedPosts, + ] + .sort((a, b) => new Date(b.likedAt).getTime() - new Date(a.likedAt).getTime()) + .slice(0, limit); + // Populate isLiked and isReposted for authenticated users try { const { getSession } = await import('@/lib/auth'); @@ -53,13 +105,17 @@ export async function GET(request: Request, context: RouteContext) { if (session?.user && likedPosts.length > 0) { const viewer = session.user; - const postIds = likedPosts.map(p => p!.id).filter(Boolean); + const isOwnLikesView = viewer.id === user.id; + const localPostIds = likedPosts + .filter((post: any) => !post.isSwarm) + .map((post: any) => post.id) + .filter(Boolean); - if (postIds.length > 0) { + 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)); @@ -67,7 +123,7 @@ export async function GET(request: Request, context: RouteContext) { const viewerReposts = await db.query.posts.findMany({ where: and( eq(posts.userId, viewer.id), - inArray(posts.repostOfId, postIds), + inArray(posts.repostOfId, localPostIds), eq(posts.isRemoved, false) ), }); @@ -75,9 +131,14 @@ export async function GET(request: Request, context: RouteContext) { likedPosts = likedPosts.map(p => ({ ...p!, - isLiked: likedPostIds.has(p!.id), + isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id), isReposted: repostedPostIds.has(p!.id), })) as any; + } else { + likedPosts = likedPosts.map(p => ({ + ...p!, + isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked, + })) as any; } } } catch (error) { diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 55cd97a..21d71e5 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; import { db, posts, users, likes } from '@/db'; -import { eq, desc, and, inArray, lt, sql } from 'drizzle-orm'; +import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; +import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -15,6 +16,43 @@ const parseRemoteHandle = (handle: string) => { return null; }; +async function populateViewerLikeState( + remotePosts: any[], + domain: string +) { + if (!remotePosts.length) { + return remotePosts; + } + + try { + const { getSession } = await import('@/lib/auth'); + const session = await getSession(); + const viewer = session?.user; + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + + if (!viewer) { + return remotePosts; + } + + const likedIds = await getViewerSwarmLikedPostIds( + remotePosts.map((post) => ({ + id: `swarm:${domain}:${post.originalPostId}`, + nodeDomain: domain, + originalPostId: post.originalPostId, + })), + viewer.handle, + nodeDomain + ); + + return remotePosts.map((post) => ({ + ...post, + isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`), + })); + } catch { + return remotePosts; + } +} + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; @@ -54,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) { const remotePosts = profileData.posts.map((post: any) => ({ id: post.id, + originalPostId: post.id, content: post.content, createdAt: post.createdAt, likesCount: post.likesCount || 0, @@ -67,10 +106,9 @@ export async function GET(request: Request, context: RouteContext) { linkPreviewImage: post.linkPreviewImage || null, isSwarm: true, nodeDomain: remote.domain, - originalPostId: post.id, })); - return NextResponse.json({ posts: remotePosts, nextCursor: null }); + return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null }); } return NextResponse.json({ posts: [] }); @@ -132,6 +170,7 @@ export async function GET(request: Request, context: RouteContext) { const remotePosts = profileData.posts.map((post: any) => ({ id: post.id, + originalPostId: post.id, content: post.content, createdAt: post.createdAt, likesCount: post.likesCount || 0, @@ -145,10 +184,9 @@ export async function GET(request: Request, context: RouteContext) { linkPreviewImage: post.linkPreviewImage || null, isSwarm: true, nodeDomain: remote.domain, - originalPostId: post.id, })); - return NextResponse.json({ posts: remotePosts, nextCursor: null }); + return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null }); } return NextResponse.json({ posts: [] }); @@ -159,7 +197,12 @@ export async function GET(request: Request, context: RouteContext) { } // Get user's posts with cursor-based pagination - let whereConditions = and(eq(posts.userId, user.id), eq(posts.isRemoved, false)); + let whereConditions = and( + eq(posts.userId, user.id), + eq(posts.isRemoved, false), + isNull(posts.replyToId), + isNull(posts.swarmReplyToId) + ); // If cursor provided, get posts older than the cursor if (cursor) { @@ -170,6 +213,8 @@ export async function GET(request: Request, context: RouteContext) { whereConditions = and( eq(posts.userId, user.id), eq(posts.isRemoved, false), + isNull(posts.replyToId), + isNull(posts.swarmReplyToId), lt(posts.createdAt, cursorPost.createdAt) ); } diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 2b7a651..7399239 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -79,6 +79,7 @@ interface SwarmPost { linkPreviewTitle?: string; linkPreviewDescription?: string; linkPreviewImage?: string; + isLiked?: boolean; } export default function ExplorePage() { @@ -466,6 +467,7 @@ export default function ExplorePage() { linkPreviewTitle: post.linkPreviewTitle || null, linkPreviewDescription: post.linkPreviewDescription || null, linkPreviewImage: post.linkPreviewImage || null, + isLiked: post.isLiked || false, }; return ( ([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + if (authLoading) { + return; + } fetchNotifications(); - }, []); + }, [authLoading]); const fetchNotifications = async () => { try { @@ -56,11 +60,14 @@ export default function NotificationsPage() { const markAllRead = async () => { try { - await fetch('/api/notifications', { + const res = await fetch('/api/notifications', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ all: true }), }); + if (!res.ok) { + throw new Error('Failed to mark notifications as read'); + } setNotifications(prev => prev.map(n => ({ ...n, readAt: new Date().toISOString() }))); } catch (err) { console.error('Failed to mark notifications as read:', err); diff --git a/src/db/schema.ts b/src/db/schema.ts index a6b3f84..004f5dc 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -353,6 +353,35 @@ export const remoteLikes = pgTable('remote_likes', { uniqueIndex('remote_likes_unique').on(table.postId, table.actorHandle, table.actorNodeDomain), ]); +// ============================================ +// USER SWARM LIKES (local users liking remote swarm posts) +// ============================================ + +export const userSwarmLikes = pgTable('user_swarm_likes', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + nodeDomain: text('node_domain').notNull(), + originalPostId: text('original_post_id').notNull(), + authorHandle: text('author_handle').notNull(), + authorDisplayName: text('author_display_name'), + authorAvatarUrl: text('author_avatar_url'), + content: text('content').notNull(), + postCreatedAt: timestamp('post_created_at').notNull(), + likesCount: integer('likes_count').default(0).notNull(), + repostsCount: integer('reposts_count').default(0).notNull(), + repliesCount: integer('replies_count').default(0).notNull(), + linkPreviewUrl: text('link_preview_url'), + linkPreviewTitle: text('link_preview_title'), + linkPreviewDescription: text('link_preview_description'), + linkPreviewImage: text('link_preview_image'), + mediaJson: text('media_json'), + likedAt: timestamp('liked_at').defaultNow().notNull(), +}, (table) => [ + index('user_swarm_likes_user_idx').on(table.userId, table.likedAt), + index('user_swarm_likes_post_idx').on(table.nodeDomain, table.originalPostId), + uniqueIndex('user_swarm_likes_unique').on(table.userId, table.nodeDomain, table.originalPostId), +]); + // ============================================ // REMOTE REPOSTS (reposts from federated users on local posts) // ============================================ diff --git a/src/lib/swarm/likes.ts b/src/lib/swarm/likes.ts new file mode 100644 index 0000000..b0df710 --- /dev/null +++ b/src/lib/swarm/likes.ts @@ -0,0 +1,44 @@ +export interface SwarmLikeTarget { + id: string; + nodeDomain: string; + originalPostId: string; +} + +export async function getViewerSwarmLikedPostIds( + targets: SwarmLikeTarget[], + viewerHandle: string, + viewerDomain: string +): Promise> { + const likedIds = new Set(); + + if (!targets.length || !viewerHandle || !viewerDomain) { + return likedIds; + } + + await Promise.all( + targets.map(async (target) => { + try { + const protocol = target.nodeDomain.includes('localhost') ? 'http' : 'https'; + const res = await fetch( + `${protocol}://${target.nodeDomain}/api/swarm/posts/${target.originalPostId}/likes?checkHandle=${encodeURIComponent(viewerHandle)}&checkDomain=${encodeURIComponent(viewerDomain)}`, + { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(3000), + } + ); + + if (!res.ok) { + return; + } + + const data = await res.json(); + if (data.isLiked) { + likedIds.add(target.id); + } + } catch { + } + }) + ); + + return likedIds; +}