diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index 3187537..2b64cf0 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons'; @@ -94,6 +94,10 @@ export default function ProfilePage() { const [repliesLoading, setRepliesLoading] = useState(false); const [followersLoading, setFollowersLoading] = useState(false); const [followingLoading, setFollowingLoading] = useState(false); + const [postsLoadingMore, setPostsLoadingMore] = useState(false); + const [repliesLoadingMore, setRepliesLoadingMore] = useState(false); + const [postsCursor, setPostsCursor] = useState(null); + const [repliesCursor, setRepliesCursor] = useState(null); const [isEditing, setIsEditing] = useState(false); const [profileForm, setProfileForm] = useState({ displayName: '', @@ -130,13 +134,74 @@ export default function ProfilePage() { .catch(() => setLoading(false)); setPostsLoading(true); + setPostsCursor(null); + setRepliesCursor(null); fetch(`/api/users/${handle}/posts`) .then(res => res.json()) - .then(data => setPosts(data.posts || [])) + .then(data => { + setPosts(data.posts || []); + setPostsCursor(data.nextCursor || null); + }) .catch(() => { }) .finally(() => setPostsLoading(false)); }, [handle]); + // Infinite scroll ref + const loadMoreRef = useRef(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 method = currentLiked ? 'DELETE' : 'POST'; await fetch(`/api/posts/${postId}/like`, { method }); @@ -223,9 +288,13 @@ export default function ProfilePage() { if (activeTab === 'replies' && user) { setRepliesLoading(true); + setRepliesCursor(null); fetch(`/api/posts?type=replies&userId=${user.id}`) .then(res => res.json()) - .then(data => setRepliesPosts(data.posts || [])) + .then(data => { + setRepliesPosts(data.posts || []); + setRepliesCursor(data.nextCursor || null); + }) .catch(() => setRepliesPosts([])) .finally(() => setRepliesLoading(false)); } @@ -765,16 +834,23 @@ export default function ProfilePage() {

No posts yet

) : ( - posts.map((post, index) => ( - - )) + <> + {posts.map((post, index) => ( + + ))} +
+ {postsLoadingMore && ( + Loading more... + )} +
+ ) )} @@ -788,16 +864,23 @@ export default function ProfilePage() {

No replies yet

) : ( - repliesPosts.map((post, index) => ( - - )) + <> + {repliesPosts.map((post, index) => ( + + ))} +
+ {repliesLoadingMore && ( + Loading more... + )} +
+ ) )} diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 86e1952..54045db 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db'; 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 { z } from 'zod'; @@ -398,8 +398,20 @@ export async function GET(request: Request) { if (type === 'local') { // 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({ - where: baseFilter, + where: whereCondition, with: { author: true, bot: true, @@ -441,8 +453,20 @@ export async function GET(request: Request) { .slice(0, limit) as any; } else if (type === 'user' && userId) { // 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({ - where: buildWhere(baseFilter, eq(posts.userId, userId)), + where: whereCondition, with: { author: true, bot: true, @@ -456,8 +480,20 @@ export async function GET(request: Request) { }); } else if (type === 'replies' && userId) { // 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({ - where: buildWhere(repliesFilter, eq(posts.userId, userId)), + where: whereCondition, with: { author: true, bot: true, @@ -598,9 +634,21 @@ export async function GET(request: Request) { // Include own posts + posts from followed users 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 const localPosts = await db.query.posts.findMany({ - where: buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)), + where: whereCondition, with: { author: true, bot: true, @@ -610,7 +658,7 @@ export async function GET(request: Request) { }, }, 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 diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index b219b28..134e3ae 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -146,6 +146,7 @@ export async function GET(request: Request, context: RouteContext) { const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const { searchParams } = new URL(request.url); const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); + const cursor = searchParams.get('cursor'); 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 }); } - // 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({ - where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)), + where: whereConditions, with: { author: true, media: true, diff --git a/src/app/page.tsx b/src/app/page.tsx index 544cc8f..d63796c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import Link from 'next/link'; import { useAuth } from '@/lib/contexts/AuthContext'; import { PostCard } from '@/components/PostCard'; @@ -22,6 +22,8 @@ export default function Home() { const { user } = useAuth(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); const [replyingTo, setReplyingTo] = useState(null); const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest'); const [isNsfwNode, setIsNsfwNode] = useState(false); @@ -37,6 +39,8 @@ export default function Home() { selfBoost: number; }; } | null>(null); + + const loadMoreRef = useRef(null); // Fetch node info to check if NSFW useEffect(() => { @@ -51,26 +55,61 @@ export default function Home() { }); }, []); - const loadFeed = async (type: 'latest' | 'curated') => { - setLoading(true); + const loadFeed = async (type: 'latest' | 'curated', cursor?: string | null) => { + if (cursor) { + setLoadingMore(true); + } else { + setLoading(true); + } 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 data = await res.json(); - setPosts(data.posts || []); + + if (cursor) { + setPosts(prev => [...prev, ...(data.posts || [])]); + } else { + setPosts(data.posts || []); + } setFeedMeta(data.meta || null); + setNextCursor(data.nextCursor || null); } catch { - setPosts([]); + if (!cursor) { + setPosts([]); + } setFeedMeta(null); + setNextCursor(null); } finally { setLoading(false); + setLoadingMore(false); } }; useEffect(() => { + setPosts([]); + setNextCursor(null); loadFeed(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) => { // Check if we're replying to a swarm post let swarmReplyTo: { postId: string; nodeDomain: string } | undefined; @@ -94,6 +133,8 @@ export default function Home() { if (res.ok) { const data = await res.json(); if (feedType === 'curated') { + setPosts([]); + setNextCursor(null); loadFeed('curated'); } else { setPosts([{ ...data.post, author: user }, ...posts]); @@ -199,19 +240,27 @@ export default function Home() {

Be the first to post something!

) : ( - posts.map(post => ( - { - setReplyingTo(p); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }} - /> - )) + <> + {posts.map(post => ( + { + setReplyingTo(p); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} + /> + ))} + {/* Infinite scroll trigger */} +
+ {loadingMore && ( + Loading more... + )} +
+ )} );