fix(native): load more titles in Explore rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 22:10:14 -04:00
1 parent ecf119c98e
commit ee53f33a79
5 files changed
+88 -3

No files matched your search

+12 -1
View File
@@ -8,6 +8,7 @@ import Animated, { FadeInDown } from "react-native-reanimated";
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { dedupeById } from "@/lib/dedupe-by-id";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
@@ -40,7 +41,7 @@ export default function ExploreScreen() {
const heroItem = trending.data?.pages[0]?.hero ?? null;
const trendingItems = useMemo(
() => trending.data?.pages.flatMap((p) => p.items) ?? [],
() => dedupeById(trending.data?.pages.flatMap((p) => p.items) ?? []),
[trending.data?.pages],
);
const trendingStatuses = useMemo(
@@ -82,6 +83,16 @@ export default function ExploreScreen() {
isLoading={trending.isPending}
isError={trending.isError}
onRetry={() => void trending.refetch()}
onEndReachedDefault={() => {
if (
trending.hasNextPage &&
!trending.isFetchingNextPage &&
!trending.isFetchNextPageError
) {
void trending.fetchNextPage();
}
}}
isFetchingNextPageDefault={trending.isFetchingNextPage}
/>
</Animated.View>
@@ -1,5 +1,6 @@
import { FlashList } from "@shopify/flash-list";
import { useCallback } from "react";
import { View } from "react-native";
import {
HorizontalListSeparator,
@@ -7,6 +8,7 @@ import {
horizontalListStyle,
} from "@/components/ui/horizontal-list-spacing";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { Spinner } from "@/components/ui/spinner";
import { useTitleActions } from "@/hooks/use-title-actions";
export interface PosterRowItem {
@@ -25,9 +27,14 @@ export interface PosterRowItem {
export function HorizontalPosterRow({
items,
isLoading,
onEndReached,
isFetchingNextPage,
}: {
items: PosterRowItem[];
isLoading?: boolean;
/** Called when the user scrolls near the end of the row (load more). */
onEndReached?: () => void;
isFetchingNextPage?: boolean;
}) {
const { updateStatus } = useTitleActions();
const handleQuickAdd = useCallback(
@@ -55,6 +62,16 @@ export function HorizontalPosterRow({
[addingKey, handleQuickAdd],
);
const footer = useCallback(
() =>
isFetchingNextPage ? (
<View className="h-full items-center justify-center px-4">
<Spinner size="sm" />
</View>
) : null,
[isFetchingNextPage],
);
if (isLoading) {
return (
<FlashList
@@ -77,6 +94,9 @@ export function HorizontalPosterRow({
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
ListFooterComponent={footer}
ItemSeparatorComponent={HorizontalListSeparator}
contentContainerStyle={horizontalListContentStyle}
style={horizontalListStyle}
@@ -11,6 +11,7 @@ import {
import { GenreChip } from "@/components/explore/genre-chip";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
import { dedupeById } from "@/lib/dedupe-by-id";
import { orpc } from "@/lib/orpc";
type TitleStatus = "in_watchlist" | "watching" | "caught_up" | "completed";
@@ -39,6 +40,8 @@ export function FilterableTitleRow({
isLoading,
isError,
onRetry,
onEndReachedDefault,
isFetchingNextPageDefault,
}: {
title: string;
icon: Icon;
@@ -61,6 +64,9 @@ export function FilterableTitleRow({
isError?: boolean;
/** Retries the default (no genre) list. */
onRetry?: () => void;
/** Loads the next page of the default (no genre) list. */
onEndReachedDefault?: () => void;
isFetchingNextPageDefault?: boolean;
}) {
const { t } = useLingui();
const [selectedGenre, setSelectedGenre] = useState<number | null>(null);
@@ -82,7 +88,7 @@ export function FilterableTitleRow({
});
const discoverItems = useMemo(
() => discover.data?.pages.flatMap((p) => p.items) ?? [],
() => dedupeById(discover.data?.pages.flatMap((p) => p.items) ?? []),
[discover.data?.pages],
);
const discoverStatuses = useMemo(
@@ -158,7 +164,26 @@ export function FilterableTitleRow({
</Text>
</View>
) : (
<HorizontalPosterRow items={items} isLoading={showLoading} />
<HorizontalPosterRow
items={items}
isLoading={showLoading}
onEndReached={
selectedGenre === null
? onEndReachedDefault
: () => {
if (
discover.hasNextPage &&
!discover.isFetchingNextPage &&
!discover.isFetchNextPageError
) {
void discover.fetchNextPage();
}
}
}
isFetchingNextPage={
selectedGenre === null ? isFetchingNextPageDefault : discover.isFetchingNextPage
}
/>
)}
</View>
);
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { dedupeById } from "./dedupe-by-id";
describe("dedupeById", () => {
it("keeps order and drops later duplicates", () => {
const result = dedupeById([
{ id: "a", n: 1 },
{ id: "b", n: 2 },
{ id: "a", n: 3 },
{ id: "c", n: 4 },
{ id: "b", n: 5 },
]);
expect(result).toEqual([
{ id: "a", n: 1 },
{ id: "b", n: 2 },
{ id: "c", n: 4 },
]);
});
it("returns an empty array for empty input", () => {
expect(dedupeById([])).toEqual([]);
});
});
+5
View File
@@ -0,0 +1,5 @@
/** Keep the first occurrence of each id (TMDB pages can repeat a title as rankings shift). */
export function dedupeById<T extends { id: string }>(items: readonly T[]): T[] {
const seen = new Set<string>();
return items.filter((item) => (seen.has(item.id) ? false : (seen.add(item.id), true)));
}