mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user