feat(native): add context menus to poster cards, hero banners, and continue-watching cards; remove failedKey error state

- Add `Link.Menu` with contextual actions to `PosterCard` (add to watchlist, mark completed, remove), `HeroBanner` ("Add to Watchlist"), and `ContinueWatchingCard` ("Mark as Completed", "Remove from Library") — all invalidate the relevant `titles` and `dashboard` query keys on success
- Remove the `failedKey`/`resetError` error-state pattern from `usePosterActions` and strip the now-unused `failedKey` and `onQuickAddFailed` props from every call site (`HorizontalPosterRow`, `person/[id].tsx`, `PosterCard`)
- Restructure `ContinueWatchingCard` JSX so `GestureDetector` wraps the outer `Animated.View` rather than sitting inside `Link`, fixing gesture/preview layering
- Simplify single-argument plugin entries in `app.json` from `["plugin-name"]` to `"plugin-name"`
This commit is contained in:
2026-03-17 19:32:00 -04:00
parent c6ae58f41d
commit 1257a485e9
8 changed files with 262 additions and 247 deletions
+3 -13
View File
@@ -23,18 +23,8 @@
"package": "com.jakejarvis.sofa" "package": "com.jakejarvis.sofa"
}, },
"plugins": [ "plugins": [
[
"expo-router", "expo-router",
{
"root": "./src/app"
}
],
[
"expo-secure-store", "expo-secure-store",
{
"configureAndroidBackup": true
}
],
[ [
"expo-font", "expo-font",
{ {
@@ -101,15 +91,15 @@
"imageWidth": 200 "imageWidth": 200
} }
], ],
["expo-system-ui"], "expo-system-ui",
["expo-web-browser"], "expo-web-browser",
[ [
"expo-image-picker", "expo-image-picker",
{ {
"microphonePermission": false "microphonePermission": false
} }
], ],
["expo-localization"], "expo-localization",
[ [
"expo-tracking-transparency", "expo-tracking-transparency",
{ {
+2 -12
View File
@@ -75,8 +75,7 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string; const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const { handleQuickAdd, addingKey, failedKey, resetError } = const { handleQuickAdd, addingKey } = usePosterActions();
usePosterActions();
const { const {
data, data,
@@ -144,19 +143,10 @@ export default function PersonDetailScreen() {
width={columnWidth} width={columnWidth}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingKey === credit.titleId} isAdding={addingKey === credit.titleId}
failedKey={failedKey}
onQuickAddFailed={resetError}
/> />
</View> </View>
), ),
[ [columnWidth, userStatuses, handleQuickAdd, addingKey],
columnWidth,
userStatuses,
handleQuickAdd,
addingKey,
failedKey,
resetError,
],
); );
if (isPending) { if (isPending) {
@@ -1,5 +1,5 @@
import { Link } from "expo-router"; import { Link } from "expo-router";
import { View } from "react-native"; import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
interpolate, interpolate,
@@ -10,6 +10,9 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; 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 ContinueWatchingItem { export interface ContinueWatchingItem {
title: { title: {
@@ -57,21 +60,20 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
.join(", "); .join(", ");
return ( return (
<GestureDetector gesture={tapGesture}>
<Animated.View className="w-[200px]" style={animatedStyle}>
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild> <Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
<Link.Trigger withAppleZoom> <Link.Trigger withAppleZoom>
<GestureDetector gesture={tapGesture}> <Pressable
<Animated.View
accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={cardLabel} accessibilityLabel={cardLabel}
className="w-[200px] overflow-hidden rounded-[12px] border bg-card" >
style={[ <View
animatedStyle, className="overflow-hidden rounded-[12px] border bg-card"
{ style={{
borderColor: "rgba(255,255,255,0.06)", borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous", borderCurve: "continuous",
}, }}
]}
> >
<View className="h-28 w-[200px]"> <View className="h-28 w-[200px]">
{(item.nextEpisode?.stillPath || item.title.backdropPath) && ( {(item.nextEpisode?.stillPath || item.title.backdropPath) && (
@@ -132,10 +134,49 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
</Text> </Text>
)} )}
</View> </View>
</Animated.View> </View>
</GestureDetector> </Pressable>
</Link.Trigger> </Link.Trigger>
<Link.Preview /> <Link.Preview />
<Link.Menu>
<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(),
});
}}
/>
<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(),
});
}}
/>
</Link.Menu>
</Link> </Link>
</Animated.View>
</GestureDetector>
); );
} }
@@ -29,8 +29,7 @@ export function HorizontalPosterRow({
items: PosterRowItem[]; items: PosterRowItem[];
isLoading?: boolean; isLoading?: boolean;
}) { }) {
const { handleQuickAdd, addingKey, failedKey, resetError } = const { handleQuickAdd, addingKey } = usePosterActions();
usePosterActions();
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []); const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => ( ({ item }: { item: PosterRowItem }) => (
@@ -46,11 +45,9 @@ export function HorizontalPosterRow({
episodeProgress={item.episodeProgress} episodeProgress={item.episodeProgress}
onQuickAdd={handleQuickAdd} onQuickAdd={handleQuickAdd}
isAdding={addingKey === item.id} isAdding={addingKey === item.id}
failedKey={failedKey}
onQuickAddFailed={resetError}
/> />
), ),
[addingKey, failedKey, handleQuickAdd, resetError], [addingKey, handleQuickAdd],
); );
if (isLoading) { if (isLoading) {
@@ -13,6 +13,9 @@ import Animated, {
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text"; 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 { export interface HeroBannerItem {
id: string; id: string;
@@ -161,6 +164,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
</Pressable> </Pressable>
</Link.Trigger> </Link.Trigger>
<Link.Preview /> <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> </Link>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
@@ -13,6 +13,9 @@ import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { client, orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
export interface HeroBannerItem { export interface HeroBannerItem {
id: string; id: string;
@@ -121,6 +124,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
</Pressable> </Pressable>
</Link.Trigger> </Link.Trigger>
<Link.Preview /> <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> </Link>
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
+46 -74
View File
@@ -9,7 +9,7 @@ import {
IconStarFilled, IconStarFilled,
} from "@tabler/icons-react-native"; } from "@tabler/icons-react-native";
import { Link } from "expo-router"; import { Link } from "expo-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback } from "react";
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, {
@@ -20,7 +20,6 @@ import Animated, {
withSpring, withSpring,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useCSSVariable } from "uniwind"; import { useCSSVariable } from "uniwind";
import * as ContextMenu from "zeego/context-menu";
import { Image } from "@/components/ui/image"; import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon"; import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -44,8 +43,6 @@ interface PosterCardProps {
width?: number; width?: number;
onQuickAdd: (id: string) => void; onQuickAdd: (id: string) => void;
isAdding?: boolean; isAdding?: boolean;
failedKey?: string | null;
onQuickAddFailed?: () => void;
} }
export function PosterCard({ export function PosterCard({
@@ -61,8 +58,6 @@ export function PosterCard({
width = 140, width = 140,
onQuickAdd, onQuickAdd,
isAdding, isAdding,
failedKey,
onQuickAddFailed,
}: PosterCardProps) { }: PosterCardProps) {
const primaryColor = useCSSVariable("--color-primary") as string; const primaryColor = useCSSVariable("--color-primary") as string;
const watchlistColor = useCSSVariable("--color-status-watchlist") as string; const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
@@ -77,20 +72,6 @@ export function PosterCard({
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const pressed = useSharedValue(0); const pressed = useSharedValue(0);
const [localStatus, setLocalStatus] = useState<TitleStatus | null>(
userStatus ?? null,
);
useEffect(() => {
setLocalStatus(userStatus ?? null);
}, [userStatus]);
useEffect(() => {
if (failedKey === id) {
setLocalStatus(userStatus ?? null);
onQuickAddFailed?.();
}
}, [failedKey, id, userStatus, onQuickAddFailed]);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [
@@ -101,20 +82,19 @@ export function PosterCard({
})); }));
const handleQuickAddPress = useCallback(() => { const handleQuickAddPress = useCallback(() => {
if (localStatus || isAdding) return; if (userStatus || isAdding) return;
setLocalStatus("watchlist");
onQuickAdd(id); onQuickAdd(id);
}, [localStatus, isAdding, onQuickAdd, id]); }, [userStatus, isAdding, onQuickAdd, id]);
const year = releaseDate?.slice(0, 4); const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5; const imageHeight = width * 1.5;
const statusLabel = const statusLabel =
localStatus === "completed" userStatus === "completed"
? "completed" ? "completed"
: localStatus === "in_progress" : userStatus === "in_progress"
? "watching" ? "watching"
: localStatus === "watchlist" : userStatus === "watchlist"
? "on watchlist" ? "on watchlist"
: undefined; : undefined;
const cardAccessibilityLabel = [ const cardAccessibilityLabel = [
@@ -129,7 +109,7 @@ export function PosterCard({
const cardContent = ( const cardContent = (
<View <View
className={`overflow-hidden rounded-xl border bg-card ${ className={`overflow-hidden rounded-xl border bg-card ${
localStatus ? "border-primary/25" : "border-white/[0.06]" userStatus ? "border-primary/25" : "border-white/[0.06]"
}`} }`}
style={{ borderCurve: "continuous" }} style={{ borderCurve: "continuous" }}
> >
@@ -153,15 +133,15 @@ export function PosterCard({
)} )}
{/* Status indicator */} {/* Status indicator */}
{localStatus && ( {userStatus && (
<View <View
accessible={false} accessible={false}
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full" className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }} style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
> >
{localStatus === "completed" ? ( {userStatus === "completed" ? (
<IconCheckbox size={16} color="white" /> <IconCheckbox size={16} color="white" />
) : localStatus === "in_progress" ? ( ) : userStatus === "in_progress" ? (
<IconPlayerPlayFilled size={16} color="white" /> <IconPlayerPlayFilled size={16} color="white" />
) : ( ) : (
<IconBookmarkFilled size={16} color="white" /> <IconBookmarkFilled size={16} color="white" />
@@ -190,10 +170,10 @@ export function PosterCard({
{/* Metadata */} {/* Metadata */}
<View className="px-2.5 pt-2 pb-2.5"> <View className="px-2.5 pt-2 pb-2.5">
<View className="flex-row items-center gap-1.5"> <View className="flex-row items-center gap-1.5">
{localStatus && ( {userStatus && (
<View <View
className="size-1.5 rounded-full" className="size-1.5 rounded-full"
style={{ backgroundColor: statusColors[localStatus] }} style={{ backgroundColor: statusColors[userStatus] }}
/> />
)} )}
<Text <Text
@@ -228,7 +208,7 @@ export function PosterCard({
</View> </View>
</View> </View>
); );
const quickAddButton = !localStatus ? ( const quickAddButton = !userStatus ? (
<Pressable <Pressable
onPress={handleQuickAddPress} onPress={handleQuickAddPress}
disabled={!!isAdding} disabled={!!isAdding}
@@ -262,8 +242,6 @@ export function PosterCard({
const titleHref = `/title/${id}` as `/title/${string}`; const titleHref = `/title/${id}` as `/title/${string}`;
return ( return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}> <GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}> <Animated.View style={[animatedStyle, { width }]}>
<View> <View>
@@ -277,23 +255,19 @@ export function PosterCard({
</Pressable> </Pressable>
</Link.Trigger> </Link.Trigger>
<Link.Preview /> <Link.Preview />
</Link> <Link.Menu>
{quickAddButton} {!userStatus && (
</View> <Link.MenuAction
</Animated.View> title="Add to Watchlist"
</GestureDetector> icon="bookmark"
</ContextMenu.Trigger> onPress={handleQuickAddPress}
<ContextMenu.Content> />
{!localStatus && (
<ContextMenu.Item key="watchlist" onSelect={handleQuickAddPress}>
<ContextMenu.ItemIcon ios={{ name: "bookmark" }} />
<ContextMenu.ItemTitle>Add to Watchlist</ContextMenu.ItemTitle>
</ContextMenu.Item>
)} )}
{localStatus !== "in_progress" && ( {userStatus !== "in_progress" && (
<ContextMenu.Item <Link.MenuAction
key="watching" title="Mark as Watching"
onSelect={async () => { icon="play.fill"
onPress={async () => {
await client.titles.updateStatus({ await client.titles.updateStatus({
id, id,
status: "in_progress", status: "in_progress",
@@ -306,18 +280,18 @@ export function PosterCard({
queryKey: orpc.dashboard.key(), queryKey: orpc.dashboard.key(),
}); });
}} }}
> />
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)} )}
{type === "movie" && ( {type === "movie" && (
<ContextMenu.Item <Link.MenuAction
key="watched" title="Mark as Watched"
onSelect={async () => { icon="checkmark.circle"
onPress={async () => {
await client.titles.watchMovie({ id }); await client.titles.watchMovie({ id });
toast.success( toast.success(
title ? `Marked "${title}" as watched` : "Marked as watched", title
? `Marked "${title}" as watched`
: "Marked as watched",
); );
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: orpc.titles.key(), queryKey: orpc.titles.key(),
@@ -326,18 +300,15 @@ export function PosterCard({
queryKey: orpc.dashboard.key(), queryKey: orpc.dashboard.key(),
}); });
}} }}
> />
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)} )}
{localStatus && ( {userStatus && (
<ContextMenu.Item <Link.MenuAction
key="remove" title="Remove from Library"
icon="trash"
destructive destructive
onSelect={async () => { onPress={async () => {
await client.titles.updateStatus({ id, status: null }); await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library"); toast.success("Removed from library");
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: orpc.titles.key(), queryKey: orpc.titles.key(),
@@ -346,13 +317,14 @@ export function PosterCard({
queryKey: orpc.dashboard.key(), queryKey: orpc.dashboard.key(),
}); });
}} }}
> />
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)} )}
</ContextMenu.Content> </Link.Menu>
</ContextMenu.Root> </Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
); );
} }
+3 -16
View File
@@ -1,5 +1,4 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -25,26 +24,14 @@ export function usePosterActions() {
}), }),
); );
const handleQuickAdd = useCallback( const handleQuickAdd = (id: string) => {
(id: string) => {
quickAddMutation.mutate({ id }); quickAddMutation.mutate({ id });
}, };
[quickAddMutation.mutate],
);
const addingKey = const addingKey =
quickAddMutation.isPending && quickAddMutation.variables quickAddMutation.isPending && quickAddMutation.variables
? quickAddMutation.variables.id ? quickAddMutation.variables.id
: null; : null;
const failedKey = return { handleQuickAdd, addingKey };
quickAddMutation.isError && quickAddMutation.variables
? quickAddMutation.variables.id
: null;
const resetError = useCallback(() => {
quickAddMutation.reset();
}, [quickAddMutation.reset]);
return { handleQuickAdd, addingKey, failedKey, resetError };
} }