diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 9cbdab0..48877e3 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -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); diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts index 15cd7ef..1cd3e7a 100644 --- a/src/app/api/swarm/users/[handle]/route.ts +++ b/src/app/api/swarm/users/[handle]/route.ts @@ -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, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 2ed2276..fcd1af2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -42,6 +42,7 @@ export default function Home() { } | null>(null); const loadMoreRef = useRef(null); + const loadingCursorRef = useRef(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; + } } }; diff --git a/src/lib/posts/feed-pagination.test.ts b/src/lib/posts/feed-pagination.test.ts new file mode 100644 index 0000000..671c3ac --- /dev/null +++ b/src/lib/posts/feed-pagination.test.ts @@ -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(); + }); +}); diff --git a/src/lib/posts/feed-pagination.ts b/src/lib/posts/feed-pagination.ts new file mode 100644 index 0000000..e57e541 --- /dev/null +++ b/src/lib/posts/feed-pagination.ts @@ -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; +} diff --git a/src/lib/swarm/interactions.ts b/src/lib/swarm/interactions.ts index 5b95a38..1a3c7b1 100644 --- a/src/lib/swarm/interactions.ts +++ b/src/lib/swarm/interactions.ts @@ -396,7 +396,8 @@ export interface SwarmProfileResponse { export async function fetchSwarmUserProfile( handle: string, domain: string, - postsLimit: number = 25 + postsLimit: number = 25, + cursor?: string ): Promise { try { const normalizedDomain = normalizeNodeDomain(domain); @@ -410,7 +411,7 @@ export async function fetchSwarmUserProfile( ? `http://${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 timeout = setTimeout(() => controller.abort(), 10000);