diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index f365f6b..8f73887 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -113,6 +113,51 @@ export async function POST(request: Request) { } } +// Helper to transform cached remote posts to match local post format +// Also deduplicates by apId to prevent showing same post multiple times +const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]) => { + const seenApIds = new Set(); + const uniquePosts: typeof remotePosts.$inferSelect[] = []; + + for (const rp of remotePostsData) { + if (!seenApIds.has(rp.apId)) { + seenApIds.add(rp.apId); + uniquePosts.push(rp); + } + } + + return uniquePosts.map(rp => { + const mediaData = rp.mediaJson ? JSON.parse(rp.mediaJson) : []; + return { + id: rp.id, + content: rp.content, + createdAt: rp.publishedAt, + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + isRemote: true, + apId: rp.apId, + linkPreviewUrl: rp.linkPreviewUrl, + linkPreviewTitle: rp.linkPreviewTitle, + linkPreviewDescription: rp.linkPreviewDescription, + linkPreviewImage: rp.linkPreviewImage, + author: { + id: rp.authorActorUrl, + handle: rp.authorHandle, + displayName: rp.authorDisplayName, + avatarUrl: rp.authorAvatarUrl, + isRemote: true, + }, + media: mediaData.map((m: { url: string; altText?: string }, idx: number) => ({ + id: `${rp.id}-media-${idx}`, + url: m.url, + altText: m.altText || null, + })), + replyTo: null, + }; + }); +}; + // Get timeline / feed export async function GET(request: Request) { try { @@ -133,8 +178,8 @@ export async function GET(request: Request) { ); if (type === 'public') { - // Public timeline - all posts - feedPosts = await db.query.posts.findMany({ + // Public timeline - all local posts + all cached remote posts + const localPosts = await db.query.posts.findMany({ where: baseFilter, with: { author: true, @@ -144,8 +189,21 @@ export async function GET(request: Request) { }, }, orderBy: [desc(posts.createdAt)], - limit, + limit: limit * 2, }); + + // Get all cached remote posts + const remotePostsData = await db.query.remotePosts.findMany({ + orderBy: [desc(remotePosts.publishedAt)], + limit: limit, + }); + + const transformedRemote = transformRemotePosts(remotePostsData); + + // Merge and sort by date + feedPosts = [...localPosts, ...transformedRemote] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, limit) as any; } else if (type === 'user' && userId) { // User's posts feedPosts = await db.query.posts.findMany({ @@ -184,7 +242,18 @@ export async function GET(request: Request) { limit: seedLimit, }); + // Also get cached remote posts for the curated feed + const remotePostsData = await db.query.remotePosts.findMany({ + orderBy: [desc(remotePosts.publishedAt)], + limit: Math.floor(seedLimit / 2), + }); + const transformedRemote = transformRemotePosts(remotePostsData); + + // Combine local and remote for ranking + const allSeedPosts = [...seedPosts, ...transformedRemote]; + let followingIds = new Set(); + let followingRemoteHandles = new Set(); let mutedIds = new Set(); let blockedIds = new Set(); @@ -194,6 +263,12 @@ export async function GET(request: Request) { .where(eq(follows.followerId, viewer.id)); followingIds = new Set(followRows.map(row => row.followingId)); + // Also get remote follows for boost + const remoteFollowRows = await db.query.remoteFollows.findMany({ + where: eq(remoteFollows.followerId, viewer.id), + }); + followingRemoteHandles = new Set(remoteFollowRows.map(row => row.targetHandle)); + const muteRows = await db.select({ mutedUserId: mutes.mutedUserId }) .from(mutes) .where(eq(mutes.userId, viewer.id)); @@ -206,27 +281,35 @@ export async function GET(request: Request) { } const now = Date.now(); - const rankedPosts = seedPosts - .filter(post => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)) - .map(post => { + const rankedPosts = allSeedPosts + .filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id)) + .map((post: any) => { const createdAt = new Date(post.createdAt).getTime(); const ageHours = Math.max(0, (now - createdAt) / 3600000); - const engagement = post.likesCount + post.repostsCount * 2 + post.repliesCount * 0.5; + const engagement = (post.likesCount || 0) + (post.repostsCount || 0) * 2 + (post.repliesCount || 0) * 0.5; const engagementScore = Math.log1p(Math.max(0, engagement)); const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS); - const followBoost = viewer && followingIds.has(post.author.id) ? 0.9 : 0; + const isRemote = post.isRemote === true; + const followBoost = viewer && ( + followingIds.has(post.author.id) || + (isRemote && followingRemoteHandles.has(post.author.handle)) + ) ? 0.9 : 0; const selfBoost = viewer && post.author.id === viewer.id ? 0.5 : 0; + const federatedBoost = isRemote ? 0.3 : 0; // Small boost for federated content diversity - const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost; + const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost + federatedBoost; const reasons: string[] = []; + if (isRemote) { + reasons.push(`From the fediverse`); + } if (followBoost > 0) { reasons.push(`You follow @${post.author.handle}`); } if (engagement >= 5) { - reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`); - } else if (post.repliesCount > 0) { + reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`); + } else if ((post.repliesCount || 0) > 0) { reasons.push(`Active conversation: ${post.repliesCount} replies`); } if (ageHours <= 6) { @@ -246,14 +329,14 @@ export async function GET(request: Request) { score: Number(score.toFixed(3)), reasons, engagement: { - likes: post.likesCount, - reposts: post.repostsCount, - replies: post.repliesCount, + likes: post.likesCount || 0, + reposts: post.repostsCount || 0, + replies: post.repliesCount || 0, }, }, }; }) - .sort((a, b) => { + .sort((a: any, b: any) => { if (b.feedMeta.score !== a.feedMeta.score) { return b.feedMeta.score - a.feedMeta.score; } @@ -297,37 +380,8 @@ export async function GET(request: Request) { }); } - // Transform remote posts to match local post format - const transformedRemotePosts = remotePostsData.map(rp => { - const mediaData = rp.mediaJson ? JSON.parse(rp.mediaJson) : []; - return { - id: rp.id, - content: rp.content, - createdAt: rp.publishedAt, - likesCount: 0, - repostsCount: 0, - repliesCount: 0, - isRemote: true, - apId: rp.apId, - linkPreviewUrl: rp.linkPreviewUrl, - linkPreviewTitle: rp.linkPreviewTitle, - linkPreviewDescription: rp.linkPreviewDescription, - linkPreviewImage: rp.linkPreviewImage, - author: { - id: rp.authorActorUrl, - handle: rp.authorHandle, - displayName: rp.authorDisplayName, - avatarUrl: rp.authorAvatarUrl, - isRemote: true, - }, - media: mediaData.map((m: { url: string; altText?: string }, idx: number) => ({ - id: `${rp.id}-media-${idx}`, - url: m.url, - altText: m.altText || null, - })), - replyTo: null, - }; - }); + // Transform remote posts to match local post format (with deduplication) + const transformedRemotePosts = transformRemotePosts(remotePostsData); // Merge and sort by date const allPosts = [...localPosts, ...transformedRemotePosts] diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index d0adab8..8af8070 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -134,6 +134,11 @@ export async function POST(request: Request, context: RouteContext) { activityId, }); + // Update the user's following count + await db.update(users) + .set({ followingCount: currentUser.followingCount + 1 }) + .where(eq(users.id, currentUser.id)); + // Cache the remote user's recent posts in the background const origin = new URL(request.url).origin; cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20) @@ -251,6 +256,12 @@ export async function DELETE(request: Request, context: RouteContext) { return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 }); } await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id)); + + // Update the user's following count + await db.update(users) + .set({ followingCount: Math.max(0, currentUser.followingCount - 1) }) + .where(eq(users.id, currentUser.id)); + return NextResponse.json({ success: true, following: false, remote: true }); } diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts index 4279f9e..b00d26b 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { db, follows, users } from '@/db'; +import { db, follows, users, remoteFollows } from '@/db'; import { eq } from 'drizzle-orm'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -28,7 +28,7 @@ export async function GET(request: Request, context: RouteContext) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - // Get following + // Get local following const userFollowing = await db.query.follows.findMany({ where: eq(follows.followerId, user.id), with: { @@ -37,15 +37,36 @@ export async function GET(request: Request, context: RouteContext) { limit, }); + const localFollowing = userFollowing.map(f => ({ + id: f.following.id, + handle: f.following.handle, + displayName: f.following.displayName, + avatarUrl: f.following.avatarUrl, + bio: f.following.bio, + isRemote: false, + })); + + // Get remote following + const userRemoteFollowing = await db.query.remoteFollows.findMany({ + where: eq(remoteFollows.followerId, user.id), + limit, + }); + + const remoteFollowing = userRemoteFollowing.map(f => ({ + id: f.targetActorUrl, + handle: f.targetHandle, + displayName: f.targetHandle.split('@')[0], // Use username part as display name + avatarUrl: null, + bio: null, + isRemote: true, + })); + + // Merge and return + const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit); + return NextResponse.json({ - following: userFollowing.map(f => ({ - id: f.following.id, - handle: f.following.handle, - displayName: f.following.displayName, - avatarUrl: f.following.avatarUrl, - bio: f.following.bio, - })), - nextCursor: userFollowing.length === limit ? userFollowing[userFollowing.length - 1]?.id : null, + following: allFollowing, + nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null, }); } catch (error) { console.error('Get following error:', error); diff --git a/src/lib/activitypub/cache.ts b/src/lib/activitypub/cache.ts index 37bd8fb..4a8afef 100644 --- a/src/lib/activitypub/cache.ts +++ b/src/lib/activitypub/cache.ts @@ -197,7 +197,7 @@ export async function cacheRemoteUserPosts( linkPreviewDescription: linkPreview?.description || null, linkPreviewImage: linkPreview?.image || null, mediaJson: mediaJson.length > 0 ? JSON.stringify(mediaJson) : null, - }); + }).onConflictDoNothing(); cached++; } catch (error) {