feat: Implement infinite scrolling for posts and replies on the main feed and user profile pages using cursor-based pagination.

This commit is contained in:
Christomatt
2026-01-26 14:34:45 +01:00
parent a2ec470354
commit 74ec163625
4 changed files with 249 additions and 50 deletions
+106 -23
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons'; import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
@@ -94,6 +94,10 @@ export default function ProfilePage() {
const [repliesLoading, setRepliesLoading] = useState(false); const [repliesLoading, setRepliesLoading] = useState(false);
const [followersLoading, setFollowersLoading] = useState(false); const [followersLoading, setFollowersLoading] = useState(false);
const [followingLoading, setFollowingLoading] = useState(false); const [followingLoading, setFollowingLoading] = useState(false);
const [postsLoadingMore, setPostsLoadingMore] = useState(false);
const [repliesLoadingMore, setRepliesLoadingMore] = useState(false);
const [postsCursor, setPostsCursor] = useState<string | null>(null);
const [repliesCursor, setRepliesCursor] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [profileForm, setProfileForm] = useState({ const [profileForm, setProfileForm] = useState({
displayName: '', displayName: '',
@@ -130,13 +134,74 @@ export default function ProfilePage() {
.catch(() => setLoading(false)); .catch(() => setLoading(false));
setPostsLoading(true); setPostsLoading(true);
setPostsCursor(null);
setRepliesCursor(null);
fetch(`/api/users/${handle}/posts`) fetch(`/api/users/${handle}/posts`)
.then(res => res.json()) .then(res => res.json())
.then(data => setPosts(data.posts || [])) .then(data => {
setPosts(data.posts || []);
setPostsCursor(data.nextCursor || null);
})
.catch(() => { }) .catch(() => { })
.finally(() => setPostsLoading(false)); .finally(() => setPostsLoading(false));
}, [handle]); }, [handle]);
// Infinite scroll ref
const loadMoreRef = useRef<HTMLDivElement>(null);
// Load more posts
const loadMorePosts = useCallback(async () => {
if (!postsCursor || postsLoadingMore) return;
setPostsLoadingMore(true);
try {
const res = await fetch(`/api/users/${handle}/posts?cursor=${postsCursor}`);
const data = await res.json();
setPosts(prev => [...prev, ...(data.posts || [])]);
setPostsCursor(data.nextCursor || null);
} catch {
// ignore
} finally {
setPostsLoadingMore(false);
}
}, [handle, postsCursor, postsLoadingMore]);
// Load more replies
const loadMoreReplies = useCallback(async () => {
if (!repliesCursor || repliesLoadingMore || !user) return;
setRepliesLoadingMore(true);
try {
const res = await fetch(`/api/posts?type=replies&userId=${user.id}&cursor=${repliesCursor}`);
const data = await res.json();
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
setRepliesCursor(data.nextCursor || null);
} catch {
// ignore
} finally {
setRepliesLoadingMore(false);
}
}, [user, repliesCursor, repliesLoadingMore]);
// Infinite scroll observer
useEffect(() => {
if (!loadMoreRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
if (activeTab === 'posts' && postsCursor && !postsLoadingMore) {
loadMorePosts();
} else if (activeTab === 'replies' && repliesCursor && !repliesLoadingMore) {
loadMoreReplies();
}
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [activeTab, postsCursor, repliesCursor, postsLoadingMore, repliesLoadingMore, loadMorePosts, loadMoreReplies]);
const handleLike = async (postId: string, currentLiked: boolean) => { const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST'; const method = currentLiked ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/like`, { method }); await fetch(`/api/posts/${postId}/like`, { method });
@@ -223,9 +288,13 @@ export default function ProfilePage() {
if (activeTab === 'replies' && user) { if (activeTab === 'replies' && user) {
setRepliesLoading(true); setRepliesLoading(true);
setRepliesCursor(null);
fetch(`/api/posts?type=replies&userId=${user.id}`) fetch(`/api/posts?type=replies&userId=${user.id}`)
.then(res => res.json()) .then(res => res.json())
.then(data => setRepliesPosts(data.posts || [])) .then(data => {
setRepliesPosts(data.posts || []);
setRepliesCursor(data.nextCursor || null);
})
.catch(() => setRepliesPosts([])) .catch(() => setRepliesPosts([]))
.finally(() => setRepliesLoading(false)); .finally(() => setRepliesLoading(false));
} }
@@ -765,16 +834,23 @@ export default function ProfilePage() {
<p>No posts yet</p> <p>No posts yet</p>
</div> </div>
) : ( ) : (
posts.map((post, index) => ( <>
<PostCard {posts.map((post, index) => (
key={`${post.id}-${index}`} <PostCard
post={post} key={`${post.id}-${index}`}
onLike={handleLike} post={post}
onRepost={handleRepost} onLike={handleLike}
onComment={handleComment} onRepost={handleRepost}
onDelete={handleDelete} onComment={handleComment}
/> onDelete={handleDelete}
)) />
))}
<div ref={loadMoreRef} style={{ padding: '24px', textAlign: 'center' }}>
{postsLoadingMore && (
<span style={{ color: 'var(--foreground-tertiary)' }}>Loading more...</span>
)}
</div>
</>
) )
)} )}
@@ -788,16 +864,23 @@ export default function ProfilePage() {
<p>No replies yet</p> <p>No replies yet</p>
</div> </div>
) : ( ) : (
repliesPosts.map((post, index) => ( <>
<PostCard {repliesPosts.map((post, index) => (
key={`${post.id}-${index}`} <PostCard
post={post} key={`${post.id}-${index}`}
onLike={handleLike} post={post}
onRepost={handleRepost} onLike={handleLike}
onComment={handleComment} onRepost={handleRepost}
onDelete={handleDelete} onComment={handleComment}
/> onDelete={handleDelete}
)) />
))}
<div ref={loadMoreRef} style={{ padding: '24px', textAlign: 'center' }}>
{repliesLoadingMore && (
<span style={{ color: 'var(--foreground-tertiary)' }}>Loading more...</span>
)}
</div>
</>
) )
)} )}
+54 -6
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db'; import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db';
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { eq, desc, and, inArray, isNull, isNotNull, notInArray, or } from 'drizzle-orm'; import { eq, desc, and, inArray, isNull, isNotNull, notInArray, or, lt } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
@@ -398,8 +398,20 @@ export async function GET(request: Request) {
if (type === 'local') { if (type === 'local') {
// Local node posts only - no fediverse content // Local node posts only - no fediverse content
let whereCondition = baseFilter;
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt));
}
}
feedPosts = await db.query.posts.findMany({ feedPosts = await db.query.posts.findMany({
where: baseFilter, where: whereCondition,
with: { with: {
author: true, author: true,
bot: true, bot: true,
@@ -441,8 +453,20 @@ export async function GET(request: Request) {
.slice(0, limit) as any; .slice(0, limit) as any;
} else if (type === 'user' && userId) { } else if (type === 'user' && userId) {
// User's posts (excluding replies) // User's posts (excluding replies)
let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId));
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
}
}
feedPosts = await db.query.posts.findMany({ feedPosts = await db.query.posts.findMany({
where: buildWhere(baseFilter, eq(posts.userId, userId)), where: whereCondition,
with: { with: {
author: true, author: true,
bot: true, bot: true,
@@ -456,8 +480,20 @@ export async function GET(request: Request) {
}); });
} else if (type === 'replies' && userId) { } else if (type === 'replies' && userId) {
// User's replies only // User's replies only
let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId));
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
}
}
feedPosts = await db.query.posts.findMany({ feedPosts = await db.query.posts.findMany({
where: buildWhere(repliesFilter, eq(posts.userId, userId)), where: whereCondition,
with: { with: {
author: true, author: true,
bot: true, bot: true,
@@ -598,9 +634,21 @@ export async function GET(request: Request) {
// Include own posts + posts from followed users // Include own posts + posts from followed users
const allowedUserIds = [user.id, ...followingIds]; const allowedUserIds = [user.id, ...followingIds];
// Build where condition with cursor support
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorPost.createdAt));
}
}
// Get local posts from people the user follows + their own posts // Get local posts from people the user follows + their own posts
const localPosts = await db.query.posts.findMany({ const localPosts = await db.query.posts.findMany({
where: buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)), where: whereCondition,
with: { with: {
author: true, author: true,
bot: true, bot: true,
@@ -610,7 +658,7 @@ export async function GET(request: Request) {
}, },
}, },
orderBy: [desc(posts.createdAt)], orderBy: [desc(posts.createdAt)],
limit: limit * 2, // Get more to account for mixing with remote limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote
}); });
// Get handles of remote users we follow // Get handles of remote users we follow
+21 -2
View File
@@ -146,6 +146,7 @@ export async function GET(request: Request, 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 cursor = searchParams.get('cursor');
const remote = parseRemoteHandle(handle); const remote = parseRemoteHandle(handle);
@@ -374,9 +375,27 @@ 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 user's posts // Get user's posts with cursor-based pagination
const { lt } = await import('drizzle-orm');
let whereConditions = and(eq(posts.userId, user.id), eq(posts.isRemoved, false));
// If cursor provided, get posts older than the cursor
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
lt(posts.createdAt, cursorPost.createdAt)
);
}
}
let userPosts: any[] = await db.query.posts.findMany({ let userPosts: any[] = await db.query.posts.findMany({
where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)), where: whereConditions,
with: { with: {
author: true, author: true,
media: true, media: true,
+68 -19
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { PostCard } from '@/components/PostCard'; import { PostCard } from '@/components/PostCard';
@@ -22,6 +22,8 @@ export default function Home() {
const { user } = useAuth(); const { user } = useAuth();
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [replyingTo, setReplyingTo] = useState<Post | null>(null); const [replyingTo, setReplyingTo] = useState<Post | null>(null);
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest'); const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
const [isNsfwNode, setIsNsfwNode] = useState(false); const [isNsfwNode, setIsNsfwNode] = useState(false);
@@ -37,6 +39,8 @@ export default function Home() {
selfBoost: number; selfBoost: number;
}; };
} | null>(null); } | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// Fetch node info to check if NSFW // Fetch node info to check if NSFW
useEffect(() => { useEffect(() => {
@@ -51,26 +55,61 @@ export default function Home() {
}); });
}, []); }, []);
const loadFeed = async (type: 'latest' | 'curated') => { const loadFeed = async (type: 'latest' | 'curated', cursor?: string | null) => {
setLoading(true); if (cursor) {
setLoadingMore(true);
} else {
setLoading(true);
}
try { try {
const endpoint = type === 'curated' ? '/api/posts?type=curated' : '/api/posts?type=home'; const endpoint = type === 'curated'
? `/api/posts?type=curated${cursor ? `&cursor=${cursor}` : ''}`
: `/api/posts?type=home${cursor ? `&cursor=${cursor}` : ''}`;
const res = await fetch(endpoint); const res = await fetch(endpoint);
const data = await res.json(); const data = await res.json();
setPosts(data.posts || []);
if (cursor) {
setPosts(prev => [...prev, ...(data.posts || [])]);
} else {
setPosts(data.posts || []);
}
setFeedMeta(data.meta || null); setFeedMeta(data.meta || null);
setNextCursor(data.nextCursor || null);
} catch { } catch {
setPosts([]); if (!cursor) {
setPosts([]);
}
setFeedMeta(null); setFeedMeta(null);
setNextCursor(null);
} finally { } finally {
setLoading(false); setLoading(false);
setLoadingMore(false);
} }
}; };
useEffect(() => { useEffect(() => {
setPosts([]);
setNextCursor(null);
loadFeed(feedType); loadFeed(feedType);
}, [feedType]); }, [feedType]);
// Infinite scroll observer
useEffect(() => {
if (!loadMoreRef.current || !nextCursor || loadingMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
loadFeed(feedType, nextCursor);
}
},
{ threshold: 0.1 }
);
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [nextCursor, loadingMore, feedType]);
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => { const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => {
// Check if we're replying to a swarm post // Check if we're replying to a swarm post
let swarmReplyTo: { postId: string; nodeDomain: string } | undefined; let swarmReplyTo: { postId: string; nodeDomain: string } | undefined;
@@ -94,6 +133,8 @@ export default function Home() {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (feedType === 'curated') { if (feedType === 'curated') {
setPosts([]);
setNextCursor(null);
loadFeed('curated'); loadFeed('curated');
} else { } else {
setPosts([{ ...data.post, author: user }, ...posts]); setPosts([{ ...data.post, author: user }, ...posts]);
@@ -199,19 +240,27 @@ export default function Home() {
<p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p> <p style={{ fontSize: '13px', marginTop: '8px' }}>Be the first to post something!</p>
</div> </div>
) : ( ) : (
posts.map(post => ( <>
<PostCard {posts.map(post => (
key={post.id} <PostCard
post={post} key={post.id}
onLike={handleLike} post={post}
onRepost={handleRepost} onLike={handleLike}
onDelete={handleDelete} onRepost={handleRepost}
onComment={(p) => { onDelete={handleDelete}
setReplyingTo(p); onComment={(p) => {
window.scrollTo({ top: 0, behavior: 'smooth' }); setReplyingTo(p);
}} window.scrollTo({ top: 0, behavior: 'smooth' });
/> }}
)) />
))}
{/* Infinite scroll trigger */}
<div ref={loadMoreRef} style={{ padding: '24px', textAlign: 'center' }}>
{loadingMore && (
<span style={{ color: 'var(--foreground-tertiary)' }}>Loading more...</span>
)}
</div>
</>
)} )}
</> </>
); );