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:
@@ -9,6 +9,7 @@ import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/medi
|
|||||||
import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access';
|
import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access';
|
||||||
import { isLocalNodeNsfw } from '@/lib/node/local-node';
|
import { isLocalNodeNsfw } from '@/lib/node/local-node';
|
||||||
import { hasPublishablePostContent } from '@/lib/posts/content-policy';
|
import { hasPublishablePostContent } from '@/lib/posts/content-policy';
|
||||||
|
import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination';
|
||||||
|
|
||||||
const POST_MAX_LENGTH = 600;
|
const POST_MAX_LENGTH = 600;
|
||||||
const CURATION_WINDOW_HOURS = 72;
|
const CURATION_WINDOW_HOURS = 72;
|
||||||
@@ -84,6 +85,11 @@ async function getMixedFeedCursorDate(cursor: string | null) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timestampCursor = decodeFeedCursor(cursor);
|
||||||
|
if (timestampCursor) {
|
||||||
|
return timestampCursor;
|
||||||
|
}
|
||||||
|
|
||||||
if (cursor.startsWith('swarm-repost:')) {
|
if (cursor.startsWith('swarm-repost:')) {
|
||||||
const repostRow = await db.query.userSwarmReposts.findFirst({
|
const repostRow = await db.query.userSwarmReposts.findFirst({
|
||||||
where: { id: cursor.replace('swarm-repost:', '') },
|
where: { id: cursor.replace('swarm-repost:', '') },
|
||||||
@@ -741,7 +747,11 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
// Fetch swarm posts with user's NSFW preference
|
// Fetch swarm posts with user's NSFW preference
|
||||||
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
|
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:', {
|
console.log('[Curated Feed] Swarm result:', {
|
||||||
postsCount: swarmResult.posts.length,
|
postsCount: swarmResult.posts.length,
|
||||||
@@ -954,13 +964,14 @@ export async function GET(request: Request) {
|
|||||||
if (!isSwarm) return [];
|
if (!isSwarm) return [];
|
||||||
|
|
||||||
const profileData = await withTimeout(
|
const profileData = await withTimeout(
|
||||||
fetchSwarmUserProfile(handle, domain, limit),
|
fetchSwarmUserProfile(handle, domain, limit, cursorDate?.toISOString()),
|
||||||
5000 // 5s timeout per node
|
5000 // 5s timeout per node
|
||||||
);
|
);
|
||||||
if (!profileData?.posts) return [];
|
if (!profileData?.posts) return [];
|
||||||
|
|
||||||
return profileData.posts
|
return profileData.posts
|
||||||
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
|
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
|
||||||
|
.filter((post: any) => !cursorDate || new Date(post.createdAt) < cursorDate)
|
||||||
.map((post: any) => mapRemoteProfilePost({
|
.map((post: any) => mapRemoteProfilePost({
|
||||||
...post,
|
...post,
|
||||||
author: post.author || {
|
author: post.author || {
|
||||||
@@ -1103,7 +1114,11 @@ export async function GET(request: Request) {
|
|||||||
selfBoost: 0.5,
|
selfBoost: 0.5,
|
||||||
},
|
},
|
||||||
} : undefined,
|
} : 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) {
|
} catch (error) {
|
||||||
console.error('Get feed error details:', error);
|
console.error('Get feed error details:', error);
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
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) {
|
if (!db) {
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
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({
|
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,
|
with: profilePostRelations,
|
||||||
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
|
||||||
limit: limit * 2,
|
limit: limit * 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
|
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)],
|
orderBy: (userSwarmReposts, { desc }) => [desc(userSwarmReposts.repostedAt)],
|
||||||
limit: limit * 2,
|
limit: limit * 2,
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-4
@@ -42,6 +42,7 @@ export default function Home() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadingCursorRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Redirect unauthenticated users to explore page
|
// Redirect unauthenticated users to explore page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,6 +58,9 @@ export default function Home() {
|
|||||||
}, [feedType]);
|
}, [feedType]);
|
||||||
|
|
||||||
const loadFeed = async (type: 'following' | 'curated', cursor?: string | null) => {
|
const loadFeed = async (type: 'following' | 'curated', cursor?: string | null) => {
|
||||||
|
if (cursor && loadingCursorRef.current === cursor) return;
|
||||||
|
if (cursor) loadingCursorRef.current = cursor;
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -64,8 +68,8 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const endpoint = type === 'curated'
|
const endpoint = type === 'curated'
|
||||||
? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}`
|
? `/api/posts?type=curated${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`
|
||||||
: `/api/posts?type=home${cursor ? `&cursor=${cursor}` : ''}`;
|
: `/api/posts?type=home${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
||||||
|
|
||||||
const res = await fetch(endpoint);
|
const res = await fetch(endpoint);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -74,12 +78,20 @@ export default function Home() {
|
|||||||
if (type !== feedTypeRef.current) return;
|
if (type !== feedTypeRef.current) return;
|
||||||
|
|
||||||
if (cursor) {
|
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 {
|
} else {
|
||||||
setPosts(data.posts || []);
|
setPosts(data.posts || []);
|
||||||
}
|
}
|
||||||
setFeedMeta(data.meta || null);
|
setFeedMeta(data.meta || null);
|
||||||
setNextCursor(data.nextCursor || null);
|
setNextCursor(data.nextCursor && data.nextCursor !== cursor ? data.nextCursor : null);
|
||||||
} catch {
|
} catch {
|
||||||
if (type !== feedTypeRef.current) return;
|
if (type !== feedTypeRef.current) return;
|
||||||
|
|
||||||
@@ -93,6 +105,9 @@ export default function Home() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
|
if (cursor && loadingCursorRef.current === cursor) {
|
||||||
|
loadingCursorRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decodeFeedCursor, encodeFeedCursor } from './feed-pagination';
|
||||||
|
|
||||||
|
describe('feed pagination cursors', () => {
|
||||||
|
it('round-trips a post timestamp', () => {
|
||||||
|
const timestamp = '2026-07-15T08:30:12.345Z';
|
||||||
|
const cursor = encodeFeedCursor(timestamp);
|
||||||
|
|
||||||
|
expect(cursor).toBe('feed:1784104212345');
|
||||||
|
expect(decodeFeedCursor(cursor)?.toISOString()).toBe(timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects IDs and malformed cursors', () => {
|
||||||
|
expect(decodeFeedCursor('swarm:example.com:post-id')).toBeNull();
|
||||||
|
expect(decodeFeedCursor('feed:not-a-number')).toBeNull();
|
||||||
|
expect(encodeFeedCursor('not-a-date')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const FEED_CURSOR_PREFIX = 'feed:';
|
||||||
|
|
||||||
|
export function encodeFeedCursor(value: string | number | Date | null | undefined): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? `${FEED_CURSOR_PREFIX}${timestamp}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeFeedCursor(cursor: string | null): Date | null {
|
||||||
|
if (!cursor?.startsWith(FEED_CURSOR_PREFIX)) return null;
|
||||||
|
|
||||||
|
const timestamp = Number(cursor.slice(FEED_CURSOR_PREFIX.length));
|
||||||
|
if (!Number.isFinite(timestamp)) return null;
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
@@ -396,7 +396,8 @@ export interface SwarmProfileResponse {
|
|||||||
export async function fetchSwarmUserProfile(
|
export async function fetchSwarmUserProfile(
|
||||||
handle: string,
|
handle: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
postsLimit: number = 25
|
postsLimit: number = 25,
|
||||||
|
cursor?: string
|
||||||
): Promise<SwarmProfileResponse | null> {
|
): Promise<SwarmProfileResponse | null> {
|
||||||
try {
|
try {
|
||||||
const normalizedDomain = normalizeNodeDomain(domain);
|
const normalizedDomain = normalizeNodeDomain(domain);
|
||||||
@@ -410,7 +411,7 @@ export async function fetchSwarmUserProfile(
|
|||||||
? `http://${normalizedDomain}`
|
? `http://${normalizedDomain}`
|
||||||
: `https://${normalizedDomain}`;
|
: `https://${normalizedDomain}`;
|
||||||
|
|
||||||
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
|
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|||||||
Reference in New Issue
Block a user