mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(native): block duplicate watch taps, stop invalidating title details, and show refresh spinner only on pull
- invalidateTitleQueries no longer invalidates titles.get and returns a promise for tracking/library refetches; mutations return it from onSuccess so isPending covers the refetch - per-episode pending guard (disabled + dimmed row) and disabled season "Mark All Watched" while pending - add usePullToRefresh so RefreshControl only reflects user-initiated pulls (Home, Explore, Library, Upcoming) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
9 files changed
+132
-73
No files matched your search
@@ -7,6 +7,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 { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
|
||||
@@ -30,12 +31,11 @@ export default function ExploreScreen() {
|
||||
const movieGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "movie" } }));
|
||||
const tvGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "tv" } }));
|
||||
|
||||
const isRefreshing =
|
||||
trending.isRefetching || popularMovies.isRefetching || popularTv.isRefetching;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||
}, []);
|
||||
const refreshDiscover = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: orpc.discover.key() }),
|
||||
[],
|
||||
);
|
||||
const { refreshing, onRefresh } = usePullToRefresh(refreshDiscover);
|
||||
|
||||
const heroItem = trending.data?.pages[0]?.hero ?? null;
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function ExploreScreen() {
|
||||
contentContainerStyle={exploreContentContainerStyle}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollToOverflowEnabled
|
||||
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<View className="gap-8">
|
||||
{heroItem && <HeroBanner item={heroItem} />}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
horizontalListStyle,
|
||||
} from "@/components/ui/horizontal-list-spacing";
|
||||
import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { authClient } from "@/lib/server";
|
||||
@@ -67,17 +68,16 @@ export default function DashboardScreen() {
|
||||
const library = useQuery(orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }));
|
||||
const recommendations = useQuery(orpc.discover.recommendations.queryOptions());
|
||||
|
||||
const isRefreshing =
|
||||
libraryStats.isRefetching ||
|
||||
continueWatching.isRefetching ||
|
||||
library.isRefetching ||
|
||||
movieHistory.isRefetching ||
|
||||
episodeHistory.isRefetching;
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
}, []);
|
||||
const refreshDashboard = useCallback(
|
||||
() =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }),
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() }),
|
||||
queryClient.invalidateQueries({ queryKey: orpc.discover.recommendations.key() }),
|
||||
]),
|
||||
[],
|
||||
);
|
||||
const { refreshing, onRefresh } = usePullToRefresh(refreshDashboard);
|
||||
|
||||
const hasLibrary = (library.data?.items?.length ?? 0) > 0;
|
||||
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
|
||||
@@ -106,7 +106,7 @@ export default function DashboardScreen() {
|
||||
contentContainerStyle={dashboardContentContainerStyle}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
scrollToOverflowEnabled
|
||||
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<View className="gap-6">
|
||||
{/* Stats Grid */}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { UpcomingRow } from "@/components/dashboard/upcoming-row";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ScaledIcon } from "@/components/ui/scaled-icon";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
@@ -56,28 +57,20 @@ export default function UpcomingScreen() {
|
||||
const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all");
|
||||
|
||||
const {
|
||||
data,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isRefetching,
|
||||
} = useInfiniteQuery(
|
||||
orpc.library.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
mediaType: mediaType !== "all" ? mediaType : undefined,
|
||||
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
|
||||
const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery(
|
||||
orpc.library.upcoming.infiniteOptions({
|
||||
input: (pageParam: string | undefined) => ({
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
mediaType: mediaType !== "all" ? mediaType : undefined,
|
||||
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
}),
|
||||
);
|
||||
);
|
||||
|
||||
const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
||||
const sections = useMemo(
|
||||
@@ -90,9 +83,11 @@ export default function UpcomingScreen() {
|
||||
[allItems],
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.upcoming.key() });
|
||||
}, []);
|
||||
const refreshUpcoming = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: orpc.library.upcoming.key() }),
|
||||
[],
|
||||
);
|
||||
const { refreshing, onRefresh } = usePullToRefresh(refreshUpcoming);
|
||||
|
||||
const onEndReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
@@ -171,7 +166,7 @@ export default function UpcomingScreen() {
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={onRefresh} />}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
ListEmptyComponent={
|
||||
isPending ? null : isError && allItems.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PosterCard } from "@/components/ui/poster-card";
|
||||
import { SelectModal } from "@/components/ui/select-modal";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTitleActions } from "@/hooks/use-title-actions";
|
||||
import {
|
||||
libraryActiveFilterCountAtom,
|
||||
@@ -257,10 +258,11 @@ export default function LibraryScreen() {
|
||||
[libraryQuery.data?.pages],
|
||||
);
|
||||
|
||||
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage;
|
||||
const onRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
}, []);
|
||||
const refreshLibrary = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: orpc.library.key() }),
|
||||
[],
|
||||
);
|
||||
const { refreshing, onRefresh } = usePullToRefresh(refreshLibrary);
|
||||
|
||||
type LibraryItem = (typeof allItems)[number];
|
||||
|
||||
@@ -416,7 +418,7 @@ export default function LibraryScreen() {
|
||||
paddingHorizontal: EDGE_PADDING - GAP / 2,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
onEndReached={() => {
|
||||
if (
|
||||
libraryQuery.hasNextPage &&
|
||||
|
||||
@@ -12,6 +12,7 @@ export const EpisodeRow = memo(function EpisodeRow({
|
||||
name,
|
||||
airDate,
|
||||
isWatched,
|
||||
isPending,
|
||||
onToggle,
|
||||
accentColor,
|
||||
mutedColor,
|
||||
@@ -21,6 +22,7 @@ export const EpisodeRow = memo(function EpisodeRow({
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
isWatched: boolean;
|
||||
isPending: boolean;
|
||||
onToggle: (episodeId: string) => void;
|
||||
accentColor: string;
|
||||
mutedColor: string;
|
||||
@@ -34,16 +36,19 @@ export const EpisodeRow = memo(function EpisodeRow({
|
||||
<Pressable
|
||||
onPress={handleToggle}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: isWatched }}
|
||||
disabled={isPending}
|
||||
accessibilityState={{ checked: isWatched, disabled: isPending }}
|
||||
accessibilityLabel={t`Episode ${episodeNumber}, ${episodeLabel}`}
|
||||
className="border-border flex-row items-center border-b px-4 py-3"
|
||||
style={{ borderBottomWidth: 0.5 }}
|
||||
>
|
||||
{isWatched ? (
|
||||
<ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} />
|
||||
) : (
|
||||
<ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} />
|
||||
)}
|
||||
<View style={{ opacity: isPending ? 0.5 : 1 }}>
|
||||
{isWatched ? (
|
||||
<ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} />
|
||||
) : (
|
||||
<ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} />
|
||||
)}
|
||||
</View>
|
||||
<View className="ml-3 flex-1">
|
||||
<Text
|
||||
className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
||||
|
||||
@@ -98,15 +98,26 @@ export function SeasonAccordion({
|
||||
},
|
||||
});
|
||||
|
||||
const [pendingEpisodeIds, setPendingEpisodeIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const handleEpisodeToggle = useCallback(
|
||||
(episodeId: string) => {
|
||||
if (pendingEpisodeIds.has(episodeId)) return;
|
||||
setPendingEpisodeIds((prev) => new Set(prev).add(episodeId));
|
||||
const done = () =>
|
||||
setPendingEpisodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(episodeId);
|
||||
return next;
|
||||
});
|
||||
const input = { scope: "episode" as const, ids: [episodeId] };
|
||||
if (watchedEpisodeIds.has(episodeId)) {
|
||||
unwatchEpisode.mutate({ scope: "episode", ids: [episodeId] });
|
||||
unwatchEpisode.mutate(input, { onSettled: done });
|
||||
} else {
|
||||
watchEpisode.mutate({ scope: "episode", ids: [episodeId] });
|
||||
watchEpisode.mutate(input, { onSettled: done });
|
||||
}
|
||||
},
|
||||
[watchedEpisodeIds, unwatchEpisode, watchEpisode],
|
||||
[pendingEpisodeIds, watchedEpisodeIds, unwatchEpisode, watchEpisode],
|
||||
);
|
||||
|
||||
const seasonNumber = season.seasonNumber;
|
||||
@@ -159,6 +170,8 @@ export function SeasonAccordion({
|
||||
{watchedCount < episodes.length && (
|
||||
<Pressable
|
||||
onPress={() => watchSeason.mutate({ scope: "season", ids: [season.id] })}
|
||||
disabled={watchSeason.isPending}
|
||||
accessibilityState={{ disabled: watchSeason.isPending }}
|
||||
className="bg-secondary mx-4 mb-2 flex-row items-center justify-center rounded-lg py-2"
|
||||
>
|
||||
<Text className="text-title-accent font-sans text-xs font-medium">
|
||||
@@ -175,6 +188,7 @@ export function SeasonAccordion({
|
||||
name={episode.name}
|
||||
airDate={episode.airDate}
|
||||
isWatched={watchedEpisodeIds.has(episode.id)}
|
||||
isPending={pendingEpisodeIds.has(episode.id)}
|
||||
onToggle={handleEpisodeToggle}
|
||||
accentColor={titleAccentColor}
|
||||
mutedColor={mutedFgColor}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/** Longest the pull-to-refresh spinner stays up; refetches keep running after it hides. */
|
||||
const MAX_SPINNER_MS = 15_000;
|
||||
|
||||
/**
|
||||
* RefreshControl state that only reflects user-initiated refreshes (not background refetches).
|
||||
* While React Query is offline its refetches are paused and `refresh()` won't settle until the app
|
||||
* is back online, so skip the spinner then (the offline banner already explains why) and cap it
|
||||
* otherwise.
|
||||
*/
|
||||
export function usePullToRefresh(refresh: () => Promise<unknown>) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (!onlineManager.isOnline()) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
refresh(),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, MAX_SPINNER_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
return { refreshing, onRefresh };
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
? (statusMessages[input.status] ?? t`Status updated`)
|
||||
: t`Removed from library`;
|
||||
toast.success(resolveToast(toastOverrides?.updateStatus, defaultMsg, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to update status`),
|
||||
}),
|
||||
@@ -62,7 +62,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
orpc.tracking.watch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to mark as watched`),
|
||||
}),
|
||||
@@ -72,7 +72,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
orpc.tracking.unwatch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.unwatchMovie, t`Marked as unwatched`, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to mark as unwatched`),
|
||||
}),
|
||||
@@ -87,7 +87,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
? t`Rated ${plural(stars, { one: "# star", other: "# stars" })}`
|
||||
: t`Rating removed`;
|
||||
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to update rating`),
|
||||
}),
|
||||
@@ -97,7 +97,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
orpc.tracking.watch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to mark episode`),
|
||||
}),
|
||||
@@ -107,7 +107,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
orpc.tracking.unwatch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to unmark episode`),
|
||||
}),
|
||||
@@ -117,7 +117,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
|
||||
orpc.tracking.watch.mutationOptions({
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input));
|
||||
invalidateTitleQueries();
|
||||
return invalidateTitleQueries();
|
||||
},
|
||||
onError: () => toast.error(t`Failed to mark some episodes`),
|
||||
}),
|
||||
|
||||
@@ -8,13 +8,16 @@ import { i18n } from "@sofa/i18n";
|
||||
|
||||
let widgetRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Invalidate every query that carries per-user tracking state. Used by most title mutations. */
|
||||
export function invalidateTitleQueries() {
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.people.key() });
|
||||
/**
|
||||
* Mark every query that depends on the user's tracking state as stale. Title details
|
||||
* (`titles.get`) are excluded — they don't depend on tracking (same rule as the web's
|
||||
* invalidateTrackingQueries). Resolves once tracking and library queries have refetched,
|
||||
* so mutations can stay pending until the UI reflects the change.
|
||||
*/
|
||||
export function invalidateTitleQueries(): Promise<unknown> {
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.discover.key() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.people.key() });
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.titles.similar.key() });
|
||||
|
||||
// Debounce widget refresh to batch rapid mutations (e.g. watching multiple episodes)
|
||||
if (widgetRefreshTimer) clearTimeout(widgetRefreshTimer);
|
||||
@@ -22,6 +25,11 @@ export function invalidateTitleQueries() {
|
||||
void refreshWidgets();
|
||||
widgetRefreshTimer = null;
|
||||
}, 2000);
|
||||
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }),
|
||||
queryClient.invalidateQueries({ queryKey: orpc.library.key() }),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +49,7 @@ export const titleActions = {
|
||||
} catch {
|
||||
toast.error(i18n._(msg`Failed to add to watchlist`));
|
||||
// Refetch so any optimistic local status reverts on failure
|
||||
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
|
||||
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user