mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(native): load more titles in Explore rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+88
-3
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user