refactor(native): centralize title mutations into useTitleActions, extract server-connection logic, and unify hero banner

- Add `useTitleActions` hook that owns all title mutation side-effects (toast messages, query invalidation) and replace inline `useMutation` calls in the title detail, search, and person screens, as well as `usePosterActions`, with it
- Extract the server URL watching, instance-ID provisioning, storage-scope management, reachability monitor, and session-reconciliation logic from `_layout.tsx` into a `useServerConnection` hook and a `seedSessionFromCache` helper in `use-server-connection.ts`; `AppContent` now calls a single hook
- Delete the iOS-only `hero-banner.ios.tsx` and consolidate into `hero-banner.tsx`
- Extract `usePressAnimation` into its own hook file
- Move shared action helpers (quick-add, watch-movie, etc.) into `lib/title-actions.ts`
This commit is contained in:
2026-03-17 20:01:03 -04:00
parent 1257a485e9
commit b2a5c298c5
15 changed files with 609 additions and 681 deletions
+13 -32
View File
@@ -1,11 +1,7 @@
import { FlashList } from "@shopify/flash-list";
import {
skipToken,
useInfiniteQuery,
useMutation,
} from "@tanstack/react-query";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
@@ -16,9 +12,8 @@ import {
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { useDebounce } from "@/hooks/use-debounce";
import { useTitleActions } from "@/hooks/use-title-actions";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
export default function SearchScreen() {
const [query, setQuery] = useState("");
@@ -36,33 +31,15 @@ export default function SearchScreen() {
}),
});
// Track which item is currently being added
const [addingId, setAddingId] = useState<string | null>(null);
const { quickAdd: quickAddMutation } = useTitleActions();
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
setAddingId(null);
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => {
setAddingId(null);
toast.error("Failed to add to watchlist");
},
}),
const handleQuickAdd = useCallback(
(id: string) => {
quickAddMutation.mutate({ id });
},
[quickAddMutation],
);
// Use ref for mutation.mutate to keep callback stable across renders
const quickAddMutateRef = useRef(quickAddMutation.mutate);
quickAddMutateRef.current = quickAddMutation.mutate;
const handleQuickAdd = useCallback((id: string) => {
setAddingId(id);
quickAddMutateRef.current({ id });
}, []);
// Memoize mapped results to maintain stable references
const allResults = useMemo<SearchResultItem[]>(
() =>
@@ -79,6 +56,10 @@ export default function SearchScreen() {
[searchResults.data?.pages],
);
const addingId = quickAddMutation.isPending
? (quickAddMutation.variables?.id ?? null)
: null;
const renderItem = useCallback(
({ item }: { item: SearchResultItem }) => (
<SearchResultRow
+9 -125
View File
@@ -5,12 +5,7 @@ import {
persistQueryClientRestore,
persistQueryClientSubscribe,
} from "@tanstack/react-query-persist-client";
import {
Stack,
useGlobalSearchParams,
usePathname,
useRouter,
} from "expo-router";
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import {
@@ -26,49 +21,24 @@ import { enableFreeze } from "react-native-screens";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-banner";
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
import { getCachedSession } from "@/lib/cached-session";
import { authClient } from "@/lib/auth-client";
import {
clearStorageScope,
hasScopedStorage,
onStorageScopeChange,
queryPersister,
setStorageScope,
} from "@/lib/mmkv";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
import { queryClient } from "@/lib/query-client";
import {
onServerReachabilityChange,
startReachabilityMonitor,
} from "@/lib/server-reachability";
import {
ensureInstanceId,
getCurrentInstanceId,
hasStoredServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
import { getCurrentInstanceId } from "@/lib/server-url";
import { sofaTheme } from "@/lib/theme";
import { toast } from "@/lib/toast";
import {
seedSessionFromCache,
useServerConnection,
} from "@/lib/use-server-connection";
SplashScreen.preventAutoHideAsync();
enableFreeze(true);
// Seed the session atom with cached data from SecureStore before React renders.
// This allows the app to show cached data immediately when the server is
// unreachable, instead of hanging on the splash screen or dumping the user
// on the login screen. Better Auth's background fetch will still fire and
// update the atom when the server responds.
const cachedSession = getCachedSession();
if (cachedSession) {
const sessionAtom = authClient.$store.atoms.session;
sessionAtom.set({
data: cachedSession,
error: null,
isPending: false,
isRefetching: true,
refetch: sessionAtom.get().refetch,
});
}
seedSessionFromCache();
export const unstable_settings = {
initialRouteName: "(tabs)",
@@ -95,45 +65,7 @@ const changePasswordOptions =
function AppContent() {
const contentStyle = useResolveClassNames("bg-background");
// Force re-render when server URL changes so useSession()
// re-subscribes to the rebuilt authClient's session atom.
const [, setUrlVersion] = useState(0);
useEffect(() => onServerUrlChange(() => setUrlVersion((n) => n + 1)), []);
const { data: session, isPending } = authClient.useSession();
const hasServerUrl =
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
// --- Ensure instance ID is available (handles upgrades and env-based URLs) ---
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
useEffect(() => {
if (!instanceId && hasServerUrl) {
ensureInstanceId().then((id) => {
if (id) {
setInstanceId(id);
rebuildAuthClient();
}
});
}
}, [instanceId, hasServerUrl]);
// Re-sync instanceId when server URL changes (registerServer sets it synchronously)
useEffect(() => {
return onServerUrlChange(() => setInstanceId(getCurrentInstanceId()));
}, []);
// --- Set storage scope when we have both instanceId and userId ---
const userId = session?.user?.id;
useEffect(() => {
if (instanceId && userId) {
setStorageScope(instanceId, userId);
} else if (!userId && hasScopedStorage()) {
clearStorageScope();
}
}, [instanceId, userId]);
const { session, isPending, hasServerUrl } = useServerConnection();
// --- App Tracking Transparency (must resolve before screen tracking) ---
const [trackingReady, setTrackingReady] = useState(false);
@@ -188,54 +120,6 @@ function AppContent() {
}
}, [isPending, hasServerUrl]);
// --- Server reachability monitor ---
useEffect(() => {
if (!hasServerUrl) return;
return startReachabilityMonitor();
}, [hasServerUrl]);
// --- Session reconciliation: re-validate when server comes back ---
// Tracks whether the current session was seeded from cache and has NOT
// yet been confirmed by the server. Once confirmed (isRefetching becomes
// false while session still exists), this flips to false so that an
// explicit sign-out doesn't show a misleading "session expired" toast.
const hadOptimisticSession = useRef(!!cachedSession);
const prevSession = useRef(session);
const { isRefetching } = authClient.useSession();
if (hadOptimisticSession.current && session && !isRefetching) {
hadOptimisticSession.current = false;
}
useEffect(() => {
return onServerReachabilityChange((reachable) => {
if (reachable) {
// Server came back — trigger session re-validation
const atom = authClient.$store.atoms.session;
atom.get().refetch?.();
}
});
}, []);
const { replace } = useRouter();
// When session is lost (sign-out or server invalidation), explicitly
// navigate to auth. Stack.Protected handles screen availability, but
// enableFreeze can prevent the navigator from transitioning on its own.
useEffect(() => {
if (prevSession.current && !session) {
replace("/(auth)/login");
if (hadOptimisticSession.current) {
toast.info("Session expired", {
description: "Please sign in again.",
});
hadOptimisticSession.current = false;
}
}
prevSession.current = session;
}, [session, replace]);
return (
<ThemeProvider value={sofaTheme}>
<StatusBar style="light" />
+9 -2
View File
@@ -29,7 +29,7 @@ import { ScaledIcon } from "@/components/ui/scaled-icon";
import { SectionHeader } from "@/components/ui/section-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Text } from "@/components/ui/text";
import { usePosterActions } from "@/hooks/use-poster-actions";
import { useTitleActions } from "@/hooks/use-title-actions";
import { orpc } from "@/lib/orpc";
import { addRecentlyViewed } from "@/lib/recently-viewed";
@@ -75,7 +75,14 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const { handleQuickAdd, addingKey } = usePosterActions();
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => quickAdd.mutate({ id }),
[quickAdd],
);
const addingKey = quickAdd.isPending
? (quickAdd.variables?.id ?? null)
: null;
const {
data,
+15 -62
View File
@@ -11,7 +11,7 @@ import {
IconThumbUp,
IconUsers,
} from "@tabler/icons-react-native";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { LinearGradient } from "expo-linear-gradient";
import { Link, useLocalSearchParams, useRouter } from "expo-router";
@@ -43,11 +43,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { StarRating } from "@/components/ui/star-rating";
import { Text } from "@/components/ui/text";
import { useTitleActions } from "@/hooks/use-title-actions";
import { useTitleTheme } from "@/hooks/use-title-theme";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { addRecentlyViewed } from "@/lib/recently-viewed";
import { toast } from "@/lib/toast";
const titleGenresContentStyle = { paddingHorizontal: 16 };
const titleAvailabilityContentStyle = { gap: 8, paddingHorizontal: 16 };
@@ -108,65 +107,19 @@ export default function TitleDetailScreen() {
orpc.titles.recommendations.queryOptions({ input: { id } }),
);
const updateStatus = useMutation(
orpc.titles.updateStatus.mutationOptions({
onSuccess: (_data, { status }) => {
const statusMessages: Record<string, string> = {
watchlist: "Added to watchlist",
in_progress: "Marked as watching",
completed: "Marked as completed",
};
toast.success(
status
? (statusMessages[status] ?? "Status updated")
: "Removed from library",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to update status"),
}),
);
const updateRating = useMutation(
orpc.titles.updateRating.mutationOptions({
onSuccess: (_data, { stars }) => {
toast.success(
stars > 0
? `Rated ${stars} star${stars > 1 ? "s" : ""}`
: "Rating removed",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
onError: () => toast.error("Failed to update rating"),
}),
);
const watchMovie = useMutation(
orpc.titles.watchMovie.mutationOptions({
onSuccess: () => {
toast.success(
title?.title
? `Marked "${title.title}" as watched`
: "Marked as watched",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to mark as watched"),
}),
);
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to add to watchlist"),
}),
);
const {
updateStatus,
updateRating,
watchMovie,
quickAdd: quickAddMutation,
} = useTitleActions({
toasts: {
watchMovie: () =>
title?.title
? `Marked "${title.title}" as watched`
: "Marked as watched",
},
});
const title = detail.data?.title;
const palette = title?.colorPalette ?? null;
@@ -1,18 +1,11 @@
import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import { usePressAnimation } from "@/hooks/use-press-animation";
import { titleActions } from "@/lib/title-actions";
export interface ContinueWatchingItem {
title: {
@@ -33,23 +26,7 @@ export interface ContinueWatchingItem {
}
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.97]),
},
],
}));
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
const { animatedStyle, gesture: tapGesture } = usePressAnimation();
const progressLabel = `${item.watchedEpisodes} of ${item.totalEpisodes} episodes`;
const nextEpLabel = item.nextEpisode
@@ -142,37 +119,15 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
<Link.MenuAction
title="Mark as Completed"
icon="checkmark.circle"
onPress={async () => {
await client.titles.updateStatus({
id: item.title.id,
status: "completed",
});
toast.success(`Marked "${item.title.title}" as completed`);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
onPress={() =>
titleActions.markCompleted(item.title.id, item.title.title)
}
/>
<Link.MenuAction
title="Remove from Library"
icon="trash"
destructive
onPress={async () => {
await client.titles.updateStatus({
id: item.title.id,
status: null,
});
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
onPress={() => titleActions.removeFromLibrary(item.title.id)}
/>
</Link.Menu>
</Link>
@@ -7,7 +7,7 @@ import {
horizontalListStyle,
} from "@/components/ui/horizontal-list-spacing";
import { PosterCard, PosterCardSkeleton } from "@/components/ui/poster-card";
import { usePosterActions } from "@/hooks/use-poster-actions";
import { useTitleActions } from "@/hooks/use-title-actions";
export interface PosterRowItem {
id: string;
@@ -29,7 +29,14 @@ export function HorizontalPosterRow({
items: PosterRowItem[];
isLoading?: boolean;
}) {
const { handleQuickAdd, addingKey } = usePosterActions();
const { quickAdd } = useTitleActions();
const handleQuickAdd = useCallback(
(id: string) => quickAdd.mutate({ id }),
[quickAdd],
);
const addingKey = quickAdd.isPending
? (quickAdd.variables?.id ?? null)
: null;
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
@@ -1,187 +0,0 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
export interface HeroBannerItem {
id: string;
title: string;
type: string;
backdropPath?: string | null;
overview?: string | null;
voteAverage?: number | null;
releaseDate?: string | null;
}
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.98]),
},
],
}));
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
const useGlass = isLiquidGlassAvailable();
const accessibilityLabel = [
item.title,
item.releaseDate?.slice(0, 4),
item.voteAverage ? `${item.voteAverage.toFixed(1)} stars` : undefined,
]
.filter(Boolean)
.join(", ");
const titleHref = `/title/${item.id}` as `/title/${string}`;
return (
<GestureDetector gesture={tapGesture}>
<Animated.View
className="mx-4 overflow-hidden rounded-2xl"
style={[
animatedStyle,
{
height: 220,
borderCurve: "continuous",
},
]}
>
<Link href={titleHref} asChild>
<Link.Trigger>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
style={{ flex: 1 }}
>
{item.backdropPath && (
<Image
source={{ uri: item.backdropPath }}
className="absolute h-full w-full"
contentFit="cover"
/>
)}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</>
)}
</Pressable>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction
title="Add to Watchlist"
icon="bookmark"
onPress={async () => {
await client.titles.quickAdd({ id: item.id });
toast.success(`Added "${item.title}" to watchlist`);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
/>
</Link.Menu>
</Link>
</Animated.View>
</GestureDetector>
);
}
@@ -1,21 +1,15 @@
import { IconStarFilled } from "@tabler/icons-react-native";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { Link } from "expo-router";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import { usePressAnimation } from "@/hooks/use-press-animation";
import { titleActions } from "@/lib/title-actions";
export interface HeroBannerItem {
id: string;
@@ -29,24 +23,9 @@ export interface HeroBannerItem {
export function HeroBanner({ item }: { item: HeroBannerItem }) {
const primary = useCSSVariable("--color-primary") as string;
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.98]),
},
],
}));
const tapGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
const { animatedStyle, gesture: tapGesture } = usePressAnimation(0.98);
const useGlass = isLiquidGlassAvailable();
const accessibilityLabel = [
item.title,
item.releaseDate?.slice(0, 4),
@@ -71,7 +50,6 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Link href={titleHref} asChild>
<Link.Trigger>
<Pressable
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Opens title details"
@@ -84,43 +62,91 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
{useGlass ? (
<GlassView
glassEffectStyle="regular"
colorScheme="dark"
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
padding: 16,
}}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.overview}
{item.title}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</GlassView>
) : (
<>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
/>
<View className="flex-1 justify-end p-4">
<Text
className="font-display text-2xl text-white"
numberOfLines={2}
>
{item.title}
</Text>
{item.overview ? (
<Text
className="mt-1 text-white/70 text-xs"
numberOfLines={2}
>
{item.overview}
</Text>
) : null}
<View className="mt-2 flex-row items-center gap-2">
{item.voteAverage != null && item.voteAverage > 0 && (
<View className="flex-row items-center gap-1">
<ScaledIcon
icon={IconStarFilled}
size={12}
color={primary}
/>
<Text className="text-primary text-xs">
{item.voteAverage.toFixed(1)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
)}
<Text className="text-white/50 text-xs">
{item.releaseDate?.slice(0, 4)}
</Text>
</View>
</View>
</View>
</>
)}
</Pressable>
</Link.Trigger>
<Link.Preview />
@@ -128,16 +154,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
<Link.MenuAction
title="Add to Watchlist"
icon="bookmark"
onPress={async () => {
await client.titles.quickAdd({ id: item.id });
toast.success(`Added "${item.title}" to watchlist`);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
onPress={() => titleActions.quickAdd(item.id, item.title)}
/>
</Link.Menu>
</Link>
@@ -1,5 +1,4 @@
import { IconChevronDown } from "@tabler/icons-react-native";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { InteractionManager, Pressable, View } from "react-native";
import Animated, {
@@ -14,9 +13,7 @@ import { useCSSVariable } from "uniwind";
import { EpisodeRow } from "@/components/titles/episode-row";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import { useTitleActions } from "@/hooks/use-title-actions";
export function SeasonAccordion({
season,
@@ -79,50 +76,23 @@ export function SeasonAccordion({
transform: [{ rotate: `${chevronRotation.get()}deg` }],
}));
const watchEpisode = useMutation(
orpc.episodes.watch.mutationOptions({
onSuccess: (_data, { id: epId }) => {
const { watchEpisode, unwatchEpisode, watchSeason } = useTitleActions({
toasts: {
watchEpisode: ({ id: epId }) => {
const ep = episodes.find((e) => e.id === epId);
toast.success(
ep
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode watched",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
return ep
? `Watched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode watched";
},
onError: () => toast.error("Failed to mark episode"),
}),
);
const unwatchEpisode = useMutation(
orpc.episodes.unwatch.mutationOptions({
onSuccess: (_data, { id: epId }) => {
unwatchEpisode: ({ id: epId }) => {
const ep = episodes.find((e) => e.id === epId);
toast.success(
ep
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode unwatched",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
return ep
? `Unwatched S${season.seasonNumber} E${ep.episodeNumber}`
: "Episode unwatched";
},
onError: () => toast.error("Failed to unmark episode"),
}),
);
const watchSeason = useMutation(
orpc.seasons.watch.mutationOptions({
onSuccess: () => {
toast.success(
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => toast.error("Failed to mark some episodes"),
}),
);
watchSeason: `Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
},
});
const handleEpisodeToggle = useCallback(
(episodeId: string) => {
+8 -67
View File
@@ -11,22 +11,15 @@ import {
import { Link } from "expo-router";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Skeleton } from "@/components/ui/skeleton";
import { Text } from "@/components/ui/text";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
import { usePressAnimation } from "@/hooks/use-press-animation";
import { titleActions } from "@/lib/title-actions";
type TitleStatus = "watchlist" | "in_progress" | "completed";
@@ -70,16 +63,7 @@ export function PosterCard({
completed: completedColor,
};
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: reduceMotion ? 1 : interpolate(pressed.get(), [0, 1], [1, 0.97]),
},
],
}));
const { animatedStyle, gesture: pressGesture } = usePressAnimation();
const handleQuickAddPress = useCallback(() => {
if (userStatus || isAdding) return;
@@ -230,15 +214,6 @@ export function PosterCard({
</Pressable>
) : null;
// Gesture for UI-thread press animation (used by both paths)
const pressGesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, { damping: 15, stiffness: 300 }));
})
.onFinalize(() => {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
});
const titleHref = `/title/${id}` as `/title/${string}`;
return (
@@ -267,39 +242,14 @@ export function PosterCard({
<Link.MenuAction
title="Mark as Watching"
icon="play.fill"
onPress={async () => {
await client.titles.updateStatus({
id,
status: "in_progress",
});
toast.success("Marked as watching");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
onPress={() => titleActions.markWatching(id)}
/>
)}
{type === "movie" && (
<Link.MenuAction
title="Mark as Watched"
icon="checkmark.circle"
onPress={async () => {
await client.titles.watchMovie({ id });
toast.success(
title
? `Marked "${title}" as watched`
: "Marked as watched",
);
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
onPress={() => titleActions.markMovieWatched(id, title)}
/>
)}
{userStatus && (
@@ -307,16 +257,7 @@ export function PosterCard({
title="Remove from Library"
icon="trash"
destructive
onPress={async () => {
await client.titles.updateStatus({ id, status: null });
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
onPress={() => titleActions.removeFromLibrary(id)}
/>
)}
</Link.Menu>
@@ -1,37 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
/**
* Provides shared quickAdd handlers for PosterCard lists.
* Use once per list parent instead of per-card to avoid creating
* a mutation observer for every mounted PosterCard.
*/
export function usePosterActions() {
const quickAddMutation = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: () => {
toast.success("Added to watchlist");
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
},
onError: () => {
toast.error("Failed to add to watchlist");
// Refetch so optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const handleQuickAdd = (id: string) => {
quickAddMutation.mutate({ id });
};
const addingKey =
quickAddMutation.isPending && quickAddMutation.variables
? quickAddMutation.variables.id
: null;
return { handleQuickAdd, addingKey };
}
@@ -0,0 +1,39 @@
import { Gesture } from "react-native-gesture-handler";
import {
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
} from "react-native-reanimated";
const SPRING_CONFIG = { damping: 15, stiffness: 300 };
/**
* Reanimated press-to-shrink animation with a tap gesture.
* Respects reduced motion preferences.
*/
export function usePressAnimation(scale = 0.97) {
const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: reduceMotion
? 1
: interpolate(pressed.get(), [0, 1], [1, scale]),
},
],
}));
const gesture = Gesture.Tap()
.onBegin(() => {
pressed.set(withSpring(1, SPRING_CONFIG));
})
.onFinalize(() => {
pressed.set(withSpring(0, SPRING_CONFIG));
});
return { animatedStyle, gesture };
}
+135
View File
@@ -0,0 +1,135 @@
import { useMutation } from "@tanstack/react-query";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { invalidateTitleQueries } from "@/lib/title-actions";
import { toast } from "@/lib/toast";
type ToastOverride<TInput> = string | ((input: TInput) => string);
function resolveToast<TInput>(
override: ToastOverride<TInput> | undefined,
fallback: string,
input: TInput,
): string {
if (!override) return fallback;
return typeof override === "function" ? override(input) : override;
}
interface UseTitleActionsOptions {
toasts?: {
quickAdd?: ToastOverride<{ id: string }>;
updateStatus?: ToastOverride<{ id: string; status: string | null }>;
watchMovie?: ToastOverride<{ id: string }>;
updateRating?: ToastOverride<{ id: string; stars: number }>;
watchEpisode?: ToastOverride<{ id: string }>;
unwatchEpisode?: ToastOverride<{ id: string }>;
watchSeason?: ToastOverride<{ id: string }>;
};
}
/**
* Tracked title mutations with loading states.
* Each returned property is a full UseMutationResult with isPending, mutate, etc.
*/
export function useTitleActions(options?: UseTitleActionsOptions) {
const t = options?.toasts;
const quickAdd = useMutation(
orpc.titles.quickAdd.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(t?.quickAdd, "Added to watchlist", input));
invalidateTitleQueries();
},
onError: () => {
toast.error("Failed to add to watchlist");
// Refetch so optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
}),
);
const updateStatus = useMutation(
orpc.titles.updateStatus.mutationOptions({
onSuccess: (_data, input) => {
const statusMessages: Record<string, string> = {
watchlist: "Added to watchlist",
in_progress: "Marked as watching",
completed: "Marked as completed",
};
const defaultMsg = input.status
? (statusMessages[input.status] ?? "Status updated")
: "Removed from library";
toast.success(resolveToast(t?.updateStatus, defaultMsg, input));
invalidateTitleQueries();
},
onError: () => toast.error("Failed to update status"),
}),
);
const watchMovie = useMutation(
orpc.titles.watchMovie.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(t?.watchMovie, "Marked as watched", input));
invalidateTitleQueries();
},
onError: () => toast.error("Failed to mark as watched"),
}),
);
const updateRating = useMutation(
orpc.titles.updateRating.mutationOptions({
onSuccess: (_data, input) => {
const defaultMsg =
input.stars > 0
? `Rated ${input.stars} star${input.stars > 1 ? "s" : ""}`
: "Rating removed";
toast.success(resolveToast(t?.updateRating, defaultMsg, input));
// Rating only invalidates title queries, not dashboard
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
},
onError: () => toast.error("Failed to update rating"),
}),
);
const watchEpisode = useMutation(
orpc.episodes.watch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(t?.watchEpisode, "Episode watched", input));
invalidateTitleQueries();
},
onError: () => toast.error("Failed to mark episode"),
}),
);
const unwatchEpisode = useMutation(
orpc.episodes.unwatch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(
resolveToast(t?.unwatchEpisode, "Episode unwatched", input),
);
invalidateTitleQueries();
},
onError: () => toast.error("Failed to unmark episode"),
}),
);
const watchSeason = useMutation(
orpc.seasons.watch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(t?.watchSeason, "Season watched", input));
invalidateTitleQueries();
},
onError: () => toast.error("Failed to mark some episodes"),
}),
);
return {
quickAdd,
updateStatus,
watchMovie,
updateRating,
watchEpisode,
unwatchEpisode,
watchSeason,
};
}
+121
View File
@@ -0,0 +1,121 @@
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
/** Invalidate title + dashboard queries. Used by most title mutations. */
export function invalidateTitleQueries() {
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
}
/**
* Fire-and-forget title actions for context menus and simple onPress handlers.
* Each method calls the RPC, shows a toast, and invalidates the relevant queries.
*/
export const titleActions = {
async quickAdd(id: string, titleName?: string) {
try {
await client.titles.quickAdd({ id });
toast.success(
titleName ? `Added "${titleName}" to watchlist` : "Added to watchlist",
);
invalidateTitleQueries();
} catch {
toast.error("Failed to add to watchlist");
// Refetch so any optimistic local status reverts on failure
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
}
},
async markWatching(id: string) {
try {
await client.titles.updateStatus({ id, status: "in_progress" });
toast.success("Marked as watching");
invalidateTitleQueries();
} catch {
toast.error("Failed to update status");
}
},
async markCompleted(id: string, titleName?: string) {
try {
await client.titles.updateStatus({ id, status: "completed" });
toast.success(
titleName
? `Marked "${titleName}" as completed`
: "Marked as completed",
);
invalidateTitleQueries();
} catch {
toast.error("Failed to update status");
}
},
async markMovieWatched(id: string, titleName?: string) {
try {
await client.titles.watchMovie({ id });
toast.success(
titleName ? `Marked "${titleName}" as watched` : "Marked as watched",
);
invalidateTitleQueries();
} catch {
toast.error("Failed to mark as watched");
}
},
async removeFromLibrary(id: string) {
try {
await client.titles.updateStatus({ id, status: null });
toast.success("Removed from library");
invalidateTitleQueries();
} catch {
toast.error("Failed to remove from library");
}
},
async rate(id: string, stars: number) {
try {
await client.titles.updateRating({ id, stars });
toast.success(
stars > 0
? `Rated ${stars} star${stars > 1 ? "s" : ""}`
: "Rating removed",
);
queryClient.invalidateQueries({ queryKey: orpc.titles.key() });
} catch {
toast.error("Failed to update rating");
}
},
async watchEpisode(id: string) {
try {
await client.episodes.watch({ id });
toast.success("Episode watched");
invalidateTitleQueries();
} catch {
toast.error("Failed to mark episode");
}
},
async unwatchEpisode(id: string) {
try {
await client.episodes.unwatch({ id });
toast.success("Episode unwatched");
invalidateTitleQueries();
} catch {
toast.error("Failed to unmark episode");
}
},
async watchSeason(id: string, seasonName?: string) {
try {
await client.seasons.watch({ id });
toast.success(
seasonName ? `Watched all of ${seasonName}` : "Season watched",
);
invalidateTitleQueries();
} catch {
toast.error("Failed to mark some episodes");
}
},
};
@@ -0,0 +1,142 @@
import { useRouter } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { authClient, rebuildAuthClient } from "@/lib/auth-client";
import { getCachedSession } from "@/lib/cached-session";
import {
clearStorageScope,
hasScopedStorage,
setStorageScope,
} from "@/lib/mmkv";
import {
onServerReachabilityChange,
startReachabilityMonitor,
} from "@/lib/server-reachability";
import {
ensureInstanceId,
getCurrentInstanceId,
hasStoredServerUrl,
onServerUrlChange,
} from "@/lib/server-url";
import { toast } from "@/lib/toast";
let _cachedSessionSeeded = false;
/**
* Seed the Better Auth session atom from SecureStore before React renders.
* Call at module scope in the root layout, before any component renders.
*/
export function seedSessionFromCache(): void {
const cached = getCachedSession();
if (cached) {
_cachedSessionSeeded = true;
const sessionAtom = authClient.$store.atoms.session;
sessionAtom.set({
data: cached,
error: null,
isPending: false,
isRefetching: true,
refetch: sessionAtom.get().refetch,
});
}
}
/**
* Manages the full server connection lifecycle:
* - URL change subscriptions
* - Instance ID resolution
* - Auth client rebuilding
* - Scoped storage management
* - Reachability monitoring
* - Session reconciliation and expiry detection
*/
export function useServerConnection() {
// Force re-render when server URL changes so useSession()
// re-subscribes to the rebuilt authClient's session atom.
const [, setUrlVersion] = useState(0);
useEffect(() => onServerUrlChange(() => setUrlVersion((n) => n + 1)), []);
const { data: session, isPending, isRefetching } = authClient.useSession();
const hasServerUrl =
!!process.env.EXPO_PUBLIC_SERVER_URL || hasStoredServerUrl();
// --- Instance ID resolution ---
const [instanceId, setInstanceId] = useState(getCurrentInstanceId);
useEffect(() => {
if (!instanceId && hasServerUrl) {
ensureInstanceId().then((id) => {
if (id) {
setInstanceId(id);
rebuildAuthClient();
}
});
}
}, [instanceId, hasServerUrl]);
// Re-sync instanceId when server URL changes
useEffect(
() => onServerUrlChange(() => setInstanceId(getCurrentInstanceId())),
[],
);
// --- Scoped storage (per instance + user) ---
const userId = session?.user?.id;
useEffect(() => {
if (instanceId && userId) {
setStorageScope(instanceId, userId);
} else if (!userId && hasScopedStorage()) {
clearStorageScope();
}
}, [instanceId, userId]);
// --- Reachability monitor ---
useEffect(() => {
if (!hasServerUrl) return;
return startReachabilityMonitor();
}, [hasServerUrl]);
// --- Session reconciliation ---
// Re-validate session when server comes back online.
useEffect(
() =>
onServerReachabilityChange((reachable) => {
if (reachable) {
authClient.$store.atoms.session.get().refetch?.();
}
}),
[],
);
// Track whether the session was seeded from cache and hasn't yet been
// confirmed by the server. Once confirmed, flip to false so explicit
// sign-outs don't show a misleading "session expired" toast.
const hadOptimisticSession = useRef(_cachedSessionSeeded);
const prevSession = useRef(session);
if (hadOptimisticSession.current && session && !isRefetching) {
hadOptimisticSession.current = false;
}
const { replace } = useRouter();
// Navigate to login when session is lost. Stack.Protected handles screen
// availability, but enableFreeze can prevent the navigator from
// transitioning on its own.
useEffect(() => {
if (prevSession.current && !session) {
replace("/(auth)/login");
if (hadOptimisticSession.current) {
toast.info("Session expired", {
description: "Please sign in again.",
});
hadOptimisticSession.current = false;
}
}
prevSession.current = session;
}, [session, replace]);
return { session, isPending, hasServerUrl, instanceId };
}