Fixed post duplication
This commit is contained in:
+100
-46
@@ -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
|
// Get timeline / feed
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -133,8 +178,8 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
// Public timeline - all posts
|
// Public timeline - all local posts + all cached remote posts
|
||||||
feedPosts = await db.query.posts.findMany({
|
const localPosts = await db.query.posts.findMany({
|
||||||
where: baseFilter,
|
where: baseFilter,
|
||||||
with: {
|
with: {
|
||||||
author: true,
|
author: true,
|
||||||
@@ -144,8 +189,21 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [desc(posts.createdAt)],
|
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) {
|
} else if (type === 'user' && userId) {
|
||||||
// User's posts
|
// User's posts
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
@@ -184,7 +242,18 @@ export async function GET(request: Request) {
|
|||||||
limit: seedLimit,
|
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 followingIds = new Set<string>();
|
||||||
|
let followingRemoteHandles = new Set<string>();
|
||||||
let mutedIds = new Set<string>();
|
let mutedIds = new Set<string>();
|
||||||
let blockedIds = new Set<string>();
|
let blockedIds = new Set<string>();
|
||||||
|
|
||||||
@@ -194,6 +263,12 @@ export async function GET(request: Request) {
|
|||||||
.where(eq(follows.followerId, viewer.id));
|
.where(eq(follows.followerId, viewer.id));
|
||||||
followingIds = new Set(followRows.map(row => row.followingId));
|
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 })
|
const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
|
||||||
.from(mutes)
|
.from(mutes)
|
||||||
.where(eq(mutes.userId, viewer.id));
|
.where(eq(mutes.userId, viewer.id));
|
||||||
@@ -206,27 +281,35 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rankedPosts = seedPosts
|
const rankedPosts = allSeedPosts
|
||||||
.filter(post => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
.filter((post: any) => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
|
||||||
.map(post => {
|
.map((post: any) => {
|
||||||
const createdAt = new Date(post.createdAt).getTime();
|
const createdAt = new Date(post.createdAt).getTime();
|
||||||
const ageHours = Math.max(0, (now - createdAt) / 3600000);
|
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 engagementScore = Math.log1p(Math.max(0, engagement));
|
||||||
const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
|
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 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[] = [];
|
const reasons: string[] = [];
|
||||||
|
if (isRemote) {
|
||||||
|
reasons.push(`From the fediverse`);
|
||||||
|
}
|
||||||
if (followBoost > 0) {
|
if (followBoost > 0) {
|
||||||
reasons.push(`You follow @${post.author.handle}`);
|
reasons.push(`You follow @${post.author.handle}`);
|
||||||
}
|
}
|
||||||
if (engagement >= 5) {
|
if (engagement >= 5) {
|
||||||
reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`);
|
reasons.push(`Popular: ${post.likesCount || 0} likes, ${post.repostsCount || 0} reposts`);
|
||||||
} else if (post.repliesCount > 0) {
|
} else if ((post.repliesCount || 0) > 0) {
|
||||||
reasons.push(`Active conversation: ${post.repliesCount} replies`);
|
reasons.push(`Active conversation: ${post.repliesCount} replies`);
|
||||||
}
|
}
|
||||||
if (ageHours <= 6) {
|
if (ageHours <= 6) {
|
||||||
@@ -246,14 +329,14 @@ export async function GET(request: Request) {
|
|||||||
score: Number(score.toFixed(3)),
|
score: Number(score.toFixed(3)),
|
||||||
reasons,
|
reasons,
|
||||||
engagement: {
|
engagement: {
|
||||||
likes: post.likesCount,
|
likes: post.likesCount || 0,
|
||||||
reposts: post.repostsCount,
|
reposts: post.repostsCount || 0,
|
||||||
replies: post.repliesCount,
|
replies: post.repliesCount || 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a: any, b: any) => {
|
||||||
if (b.feedMeta.score !== a.feedMeta.score) {
|
if (b.feedMeta.score !== a.feedMeta.score) {
|
||||||
return 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
|
// Transform remote posts to match local post format (with deduplication)
|
||||||
const transformedRemotePosts = remotePostsData.map(rp => {
|
const transformedRemotePosts = transformRemotePosts(remotePostsData);
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge and sort by date
|
// Merge and sort by date
|
||||||
const allPosts = [...localPosts, ...transformedRemotePosts]
|
const allPosts = [...localPosts, ...transformedRemotePosts]
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
activityId,
|
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
|
// Cache the remote user's recent posts in the background
|
||||||
const origin = new URL(request.url).origin;
|
const origin = new URL(request.url).origin;
|
||||||
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
|
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 });
|
return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 });
|
||||||
}
|
}
|
||||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
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 });
|
return NextResponse.json({ success: true, following: false, remote: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, follows, users } from '@/db';
|
import { db, follows, users, remoteFollows } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
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 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get following
|
// Get local following
|
||||||
const userFollowing = await db.query.follows.findMany({
|
const userFollowing = await db.query.follows.findMany({
|
||||||
where: eq(follows.followerId, user.id),
|
where: eq(follows.followerId, user.id),
|
||||||
with: {
|
with: {
|
||||||
@@ -37,15 +37,36 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
const localFollowing = userFollowing.map(f => ({
|
||||||
following: userFollowing.map(f => ({
|
|
||||||
id: f.following.id,
|
id: f.following.id,
|
||||||
handle: f.following.handle,
|
handle: f.following.handle,
|
||||||
displayName: f.following.displayName,
|
displayName: f.following.displayName,
|
||||||
avatarUrl: f.following.avatarUrl,
|
avatarUrl: f.following.avatarUrl,
|
||||||
bio: f.following.bio,
|
bio: f.following.bio,
|
||||||
})),
|
isRemote: false,
|
||||||
nextCursor: userFollowing.length === limit ? userFollowing[userFollowing.length - 1]?.id : null,
|
}));
|
||||||
|
|
||||||
|
// 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: allFollowing,
|
||||||
|
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get following error:', error);
|
console.error('Get following error:', error);
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export async function cacheRemoteUserPosts(
|
|||||||
linkPreviewDescription: linkPreview?.description || null,
|
linkPreviewDescription: linkPreview?.description || null,
|
||||||
linkPreviewImage: linkPreview?.image || null,
|
linkPreviewImage: linkPreview?.image || null,
|
||||||
mediaJson: mediaJson.length > 0 ? JSON.stringify(mediaJson) : null,
|
mediaJson: mediaJson.length > 0 ? JSON.stringify(mediaJson) : null,
|
||||||
});
|
}).onConflictDoNothing();
|
||||||
|
|
||||||
cached++;
|
cached++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user