Fix repeating Home feeds with timestamp cursors across local and swarm sources, remote profile pagination, duplicate filtering, and in-flight request guards.

Hop-State: A_06FPACHVKP3PTEY7DT6NW1R
Hop-Proposal: R_06FPACGSH34JXPKH7QHAXAR
Hop-Task: T_06FPABDPSJFCRPZSG0PXS9G
Hop-Attempt: AT_06FPABDPSG9CRMFEZ4WC35R
This commit is contained in:
2026-07-15 02:53:38 -07:00
committed by Hop
parent a7a430a627
commit 93be1cffd8
6 changed files with 93 additions and 11 deletions
+18 -3
View File
@@ -9,6 +9,7 @@ import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/medi
import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access';
import { isLocalNodeNsfw } from '@/lib/node/local-node';
import { hasPublishablePostContent } from '@/lib/posts/content-policy';
import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination';
const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72;
@@ -84,6 +85,11 @@ async function getMixedFeedCursorDate(cursor: string | null) {
return null;
}
const timestampCursor = decodeFeedCursor(cursor);
if (timestampCursor) {
return timestampCursor;
}
if (cursor.startsWith('swarm-repost:')) {
const repostRow = await db.query.userSwarmReposts.findFirst({
where: { id: cursor.replace('swarm-repost:', '') },
@@ -741,7 +747,11 @@ export async function GET(request: Request) {
// Fetch swarm posts with user's NSFW preference
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
const cursorDate = await getMixedFeedCursorDate(cursor);
const swarmResult = await fetchSwarmTimeline(10, 30, {
includeNsfw,
cursor: cursorDate?.toISOString(),
});
console.log('[Curated Feed] Swarm result:', {
postsCount: swarmResult.posts.length,
@@ -954,13 +964,14 @@ export async function GET(request: Request) {
if (!isSwarm) return [];
const profileData = await withTimeout(
fetchSwarmUserProfile(handle, domain, limit),
fetchSwarmUserProfile(handle, domain, limit, cursorDate?.toISOString()),
5000 // 5s timeout per node
);
if (!profileData?.posts) return [];
return profileData.posts
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
.filter((post: any) => !cursorDate || new Date(post.createdAt) < cursorDate)
.map((post: any) => mapRemoteProfilePost({
...post,
author: post.author || {
@@ -1103,7 +1114,11 @@ export async function GET(request: Request) {
selfBoost: 0.5,
},
} : undefined,
nextCursor: (feedPosts?.length === limit) ? feedPosts[feedPosts.length - 1]?.id : null,
nextCursor: (feedPosts?.length === limit)
? (type === 'home' || type === 'curated'
? encodeFeedCursor(feedPosts[feedPosts.length - 1]?.createdAt)
: feedPosts[feedPosts.length - 1]?.id)
: null,
});
} catch (error) {
console.error('Get feed error details:', error);
+18 -2
View File
@@ -189,6 +189,11 @@ export async function GET(request: NextRequest, context: RouteContext) {
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const cursorValue = searchParams.get('cursor');
const parsedCursorDate = cursorValue ? new Date(cursorValue) : null;
const cursorDate = parsedCursorDate && !Number.isNaN(parsedCursorDate.getTime())
? parsedCursorDate
: null;
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
@@ -240,14 +245,25 @@ export async function GET(request: NextRequest, context: RouteContext) {
};
const localPosts = await db.query.posts.findMany({
where: { AND: [{ userId: user.id }, { isRemoved: false }, { replyToId: { isNull: true } }, { swarmReplyToId: { isNull: true } }] },
where: {
AND: [
{ userId: user.id },
{ isRemoved: false },
{ replyToId: { isNull: true } },
{ swarmReplyToId: { isNull: true } },
...(cursorDate ? [{ createdAt: { lt: cursorDate } }] : []),
],
},
with: profilePostRelations,
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: limit * 2,
});
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
where: { userId: user.id },
where: {
userId: user.id,
...(cursorDate ? { repostedAt: { lt: cursorDate } } : {}),
},
orderBy: (userSwarmReposts, { desc }) => [desc(userSwarmReposts.repostedAt)],
limit: limit * 2,
});
+19 -4
View File
@@ -42,6 +42,7 @@ export default function Home() {
} | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const loadingCursorRef = useRef<string | null>(null);
// Redirect unauthenticated users to explore page
useEffect(() => {
@@ -57,6 +58,9 @@ export default function Home() {
}, [feedType]);
const loadFeed = async (type: 'following' | 'curated', cursor?: string | null) => {
if (cursor && loadingCursorRef.current === cursor) return;
if (cursor) loadingCursorRef.current = cursor;
if (cursor) {
setLoadingMore(true);
} else {
@@ -64,8 +68,8 @@ export default function Home() {
}
try {
const endpoint = type === 'curated'
? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}`
: `/api/posts?type=home${cursor ? `&cursor=${cursor}` : ''}`;
? `/api/posts?type=curated${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`
: `/api/posts?type=home${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
const res = await fetch(endpoint);
const data = await res.json();
@@ -74,12 +78,20 @@ export default function Home() {
if (type !== feedTypeRef.current) return;
if (cursor) {
setPosts(prev => [...prev, ...(data.posts || [])]);
setPosts(prev => {
const seen = new Set(prev.map(post => post.id));
const newPosts = (data.posts || []).filter((post: Post) => {
if (seen.has(post.id)) return false;
seen.add(post.id);
return true;
});
return [...prev, ...newPosts];
});
} else {
setPosts(data.posts || []);
}
setFeedMeta(data.meta || null);
setNextCursor(data.nextCursor || null);
setNextCursor(data.nextCursor && data.nextCursor !== cursor ? data.nextCursor : null);
} catch {
if (type !== feedTypeRef.current) return;
@@ -93,6 +105,9 @@ export default function Home() {
setLoading(false);
setLoadingMore(false);
}
if (cursor && loadingCursorRef.current === cursor) {
loadingCursorRef.current = null;
}
}
};