diff --git a/assets/js/components/users.tsx b/assets/js/components/users.tsx index ea2228c..41f57c1 100644 --- a/assets/js/components/users.tsx +++ b/assets/js/components/users.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect, useContext } from "react"; import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc"; import { AuthCtx } from "../context"; -import { FEED_PAGE_SIZE } from "../constants"; +import { FEED_PAGE_SIZE, USERS_PAGE_SIZE } from "../constants"; import { userDisplayLabel } from "../utils"; import { useFollowUser } from "../hooks"; import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui"; @@ -83,23 +83,54 @@ export function UserCard({ user }: { user: User }) { } export function UserList() { - const { data, isLoading, isError, error } = useQuery({ + const sentinelRef = useRef(null); + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: ["users"], - queryFn: async () => { + queryFn: async ({ pageParam }: { pageParam: number }) => { const res = await readUser({ fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"], + sort: "username", + page: { limit: USERS_PAGE_SIZE, offset: pageParam }, headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load users"); - const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; - return users as User[]; + const pageData = res.data as any; + const users: User[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []); + const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false); + return { users, hasMore, nextOffset: pageParam + USERS_PAGE_SIZE }; }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined, }); + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + if (isLoading) return ; if (isError) return ; - const users = data ?? []; + const users = data?.pages.flatMap((p) => p.users) ?? []; if (users.length === 0) { return ( @@ -116,6 +147,8 @@ export function UserList() { {users.map((u) => ( ))} +
+ {isFetchingNextPage && }
); } diff --git a/assets/js/constants.ts b/assets/js/constants.ts index f13a770..7b2c3fd 100644 --- a/assets/js/constants.ts +++ b/assets/js/constants.ts @@ -1,2 +1,3 @@ export const FEED_PAGE_SIZE = 10; export const COMMENTS_PAGE_SIZE = 10; +export const USERS_PAGE_SIZE = 20; diff --git a/fix_plan.md b/fix_plan.md index 1a7862b..954bccc 100644 --- a/fix_plan.md +++ b/fix_plan.md @@ -13,13 +13,12 @@ - [x] Self-follow validation used `get_attribute(:follower_id)` which is nil at validation time (relate_actor runs after) — fixed to use `context.actor.id` - [x] Follow/unfollow test coverage (9 tests) -- [ ] No pagination on user list (`/users`) +- [x] User list pagination — useInfiniteQuery + scroll sentinel, USERS_PAGE_SIZE=20, sorted by username - [ ] No CHECK constraint on `likes >= 0` at DB level (low priority, app logic prevents it) - [ ] `read :following_feed` — nil actor returns empty list (not a bug) - [ ] No search for users or tweets - [x] Tweet creation, update, delete, comment tests (13 tests) - [ ] Missing test coverage: auth flows -- [ ] No pagination on user list (`/users`) ## Notes