mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -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 { 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} />}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,28 +57,20 @@ 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,
|
orpc.library.upcoming.infiniteOptions({
|
||||||
isError,
|
input: (pageParam: string | undefined) => ({
|
||||||
refetch,
|
days: 90,
|
||||||
fetchNextPage,
|
limit: 20,
|
||||||
hasNextPage,
|
cursor: pageParam,
|
||||||
isFetchingNextPage,
|
mediaType: mediaType !== "all" ? mediaType : undefined,
|
||||||
isRefetching,
|
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
|
||||||
} = useInfiniteQuery(
|
}),
|
||||||
orpc.library.upcoming.infiniteOptions({
|
initialPageParam: undefined as string | undefined,
|
||||||
input: (pageParam: string | undefined) => ({
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? 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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
{isWatched ? (
|
<View style={{ opacity: isPending ? 0.5 : 1 }}>
|
||||||
<ScaledIcon icon={IconCircleCheckFilled} size={22} color={accentColor} />
|
{isWatched ? (
|
||||||
) : (
|
<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 };
|
||||||
|
}
|
||||||
@@ -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`),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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() });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user