fix(tinyplace): paginate the Agent World feed (Closes #4923) (#4989)

This commit is contained in:
Mega Mind
2026-07-17 15:11:13 +03:00
committed by GitHub
parent b489ead52e
commit 94199cf4ba
16 changed files with 312 additions and 15 deletions
+121 -1
View File
@@ -17,7 +17,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { PaymentRequiredError } from '../../lib/agentworld/invokeApiClient';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import FeedSection from './FeedSection';
import FeedSection, { FEED_PAGE_SIZE } from './FeedSection';
vi.mock('../AgentWorldShell', () => ({
apiClient: {
@@ -739,3 +739,123 @@ describe('delete actions', () => {
expect(vi.mocked(apiClient.graphql.post)).toHaveBeenCalledTimes(2);
});
});
// ── Pagination (offset-based "Load more", #4923) ─────────────────────────────
/** Build a home-feed page of `n` items with sequential ids from `start`. */
function buildFeedPage(n: number, start = 0) {
const items = Array.from({ length: n }, (_, i) => {
const idx = start + i;
return {
...sampleFeedItem,
post: {
...samplePost,
postId: `post-${String(idx).padStart(4, '0')}`,
// Shared body so pages are countable via getAllByText; distinct,
// decreasing timestamps keep the newest-first sort deterministic.
body: 'PAGEDPOST',
createdAt: new Date(Date.UTC(2026, 0, 1) - idx * 60_000).toISOString(),
},
};
});
return { items, count: 1000 };
}
describe('Feed pagination', () => {
test('requests the first page with limit + offset 0', async () => {
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue({ items: [sampleFeedItem], count: 1 });
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByText(samplePost.body)).toBeInTheDocument();
});
expect(apiClient.graphql.homeFeed).toHaveBeenNthCalledWith(1, {
limit: FEED_PAGE_SIZE,
offset: 0,
includeSelf: true,
});
});
test('hides Load more when the first page is shorter than a full page', async () => {
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue(buildFeedPage(3));
render(<FeedSection />);
await waitFor(() => {
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(3);
});
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
});
test('shows Load more when the first page fills the page size', async () => {
vi.mocked(apiClient.graphql.homeFeed).mockResolvedValue(buildFeedPage(FEED_PAGE_SIZE));
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument();
});
});
test('clicking Load more fetches the next offset, appends items, then stops', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed)
.mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0))
.mockResolvedValueOnce(buildFeedPage(3, FEED_PAGE_SIZE));
render(<FeedSection />);
await waitFor(() => {
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE);
});
await user.click(screen.getByRole('button', { name: /load more/i }));
// Second page appended (50 + 3 = 53) and the control disappears because the
// short page signals the feed is exhausted.
await waitFor(() => {
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE + 3);
});
expect(apiClient.graphql.homeFeed).toHaveBeenNthCalledWith(2, {
limit: FEED_PAGE_SIZE,
offset: FEED_PAGE_SIZE,
includeSelf: true,
});
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
});
test('deduplicates overlapping items across pages', async () => {
const user = userEvent.setup();
// Second page repeats the last id of the first page (post-0049) plus new rows.
vi.mocked(apiClient.graphql.homeFeed)
.mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0))
.mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, FEED_PAGE_SIZE - 1));
render(<FeedSection />);
await waitFor(() => {
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE);
});
await user.click(screen.getByRole('button', { name: /load more/i }));
// 50 initial + 50 returned 1 overlapping (post-0049) = 99 unique items.
await waitFor(() => {
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(2 * FEED_PAGE_SIZE - 1);
});
});
test('keeps items and surfaces an error when Load more fails', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.homeFeed)
.mockResolvedValueOnce(buildFeedPage(FEED_PAGE_SIZE, 0))
.mockRejectedValueOnce(new Error('network failure'));
render(<FeedSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /load more/i }));
// Existing items stay; an error message appears; the control remains for retry.
await waitFor(() => {
expect(screen.getByText(/could not load more posts/i)).toBeInTheDocument();
});
expect(screen.getAllByText('PAGEDPOST')).toHaveLength(FEED_PAGE_SIZE);
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument();
});
});
+148 -14
View File
@@ -25,10 +25,12 @@ import Button from '../../components/ui/Button';
import {
type GqlComment,
type GqlHomeFeedItem,
type GqlHomeFeedResult,
type GqlPost,
type LikeResult,
PaymentRequiredError,
} from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';
import ConfirmDialog from '../components/ConfirmDialog';
@@ -36,6 +38,15 @@ import { relativeTime } from './relativeTime';
const log = debug('agentworld:feed');
/**
* Home-feed items fetched per page (also the initial page size). The
* `tinyplace_graphql_home_feed` RPC accepts `limit`/`offset`
* (`src/openhuman/tinyplace/manifest.rs`), so the feed is loaded a page at a
* time and extended via an offset-based "Load more" control. A page shorter
* than this size means the feed is exhausted (`hasMore=false`).
*/
export const FEED_PAGE_SIZE = 50;
// ── State types ───────────────────────────────────────────────────────────────
type FeedState =
@@ -43,7 +54,41 @@ type FeedState =
| { status: 'wallet_unconfigured' }
| { status: 'payment_required'; challenge: unknown }
| { status: 'error'; message: string }
| { status: 'ok'; items: GqlHomeFeedItem[] };
| {
status: 'ok';
items: GqlHomeFeedItem[];
// Server-side cursor, in request units: how many rows to skip on the next
// page. Advances by FEED_PAGE_SIZE per fetch, decoupled from the client
// item count so dedupe never desyncs the offset.
nextOffset: number;
// A full page came back, so more items may exist.
hasMore: boolean;
// A "Load more" fetch is in flight.
loadingMore: boolean;
// Non-null when the most recent "Load more" fetch failed (existing items
// stay visible; the user can retry).
moreError: string | null;
};
/**
* Build the first-page `ok` state from a home-feed result. Used by the initial
* fetch and by every post-mutation refetch (compose / delete), all of which
* reset pagination to page one. `hasMore` is derived from the raw returned page
* length so a full page signals that older items may still be reachable.
*/
function firstPageFeedState(result: GqlHomeFeedResult | null | undefined): FeedState {
const items = sortedHomeFeedItems(result);
const received = Array.isArray(result?.items) ? result.items.length : 0;
const hasMore = received >= FEED_PAGE_SIZE;
return {
status: 'ok',
items,
nextOffset: FEED_PAGE_SIZE,
hasMore,
loadingMore: false,
moreError: null,
};
}
/**
* Result of resolving the local wallet on mount.
@@ -588,6 +633,7 @@ function CommentRow({
// ── FeedSection (main export) ─────────────────────────────────────────────────
export default function FeedSection() {
const { t } = useT();
const [feedState, setFeedState] = useState<FeedState>({ status: 'loading' });
const [followState, setFollowState] = useState<Record<string, boolean>>({});
const [followLoading, setFollowLoading] = useState<Record<string, boolean>>({});
@@ -599,6 +645,17 @@ export default function FeedSection() {
const { agentId: myAgentId, configured: walletConfigured } = useWalletResolution();
// Guards async setState after unmount. The initial fetch effect uses its own
// `cancelled` flag; "Load more" fetches outlive no single effect, so they read
// this ref instead.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// ── Hydrate follow state from the server ───────────────────────────────────
// The home feed doesn't carry "am I following this author?", so seed the
// follow map from the wallet's actual following list. Without this, the
@@ -654,18 +711,24 @@ export default function FeedSection() {
// one they just created via the composer) never appear. Without this the
// composer looks broken: Post succeeds server-side but the refetch can't
// show it (#4059).
log('loading first feed page', { limit: FEED_PAGE_SIZE });
void apiClient.graphql
.homeFeed({ limit: 50, includeSelf: true })
.homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true })
.then(result => {
if (cancelled) return;
const items = sortedHomeFeedItems(result);
setFeedState({ status: 'ok', items });
const next = firstPageFeedState(result);
log('loaded first feed page', {
received: Array.isArray(result?.items) ? result.items.length : 0,
hasMore: next.status === 'ok' ? next.hasMore : false,
});
setFeedState(next);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof PaymentRequiredError) {
setFeedState({ status: 'payment_required', challenge: err.challenge });
} else {
log('first feed page failed', { error: String(err) });
setFeedState({ status: 'error', message: String(err) });
}
});
@@ -675,6 +738,54 @@ export default function FeedSection() {
};
}, [walletConfigured]);
// ── Fetch the next page and append it ──────────────────────────────────────
// `offset` is passed in from the rendered 'ok' state so the cursor stays a
// pure function of pages requested. Reentry is prevented by disabling the
// button while `loadingMore` is set.
const loadMore = useCallback((offset: number) => {
log('loading more feed items', { offset, limit: FEED_PAGE_SIZE });
setFeedState(prev =>
prev.status === 'ok' ? { ...prev, loadingMore: true, moreError: null } : prev
);
void apiClient.graphql
.homeFeed({ limit: FEED_PAGE_SIZE, offset, includeSelf: true })
.then(result => {
if (!mountedRef.current) return;
const page = Array.isArray(result?.items) ? result.items : [];
const hasMore = page.length >= FEED_PAGE_SIZE;
setFeedState(prev => {
if (prev.status !== 'ok') return prev;
// Dedupe by postId: if items shifted between page fetches the overlap
// must not produce duplicate React keys or double-counted posts.
const seen = new Set(prev.items.map(item => item.post.postId));
const fresh = page.filter(item => !seen.has(item.post.postId));
const merged = sortedHomeFeedItems({ items: [...prev.items, ...fresh] });
log('appended feed items', {
received: page.length,
fresh: fresh.length,
total: merged.length,
hasMore,
});
return {
status: 'ok',
items: merged,
nextOffset: offset + FEED_PAGE_SIZE,
hasMore,
loadingMore: false,
moreError: null,
};
});
})
.catch((err: unknown) => {
if (!mountedRef.current) return;
log('load more feed failed', { error: String(err) });
setFeedState(prev =>
prev.status === 'ok' ? { ...prev, loadingMore: false, moreError: String(err) } : prev
);
});
}, []);
// ── Follow / Unfollow ──────────────────────────────────────────────────────
const handleToggleFollow = async (cryptoId: string) => {
@@ -741,11 +852,13 @@ export default function FeedSection() {
.then(({ ok }) => {
if (!ok) throw new Error('Post deletion was not accepted by the backend');
// Return the refresh promise so its rejection reaches `.catch` (rather
// than resolving the delete as "done" before the feed is reloaded).
return apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => {
const items = sortedHomeFeedItems(result);
setFeedState({ status: 'ok', items });
});
// than resolving the delete as "done" before the feed is reloaded). A
// mutation invalidates offsets, so reset pagination to the first page.
return apiClient.graphql
.homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true })
.then(result => {
setFeedState(firstPageFeedState(result));
});
})
.catch(err => console.error('[FeedSection] delete post failed:', err))
.finally(() => {
@@ -756,11 +869,13 @@ export default function FeedSection() {
// ── Refetch feed ───────────────────────────────────────────────────────────
// A fresh compose invalidates offsets, so reset pagination to the first page.
const refetchFeed = () => {
void apiClient.graphql.homeFeed({ limit: 50, includeSelf: true }).then(result => {
const items = sortedHomeFeedItems(result);
setFeedState({ status: 'ok', items });
});
void apiClient.graphql
.homeFeed({ limit: FEED_PAGE_SIZE, offset: 0, includeSelf: true })
.then(result => {
setFeedState(firstPageFeedState(result));
});
};
// ── Render ─────────────────────────────────────────────────────────────────
@@ -812,9 +927,10 @@ export default function FeedSection() {
/>
);
} else {
const { items, hasMore, loadingMore, moreError, nextOffset } = feedState;
body = (
<div className="space-y-3">
{feedState.items.map(item => (
{items.map(item => (
<PostCard
key={item.post.postId}
item={item}
@@ -831,6 +947,24 @@ export default function FeedSection() {
onDeletePost={handleDeletePost}
/>
))}
{moreError && (
<p className="text-center text-xs text-red-600 dark:text-red-400">
{t('agentWorld.feed.loadMoreError')}
</p>
)}
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
size="sm"
disabled={loadingMore}
onClick={() => loadMore(nextOffset)}>
{loadingMore ? t('agentWorld.feed.loadingMore') : t('agentWorld.feed.loadMore')}
</Button>
</div>
)}
</div>
);
}
+3
View File
@@ -439,6 +439,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'العالم',
'agentWorld.world.rooms.outside.description': 'ساحة مفتوحة كبيرة تحيط بها المباني.',
'agentWorld.feed': 'التغذية',
'agentWorld.feed.loadMore': 'تحميل المزيد',
'agentWorld.feed.loadingMore': 'جارٍ تحميل المزيد…',
'agentWorld.feed.loadMoreError': 'تعذّر تحميل المزيد من المنشورات. حاول مرة أخرى.',
'agentWorld.ledger': 'السجل',
'agentWorld.ledger.loadMore': 'تحميل المزيد',
'agentWorld.ledger.loadingMore': 'جارٍ تحميل المزيد…',
+3
View File
@@ -455,6 +455,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'বিশ্ব',
'agentWorld.world.rooms.outside.description': 'ভবনঘেরা বড় খোলা প্লাজা।',
'agentWorld.feed': 'ফিড',
'agentWorld.feed.loadMore': 'আরও লোড করুন',
'agentWorld.feed.loadingMore': 'আরও লোড হচ্ছে…',
'agentWorld.feed.loadMoreError': 'আরও পোস্ট লোড করা যায়নি। আবার চেষ্টা করুন।',
'agentWorld.ledger': 'লেজার',
'agentWorld.ledger.loadMore': 'আরও লোড করুন',
'agentWorld.ledger.loadingMore': 'আরও লোড হচ্ছে…',
+4
View File
@@ -479,6 +479,10 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Welt',
'agentWorld.world.rooms.outside.description': 'Ein großer offener Platz mit Gebäuden ringsum.',
'agentWorld.feed': 'Feed',
'agentWorld.feed.loadMore': 'Mehr laden',
'agentWorld.feed.loadingMore': 'Wird geladen…',
'agentWorld.feed.loadMoreError':
'Weitere Beiträge konnten nicht geladen werden. Bitte erneut versuchen.',
'agentWorld.ledger': 'Kontobuch',
'agentWorld.ledger.loadMore': 'Mehr laden',
'agentWorld.ledger.loadingMore': 'Wird geladen…',
+3
View File
@@ -184,6 +184,9 @@ const en: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'World',
'agentWorld.world.rooms.outside.description': 'A large open plaza ringed with buildings.',
'agentWorld.feed': 'Feed',
'agentWorld.feed.loadMore': 'Load more',
'agentWorld.feed.loadingMore': 'Loading more…',
'agentWorld.feed.loadMoreError': 'Could not load more posts. Try again.',
'agentWorld.ledger': 'Ledger',
'agentWorld.ledger.loadMore': 'Load more',
'agentWorld.ledger.loadingMore': 'Loading more…',
+3
View File
@@ -465,6 +465,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Mundo',
'agentWorld.world.rooms.outside.description': 'Una gran plaza abierta rodeada de edificios.',
'agentWorld.feed': 'Noticias',
'agentWorld.feed.loadMore': 'Cargar más',
'agentWorld.feed.loadingMore': 'Cargando más…',
'agentWorld.feed.loadMoreError': 'No se pudieron cargar más publicaciones. Inténtalo de nuevo.',
'agentWorld.ledger': 'Libro mayor',
'agentWorld.ledger.loadMore': 'Cargar más',
'agentWorld.ledger.loadingMore': 'Cargando más…',
+3
View File
@@ -475,6 +475,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Monde',
'agentWorld.world.rooms.outside.description': 'Une grande place ouverte entourée de bâtiments.',
'agentWorld.feed': 'Fil',
'agentWorld.feed.loadMore': 'Charger plus',
'agentWorld.feed.loadingMore': 'Chargement…',
'agentWorld.feed.loadMoreError': 'Impossible de charger plus de publications. Réessayez.',
'agentWorld.ledger': 'Grand livre',
'agentWorld.ledger.loadMore': 'Charger plus',
'agentWorld.ledger.loadingMore': 'Chargement…',
+3
View File
@@ -455,6 +455,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'दुनिया',
'agentWorld.world.rooms.outside.description': 'इमारतों से घिरा बड़ा खुला प्लाजा।',
'agentWorld.feed': 'फ़ीड',
'agentWorld.feed.loadMore': 'और लोड करें',
'agentWorld.feed.loadingMore': 'और लोड हो रहा है…',
'agentWorld.feed.loadMoreError': 'अधिक पोस्ट लोड नहीं हो सके। फिर से प्रयास करें।',
'agentWorld.ledger': 'खाता बही',
'agentWorld.ledger.loadMore': 'और लोड करें',
'agentWorld.ledger.loadingMore': 'और लोड हो रहा है…',
+3
View File
@@ -461,6 +461,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Dunia',
'agentWorld.world.rooms.outside.description': 'Plaza terbuka besar yang dikelilingi gedung.',
'agentWorld.feed': 'Feed',
'agentWorld.feed.loadMore': 'Muat lebih banyak',
'agentWorld.feed.loadingMore': 'Memuat lagi…',
'agentWorld.feed.loadMoreError': 'Tidak dapat memuat postingan lainnya. Coba lagi.',
'agentWorld.ledger': 'Buku Besar',
'agentWorld.ledger.loadMore': 'Muat lebih banyak',
'agentWorld.ledger.loadingMore': 'Memuat lagi…',
+3
View File
@@ -468,6 +468,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Mondo',
'agentWorld.world.rooms.outside.description': 'Una grande piazza aperta circondata da edifici.',
'agentWorld.feed': 'Feed',
'agentWorld.feed.loadMore': 'Carica altro',
'agentWorld.feed.loadingMore': 'Caricamento…',
'agentWorld.feed.loadMoreError': 'Impossibile caricare altri post. Riprova.',
'agentWorld.ledger': 'Registro',
'agentWorld.ledger.loadMore': 'Carica altro',
'agentWorld.ledger.loadingMore': 'Caricamento…',
+3
View File
@@ -448,6 +448,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': '월드',
'agentWorld.world.rooms.outside.description': '건물로 둘러싸인 넓은 열린 광장.',
'agentWorld.feed': '피드',
'agentWorld.feed.loadMore': '더 보기',
'agentWorld.feed.loadingMore': '더 불러오는 중…',
'agentWorld.feed.loadMoreError': '게시물을 더 불러오지 못했습니다. 다시 시도하세요.',
'agentWorld.ledger': '원장',
'agentWorld.ledger.loadMore': '더 보기',
'agentWorld.ledger.loadingMore': '더 불러오는 중…',
+3
View File
@@ -466,6 +466,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Świat',
'agentWorld.world.rooms.outside.description': 'Duży otwarty plac otoczony budynkami.',
'agentWorld.feed': 'Kanał',
'agentWorld.feed.loadMore': 'Załaduj więcej',
'agentWorld.feed.loadingMore': 'Ładowanie…',
'agentWorld.feed.loadMoreError': 'Nie udało się załadować kolejnych postów. Spróbuj ponownie.',
'agentWorld.ledger': 'Księga',
'agentWorld.ledger.loadMore': 'Załaduj więcej',
'agentWorld.ledger.loadingMore': 'Ładowanie…',
+3
View File
@@ -460,6 +460,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Mundo',
'agentWorld.world.rooms.outside.description': 'Uma grande praça aberta cercada por edifícios.',
'agentWorld.feed': 'Feed',
'agentWorld.feed.loadMore': 'Carregar mais',
'agentWorld.feed.loadingMore': 'Carregando mais…',
'agentWorld.feed.loadMoreError': 'Não foi possível carregar mais publicações. Tente novamente.',
'agentWorld.ledger': 'Livro-razão',
'agentWorld.ledger.loadMore': 'Carregar mais',
'agentWorld.ledger.loadingMore': 'Carregando mais…',
+3
View File
@@ -460,6 +460,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': 'Мир',
'agentWorld.world.rooms.outside.description': 'Большая открытая площадь, окруженная зданиями.',
'agentWorld.feed': 'Лента',
'agentWorld.feed.loadMore': 'Загрузить ещё',
'agentWorld.feed.loadingMore': 'Загрузка…',
'agentWorld.feed.loadMoreError': 'Не удалось загрузить больше публикаций. Попробуйте ещё раз.',
'agentWorld.ledger': 'Реестр',
'agentWorld.ledger.loadMore': 'Загрузить ещё',
'agentWorld.ledger.loadingMore': 'Загрузка…',
+3
View File
@@ -424,6 +424,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.name': '世界',
'agentWorld.world.rooms.outside.description': '一座被建筑环绕的大型开放广场。',
'agentWorld.feed': '动态',
'agentWorld.feed.loadMore': '加载更多',
'agentWorld.feed.loadingMore': '正在加载…',
'agentWorld.feed.loadMoreError': '无法加载更多帖子,请重试。',
'agentWorld.ledger': '账本',
'agentWorld.ledger.loadMore': '加载更多',
'agentWorld.ledger.loadingMore': '正在加载…',