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:
jakeandClaude Opus 5.5 committed 2026-10-05 03:07:24 -04:00
1 parent bc3ea712f9
commit 67766298d8
9 files changed
+116 -57

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 { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner"; import { HeroBanner } from "@/components/explore/hero-banner";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; 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 movieGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "movie" } }));
const tvGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "tv" } })); const tvGenres = useQuery(orpc.discover.genres.queryOptions({ input: { type: "tv" } }));
const isRefreshing = const refreshDiscover = useCallback(
trending.isRefetching || popularMovies.isRefetching || popularTv.isRefetching; () => queryClient.invalidateQueries({ queryKey: orpc.discover.key() }),
[],
const onRefresh = useCallback(() => { );
queryClient.invalidateQueries({ queryKey: orpc.discover.key() }); const { refreshing, onRefresh } = usePullToRefresh(refreshDiscover);
}, []);
const heroItem = trending.data?.pages[0]?.hero ?? null; const heroItem = trending.data?.pages[0]?.hero ?? null;
@@ -66,7 +66,7 @@ export default function ExploreScreen() {
contentContainerStyle={exploreContentContainerStyle} contentContainerStyle={exploreContentContainerStyle}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled scrollToOverflowEnabled
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
> >
<View className="gap-8"> <View className="gap-8">
{heroItem && <HeroBanner item={heroItem} />} {heroItem && <HeroBanner item={heroItem} />}
+12 -12
View File
@@ -27,6 +27,7 @@ import {
horizontalListStyle, horizontalListStyle,
} from "@/components/ui/horizontal-list-spacing"; } from "@/components/ui/horizontal-list-spacing";
import { SectionHeader } from "@/components/ui/section-header"; import { SectionHeader } from "@/components/ui/section-header";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { authClient } from "@/lib/server"; 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 library = useQuery(orpc.library.list.queryOptions({ input: { page: 1, limit: 10 } }));
const recommendations = useQuery(orpc.discover.recommendations.queryOptions()); const recommendations = useQuery(orpc.discover.recommendations.queryOptions());
const isRefreshing = const refreshDashboard = useCallback(
libraryStats.isRefetching || () =>
continueWatching.isRefetching || Promise.all([
library.isRefetching || queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }),
movieHistory.isRefetching || queryClient.invalidateQueries({ queryKey: orpc.library.key() }),
episodeHistory.isRefetching; queryClient.invalidateQueries({ queryKey: orpc.discover.recommendations.key() }),
]),
const onRefresh = useCallback(() => { [],
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }); );
queryClient.invalidateQueries({ queryKey: orpc.library.key() }); const { refreshing, onRefresh } = usePullToRefresh(refreshDashboard);
}, []);
const hasLibrary = (library.data?.items?.length ?? 0) > 0; const hasLibrary = (library.data?.items?.length ?? 0) > 0;
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0; const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
@@ -106,7 +106,7 @@ export default function DashboardScreen() {
contentContainerStyle={dashboardContentContainerStyle} contentContainerStyle={dashboardContentContainerStyle}
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
scrollToOverflowEnabled scrollToOverflowEnabled
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
> >
<View className="gap-6"> <View className="gap-6">
{/* Stats Grid */} {/* Stats Grid */}
+9 -14
View File
@@ -11,6 +11,7 @@ import { UpcomingRow } from "@/components/dashboard/upcoming-row";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import * as Haptics from "@/utils/haptics"; import * as Haptics from "@/utils/haptics";
@@ -56,16 +57,8 @@ export default function UpcomingScreen() {
const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all"); const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all"); const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all");
const { const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
data, useInfiniteQuery(
isPending,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isRefetching,
} = useInfiniteQuery(
orpc.library.upcoming.infiniteOptions({ orpc.library.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({ input: (pageParam: string | undefined) => ({
days: 90, days: 90,
@@ -90,9 +83,11 @@ export default function UpcomingScreen() {
[allItems], [allItems],
); );
const onRefresh = useCallback(() => { const refreshUpcoming = useCallback(
queryClient.invalidateQueries({ queryKey: orpc.library.upcoming.key() }); () => queryClient.invalidateQueries({ queryKey: orpc.library.upcoming.key() }),
}, []); [],
);
const { refreshing, onRefresh } = usePullToRefresh(refreshUpcoming);
const onEndReached = useCallback(() => { const onEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) { if (hasNextPage && !isFetchingNextPage) {
@@ -171,7 +166,7 @@ export default function UpcomingScreen() {
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={onRefresh} />} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
ListEmptyComponent={ ListEmptyComponent={
isPending ? null : isError && allItems.length === 0 ? ( isPending ? null : isError && allItems.length === 0 ? (
<EmptyState <EmptyState
@@ -21,6 +21,7 @@ import { PosterCard } from "@/components/ui/poster-card";
import { SelectModal } from "@/components/ui/select-modal"; import { SelectModal } from "@/components/ui/select-modal";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTitleActions } from "@/hooks/use-title-actions"; import { useTitleActions } from "@/hooks/use-title-actions";
import { import {
libraryActiveFilterCountAtom, libraryActiveFilterCountAtom,
@@ -257,10 +258,11 @@ export default function LibraryScreen() {
[libraryQuery.data?.pages], [libraryQuery.data?.pages],
); );
const isRefreshing = libraryQuery.isRefetching && !libraryQuery.isFetchingNextPage; const refreshLibrary = useCallback(
const onRefresh = useCallback(() => { () => queryClient.invalidateQueries({ queryKey: orpc.library.key() }),
queryClient.invalidateQueries({ queryKey: orpc.library.key() }); [],
}, []); );
const { refreshing, onRefresh } = usePullToRefresh(refreshLibrary);
type LibraryItem = (typeof allItems)[number]; type LibraryItem = (typeof allItems)[number];
@@ -416,7 +418,7 @@ export default function LibraryScreen() {
paddingHorizontal: EDGE_PADDING - GAP / 2, paddingHorizontal: EDGE_PADDING - GAP / 2,
paddingBottom: 16, paddingBottom: 16,
}} }}
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
onEndReached={() => { onEndReached={() => {
if ( if (
libraryQuery.hasNextPage && libraryQuery.hasNextPage &&
@@ -12,6 +12,7 @@ export const EpisodeRow = memo(function EpisodeRow({
name, name,
airDate, airDate,
isWatched, isWatched,
isPending,
onToggle, onToggle,
accentColor, accentColor,
mutedColor, mutedColor,
@@ -21,6 +22,7 @@ export const EpisodeRow = memo(function EpisodeRow({
name: string | null; name: string | null;
airDate: string | null; airDate: string | null;
isWatched: boolean; isWatched: boolean;
isPending: boolean;
onToggle: (episodeId: string) => void; onToggle: (episodeId: string) => void;
accentColor: string; accentColor: string;
mutedColor: string; mutedColor: string;
@@ -34,16 +36,19 @@ export const EpisodeRow = memo(function EpisodeRow({
<Pressable <Pressable
onPress={handleToggle} onPress={handleToggle}
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityState={{ checked: isWatched }} disabled={isPending}
accessibilityState={{ checked: isWatched, disabled: isPending }}
accessibilityLabel={t`Episode ${episodeNumber}, ${episodeLabel}`} accessibilityLabel={t`Episode ${episodeNumber}, ${episodeLabel}`}
className="border-border flex-row items-center border-b px-4 py-3" className="border-border flex-row items-center border-b px-4 py-3"
style={{ borderBottomWidth: 0.5 }} style={{ borderBottomWidth: 0.5 }}
> >
<View style={{ opacity: isPending ? 0.5 : 1 }}>
{isWatched ? ( {isWatched ? (
<ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} /> <ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} />
) : ( ) : (
<ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} /> <ScaledIcon icon={IconCircleDashed} size={22} color={mutedColor} />
)} )}
</View>
<View className="ml-3 flex-1"> <View className="ml-3 flex-1">
<Text <Text
className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`} 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( const handleEpisodeToggle = useCallback(
(episodeId: string) => { (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)) { if (watchedEpisodeIds.has(episodeId)) {
unwatchEpisode.mutate({ scope: "episode", ids: [episodeId] }); unwatchEpisode.mutate(input, { onSettled: done });
} else { } else {
watchEpisode.mutate({ scope: "episode", ids: [episodeId] }); watchEpisode.mutate(input, { onSettled: done });
} }
}, },
[watchedEpisodeIds, unwatchEpisode, watchEpisode], [pendingEpisodeIds, watchedEpisodeIds, unwatchEpisode, watchEpisode],
); );
const seasonNumber = season.seasonNumber; const seasonNumber = season.seasonNumber;
@@ -159,6 +170,8 @@ export function SeasonAccordion({
{watchedCount < episodes.length && ( {watchedCount < episodes.length && (
<Pressable <Pressable
onPress={() => watchSeason.mutate({ scope: "season", ids: [season.id] })} 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" 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"> <Text className="text-title-accent font-sans text-xs font-medium">
@@ -175,6 +188,7 @@ export function SeasonAccordion({
name={episode.name} name={episode.name}
airDate={episode.airDate} airDate={episode.airDate}
isWatched={watchedEpisodeIds.has(episode.id)} isWatched={watchedEpisodeIds.has(episode.id)}
isPending={pendingEpisodeIds.has(episode.id)}
onToggle={handleEpisodeToggle} onToggle={handleEpisodeToggle}
accentColor={titleAccentColor} accentColor={titleAccentColor}
mutedColor={mutedFgColor} 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 };
}
+7 -7
View File
@@ -52,7 +52,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
? (statusMessages[input.status] ?? t`Status updated`) ? (statusMessages[input.status] ?? t`Status updated`)
: t`Removed from library`; : t`Removed from library`;
toast.success(resolveToast(toastOverrides?.updateStatus, defaultMsg, input)); toast.success(resolveToast(toastOverrides?.updateStatus, defaultMsg, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to update status`), onError: () => toast.error(t`Failed to update status`),
}), }),
@@ -62,7 +62,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.tracking.watch.mutationOptions({ orpc.tracking.watch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input)); toast.success(resolveToast(toastOverrides?.watchMovie, t`Marked as watched`, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to mark as watched`), onError: () => toast.error(t`Failed to mark as watched`),
}), }),
@@ -72,7 +72,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.tracking.unwatch.mutationOptions({ orpc.tracking.unwatch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.unwatchMovie, t`Marked as unwatched`, input)); toast.success(resolveToast(toastOverrides?.unwatchMovie, t`Marked as unwatched`, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to mark as unwatched`), 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`Rated ${plural(stars, { one: "# star", other: "# stars" })}`
: t`Rating removed`; : t`Rating removed`;
toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input)); toast.success(resolveToast(toastOverrides?.updateRating, defaultMsg, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to update rating`), onError: () => toast.error(t`Failed to update rating`),
}), }),
@@ -97,7 +97,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.tracking.watch.mutationOptions({ orpc.tracking.watch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input)); toast.success(resolveToast(toastOverrides?.watchEpisode, t`Episode watched`, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to mark episode`), onError: () => toast.error(t`Failed to mark episode`),
}), }),
@@ -107,7 +107,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.tracking.unwatch.mutationOptions({ orpc.tracking.unwatch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input)); toast.success(resolveToast(toastOverrides?.unwatchEpisode, t`Episode unwatched`, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to unmark episode`), onError: () => toast.error(t`Failed to unmark episode`),
}), }),
@@ -117,7 +117,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
orpc.tracking.watch.mutationOptions({ orpc.tracking.watch.mutationOptions({
onSuccess: (_data, input) => { onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input)); toast.success(resolveToast(toastOverrides?.watchSeason, t`Season watched`, input));
invalidateTitleQueries(); return invalidateTitleQueries();
}, },
onError: () => toast.error(t`Failed to mark some episodes`), onError: () => toast.error(t`Failed to mark some episodes`),
}), }),
+16 -8
View File
@@ -8,13 +8,16 @@ import { i18n } from "@sofa/i18n";
let widgetRefreshTimer: ReturnType<typeof setTimeout> | null = null; let widgetRefreshTimer: ReturnType<typeof setTimeout> | null = null;
/** Invalidate every query that carries per-user tracking state. Used by most title mutations. */ /**
export function invalidateTitleQueries() { * Mark every query that depends on the user's tracking state as stale. Title details
queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); * (`titles.get`) are excluded — they don't depend on tracking (same rule as the web's
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }); * invalidateTrackingQueries). Resolves once tracking and library queries have refetched,
queryClient.invalidateQueries({ queryKey: orpc.library.key() }); * so mutations can stay pending until the UI reflects the change.
queryClient.invalidateQueries({ queryKey: orpc.discover.key() }); */
queryClient.invalidateQueries({ queryKey: orpc.people.key() }); 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) // Debounce widget refresh to batch rapid mutations (e.g. watching multiple episodes)
if (widgetRefreshTimer) clearTimeout(widgetRefreshTimer); if (widgetRefreshTimer) clearTimeout(widgetRefreshTimer);
@@ -22,6 +25,11 @@ export function invalidateTitleQueries() {
void refreshWidgets(); void refreshWidgets();
widgetRefreshTimer = null; widgetRefreshTimer = null;
}, 2000); }, 2000);
return Promise.all([
queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }),
queryClient.invalidateQueries({ queryKey: orpc.library.key() }),
]);
} }
/** /**
@@ -41,7 +49,7 @@ export const titleActions = {
} catch { } catch {
toast.error(i18n._(msg`Failed to add to watchlist`)); toast.error(i18n._(msg`Failed to add to watchlist`));
// Refetch so any optimistic local status reverts on failure // Refetch so any optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() }); queryClient.invalidateQueries({ queryKey: orpc.tracking.key() });
} }
}, },