Files
Synapsis/src/app/api/posts/route.ts
T
Christomatt 141b99747a feat(posts): Add swarm reply tracking and thread visualization
- Add swarm reply metadata fields (swarmReplyToId, swarmReplyToContent, swarmReplyToAuthor) to posts table for tracking replies to federated posts
- Extend swarmReplyTo schema to include optional content and author information for context preservation
- Build synthetic replyTo objects from swarm reply metadata to unify local and federated reply handling
- Include media in replyTo relations across all post queries for complete reply context
- Add thread container styling with visual line connector between parent and child posts
- Implement thread parent display mode with reduced opacity and hidden actions for context
- Add showThread and isThreadParent props to PostCard for flexible thread rendering
- Enable inline parent post visualization when viewing post details to show conversation context
2026-01-26 13:50:15 +01:00

811 lines
35 KiB
TypeScript

import { NextResponse } from 'next/server';
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { z } from 'zod';
const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72;
const CURATION_SEED_MULTIPLIER = 5;
const CURATION_SEED_CAP = 200;
const buildWhere = (...conditions: Array<SQL | undefined>) => {
const filtered = conditions.filter(Boolean) as SQL[];
if (filtered.length === 0) return undefined;
return and(...filtered);
};
const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
swarmReplyTo: z.object({
postId: z.string(),
nodeDomain: z.string(),
content: z.string().optional(),
author: z.object({
handle: z.string(),
displayName: z.string().optional().nullable(),
avatarUrl: z.string().optional().nullable(),
nodeDomain: z.string().optional().nullable(),
}).optional(),
}).optional(),
mediaIds: z.array(z.string().uuid()).max(4).optional(),
isNsfw: z.boolean().optional(),
linkPreview: z.object({
url: z.string().url(),
title: z.string().optional(),
description: z.string().optional(),
image: z.string().url().optional().nullable(),
}).optional().nullable(),
});
// Create a new post
export async function POST(request: Request) {
try {
const user = await requireAuth();
const body = await request.json();
const data = createPostSchema.parse(body);
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Build swarm reply fields if replying to a swarm post
const swarmReplyFields = data.swarmReplyTo ? {
swarmReplyToId: `swarm:${data.swarmReplyTo.nodeDomain}:${data.swarmReplyTo.postId}`,
swarmReplyToContent: data.swarmReplyTo.content?.slice(0, 300) || null,
swarmReplyToAuthor: data.swarmReplyTo.author ? JSON.stringify({
handle: data.swarmReplyTo.author.handle,
displayName: data.swarmReplyTo.author.displayName,
avatarUrl: data.swarmReplyTo.author.avatarUrl,
nodeDomain: data.swarmReplyTo.nodeDomain,
}) : null,
} : {};
const [post] = await db.insert(posts).values({
userId: user.id,
content: data.content,
replyToId: data.replyToId,
...swarmReplyFields,
isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW
apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
linkPreviewUrl: data.linkPreview?.url,
linkPreviewTitle: data.linkPreview?.title,
linkPreviewDescription: data.linkPreview?.description,
linkPreviewImage: data.linkPreview?.image,
}).returning();
let attachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
await db.update(media)
.set({ postId: post.id })
.where(and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
isNull(media.postId),
));
attachedMedia = await db.query.media.findMany({
where: and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
eq(media.postId, post.id),
),
});
}
// Update user's post count
await db.update(users)
.set({ postsCount: user.postsCount + 1 })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count
if (data.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, data.replyToId),
});
if (parentPost) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount + 1 })
.where(eq(posts.id, data.replyToId));
}
}
// 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);
}
})();
}
// Handle local mentions (create notifications for users on this node)
(async () => {
try {
const { extractMentions } = await import('@/lib/swarm/interactions');
const { notifications } = await import('@/db');
const mentions = extractMentions(data.content);
for (const mention of mentions) {
// Only handle local mentions (no domain)
if (mention.domain) continue;
// Find the mentioned user
const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, mention.handle.toLowerCase()),
});
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
// Create notification for the mentioned user with actor info stored directly
await db.insert(notifications).values({
userId: mentionedUser.id,
actorId: user.id,
actorHandle: user.handle,
actorDisplayName: user.displayName,
actorAvatarUrl: user.avatarUrl,
actorNodeDomain: null, // Local user
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
type: 'mention',
});
// Also notify bot owner if this is a bot being mentioned
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
await db.insert(notifications).values({
userId: mentionedUser.botOwnerId,
actorId: user.id,
actorHandle: user.handle,
actorDisplayName: user.displayName,
actorAvatarUrl: user.avatarUrl,
actorNodeDomain: null,
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
type: 'mention',
});
}
}
}
} catch (err) {
console.error('[Local] Error creating mention notifications:', err);
}
})();
// SWARM-FIRST: Deliver mentions to swarm nodes
(async () => {
try {
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions(
data.content,
post.id,
{
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
nodeDomain,
}
);
if (result.delivered > 0) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
}
} catch (err) {
console.error('[Swarm] Error delivering mentions:', err);
}
})();
// Federate the post to remote followers (non-blocking)
(async () => {
try {
// SWARM-FIRST: Deliver to swarm followers directly
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
const swarmResult = await deliverPostToSwarmFollowers(
user.id,
post,
{
handle: user.handle,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
isNsfw: user.isNsfw,
},
attachedMedia,
nodeDomain
);
if (swarmResult.delivered > 0) {
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
}
// FALLBACK: Deliver to ActivityPub followers
const { createCreateActivity } = await import('@/lib/activitypub/activities');
const { getFollowerInboxes, deliverToFollowers } = await import('@/lib/activitypub/outbox');
const followerInboxes = await getFollowerInboxes(user.id);
if (followerInboxes.length === 0) {
return; // No remote followers to notify
}
const createActivity = createCreateActivity(post, user, nodeDomain);
const privateKey = user.privateKeyEncrypted;
if (!privateKey) {
console.error('[Federation] User has no private key for signing');
return;
}
const keyId = `https://${nodeDomain}/users/${user.handle}#main-key`;
const result = await deliverToFollowers(createActivity, followerInboxes, privateKey, keyId);
console.log(`[Federation] Post ${post.id} delivered to ${result.delivered}/${followerInboxes.length} inboxes (${result.failed} failed)`);
} catch (err) {
console.error('[Federation] Error federating post:', err);
}
})();
return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
} catch (error) {
console.error('Create post error:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
);
}
}
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
const normalizeForDedup = (content: string): string => {
return content
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like &lsquo;
.replace(/&#\d+;/g, '') // Remove numeric entities
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
.replace(/[^\w\s]/g, '') // Remove punctuation
.replace(/\s+/g, ' ') // Normalize whitespace
.toLowerCase()
.trim()
.slice(0, 50); // Compare first 50 chars (article title)
};
// Helper to transform cached remote posts to match local post format
// Deduplicates by apId AND by similar content from same author
const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]) => {
const seenApIds = new Set<string>();
const seenContentKeys = new Set<string>(); // author+normalizedContent
const uniquePosts: typeof remotePosts.$inferSelect[] = [];
for (const rp of remotePostsData) {
if (seenApIds.has(rp.apId)) continue;
// Content-based dedup: same author + similar content = skip
const contentKey = `${rp.authorHandle}:${normalizeForDedup(rp.content)}`;
if (seenContentKeys.has(contentKey)) continue;
seenApIds.add(rp.apId);
seenContentKeys.add(contentKey);
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 {
// Return empty posts if no database is connected (for UI testing)
if (!db) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const { searchParams } = new URL(request.url);
const type = searchParams.get('type') || 'home'; // home, public, user, curated
const userId = searchParams.get('userId');
const cursor = searchParams.get('cursor');
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
let feedPosts;
const baseFilter = buildWhere(
eq(posts.isRemoved, false)
);
if (type === 'local') {
// Local node posts only - no fediverse content
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
} else if (type === 'public') {
// Public timeline - all local posts + all cached remote posts
const localPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
orderBy: [desc(posts.createdAt)],
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({
where: buildWhere(baseFilter, eq(posts.userId, userId)),
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
} else if (type === 'curated') {
// Curated feed - swarm posts only (no fediverse)
let viewer = null;
let includeNsfw = false;
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
viewer = session?.user || null;
includeNsfw = session?.user?.nsfwEnabled ?? false;
} catch {
viewer = null;
includeNsfw = false;
}
// Fetch swarm posts with user's NSFW preference
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
// 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,
repostsCount: sp.repostCount,
repliesCount: sp.replyCount,
isSwarm: true,
nodeDomain: sp.nodeDomain,
author: {
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
handle: sp.author.handle,
displayName: sp.author.displayName,
avatarUrl: sp.author.avatarUrl,
isSwarm: true,
nodeDomain: sp.nodeDomain,
},
media: sp.media?.map((m, idx) => ({
id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`,
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,
}));
let mutedIds = new Set<string>();
let blockedIds = new Set<string>();
if (viewer) {
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
.from(mutes)
.where(eq(mutes.userId, viewer.id));
mutedIds = new Set(muteRows.map(row => row.mutedUserId));
const blockRows = await db.select({ blockedUserId: blocks.blockedUserId })
.from(blocks)
.where(eq(blocks.userId, viewer.id));
blockedIds = new Set(blockRows.map(row => row.blockedUserId));
}
const now = Date.now();
const rankedPosts = swarmPosts
.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 || 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 score = engagementScore * 1.4 + recencyScore * 1.1;
const reasons: string[] = [];
reasons.push(`From ${post.nodeDomain}`);
if (engagement >= 5) {
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) {
reasons.push('Posted recently');
} else if (ageHours <= 24) {
reasons.push('Posted today');
}
if (reasons.length === 1) {
reasons.push('New post');
}
return {
...post,
feedMeta: {
score: Number(score.toFixed(3)),
reasons,
engagement: {
likes: post.likesCount || 0,
reposts: post.repostsCount || 0,
replies: post.repliesCount || 0,
},
},
};
})
.sort((a: any, b: any) => {
if (b.feedMeta.score !== a.feedMeta.score) {
return b.feedMeta.score - a.feedMeta.score;
}
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
})
.slice(0, limit);
feedPosts = rankedPosts;
} else {
// Home timeline - need auth
try {
const user = await requireAuth();
// Get IDs of users the current user follows
const followRows = await db.select({ followingId: follows.followingId })
.from(follows)
.where(eq(follows.followerId, user.id));
const followingIds = followRows.map(row => row.followingId);
// Include own posts + posts from followed users
const allowedUserIds = [user.id, ...followingIds];
// Get local posts from people the user follows + their own posts
const localPosts = await db.query.posts.findMany({
where: buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)),
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
orderBy: [desc(posts.createdAt)],
limit: limit * 2, // Get more to account for mixing with remote
});
// Get handles of remote users we follow
const followedRemoteUsers = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
});
// Fetch posts LIVE from followed remote users (in parallel, with timeout)
let liveRemotePosts: any[] = [];
if (followedRemoteUsers.length > 0) {
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
const { resolveRemoteUser } = await import('@/lib/activitypub/fetch');
// Wrap each fetch with a timeout to prevent slow nodes from blocking
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
]);
};
const fetchPromises = followedRemoteUsers.map(async (follow) => {
try {
const atIndex = follow.targetHandle.lastIndexOf('@');
if (atIndex === -1) return [];
const handle = follow.targetHandle.slice(0, atIndex);
const domain = follow.targetHandle.slice(atIndex + 1);
// Check if swarm node - use swarm API (faster)
const isSwarm = await isSwarmNode(domain);
if (isSwarm) {
const profileData = await withTimeout(
fetchSwarmUserProfile(handle, domain, limit),
5000 // 5s timeout per node
);
if (!profileData?.posts) return [];
return profileData.posts.map(post => ({
id: `swarm:${domain}:${post.id}`,
content: post.content,
createdAt: new Date(post.createdAt),
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
isRemote: true,
isNsfw: post.isNsfw,
linkPreviewUrl: post.linkPreviewUrl,
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
author: {
id: `swarm:${domain}:${handle}`,
handle: follow.targetHandle,
displayName: follow.displayName || profileData.profile?.displayName || handle,
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
isRemote: true,
},
media: post.media?.map((m: any, idx: number) => ({
id: `swarm:${domain}:${post.id}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
replyTo: null,
}));
} else {
// ActivityPub - fetch from outbox
const remoteProfile = await resolveRemoteUser(handle, domain);
if (!remoteProfile?.outbox) return [];
// For AP, fall back to cached posts (live outbox fetch is slower)
const cachedPosts = await db.query.remotePosts.findMany({
where: eq(remotePosts.authorHandle, follow.targetHandle),
orderBy: [desc(remotePosts.publishedAt)],
limit: limit,
});
return transformRemotePosts(cachedPosts);
}
} catch (error) {
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
return [];
}
});
const results = await Promise.all(fetchPromises);
liveRemotePosts = results.flat();
}
// Merge and sort by date
const allPosts = [...localPosts, ...liveRemotePosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
feedPosts = allPosts as any;
} catch {
// Not authenticated, return public timeline
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
orderBy: [desc(posts.createdAt)],
limit,
});
}
}
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user;
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);
}
}
// 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, localPostIds)
),
});
viewerLikes.forEach(l => likedPostIds.add(l.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localPostIds)
),
});
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);
}
return NextResponse.json({
posts: feedPosts || [],
meta: type === 'curated' ? {
algorithm: 'curated-v1',
windowHours: CURATION_WINDOW_HOURS,
seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP),
weights: {
engagement: 1.4,
recency: 1.1,
followBoost: 0.9,
selfBoost: 0.5,
},
} : undefined,
nextCursor: (feedPosts?.length === limit) ? feedPosts[feedPosts.length - 1]?.id : null,
});
} catch (error) {
console.error('Get feed error details:', error);
return NextResponse.json(
{ error: 'Failed to get feed' },
{ status: 500 }
);
}
}