Fixed post duplication

This commit is contained in:
Christopher
2026-01-22 16:36:32 -08:00
parent d2d8d9b746
commit 7ff8b36ead
4 changed files with 143 additions and 57 deletions
+100 -46
View File
@@ -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<string>();
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<string>();
let followingRemoteHandles = new Set<string>();
let mutedIds = new Set<string>();
let blockedIds = new Set<string>();
@@ -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]
@@ -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 });
}
+31 -10
View File
@@ -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);
+1 -1
View File
@@ -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) {