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
+5 -15
View File
@@ -23,18 +23,8 @@
"package": "com.jakejarvis.sofa"
},
"plugins": [
[
"expo-router",
{
"root": "./src/app"
}
],
[
"expo-secure-store",
{
"configureAndroidBackup": true
}
],
"expo-router",
"expo-secure-store",
[
"expo-font",
{
@@ -101,15 +91,15 @@
"imageWidth": 200
}
],
["expo-system-ui"],
["expo-web-browser"],
"expo-system-ui",
"expo-web-browser",
[
"expo-image-picker",
{
"microphonePermission": false
}
],
["expo-localization"],
"expo-localization",
[
"expo-tracking-transparency",
{
+2 -12
View File
@@ -75,8 +75,7 @@ export default function PersonDetailScreen() {
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const { handleQuickAdd, addingKey } = usePosterActions();
const {
data,
@@ -144,19 +143,10 @@ export default function PersonDetailScreen() {
width={columnWidth}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === credit.titleId}
failedKey={failedKey}
onQuickAddFailed={resetError}
/>
</View>
),
[
columnWidth,
userStatuses,
handleQuickAdd,
addingKey,
failedKey,
resetError,
],
[columnWidth, userStatuses, handleQuickAdd, addingKey],
);
if (isPending) {
@@ -1,5 +1,5 @@
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 Animated, {
interpolate,
@@ -10,6 +10,9 @@ 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";
export interface ContinueWatchingItem {
title: {
@@ -57,85 +60,123 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
.join(", ");
return (
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
<Link.Trigger withAppleZoom>
<GestureDetector gesture={tapGesture}>
<Animated.View
accessible
accessibilityRole="button"
accessibilityLabel={cardLabel}
className="w-[200px] overflow-hidden rounded-[12px] border bg-card"
style={[
animatedStyle,
{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
},
]}
>
<View className="h-28 w-[200px]">
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
<Image
source={{
uri: (item.nextEpisode?.stillPath ??
item.title.backdropPath) as string,
}}
thumbHash={
item.nextEpisode?.stillThumbHash ??
item.title.backdropThumbHash
}
recyclingKey={item.title.id}
className="h-full w-full"
contentFit="cover"
/>
)}
<GestureDetector gesture={tapGesture}>
<Animated.View className="w-[200px]" style={animatedStyle}>
<Link href={`/title/${item.title.id}` as `/title/${string}`} asChild>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardLabel}
>
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
/>
{item.nextEpisode && (
<View className="absolute right-2.5 bottom-3 left-2.5">
className="overflow-hidden rounded-[12px] border bg-card"
style={{
borderColor: "rgba(255,255,255,0.06)",
borderCurve: "continuous",
}}
>
<View className="h-28 w-[200px]">
{(item.nextEpisode?.stillPath || item.title.backdropPath) && (
<Image
source={{
uri: (item.nextEpisode?.stillPath ??
item.title.backdropPath) as string,
}}
thumbHash={
item.nextEpisode?.stillThumbHash ??
item.title.backdropThumbHash
}
recyclingKey={item.title.id}
className="h-full w-full"
contentFit="cover"
/>
)}
<View
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.4)" }}
/>
{item.nextEpisode && (
<View className="absolute right-2.5 bottom-3 left-2.5">
<Text
numberOfLines={1}
className="font-medium font-sans text-white/80 text-xs"
>
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
</Text>
</View>
)}
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
style={{
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
}}
/>
</View>
</View>
<View className="p-2.5">
<Text
numberOfLines={1}
className="font-medium font-sans text-white/80 text-xs"
className="font-medium font-sans text-foreground text-sm"
>
S{item.nextEpisode.seasonNumber} E
{item.nextEpisode.episodeNumber}
{item.title.title}
</Text>
{item.nextEpisode && (
<Text
numberOfLines={1}
className="mt-0.5 text-muted-foreground text-xs"
>
{item.nextEpisode.name}
</Text>
)}
</View>
)}
<View
className="absolute right-0 bottom-0 left-0 h-[3px]"
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
style={{
width: `${item.totalEpisodes > 0 ? (item.watchedEpisodes / item.totalEpisodes) * 100 : 0}%`,
}}
/>
</View>
</View>
<View className="p-2.5">
<Text
numberOfLines={1}
className="font-medium font-sans text-foreground text-sm"
>
{item.title.title}
</Text>
{item.nextEpisode && (
<Text
numberOfLines={1}
className="mt-0.5 text-muted-foreground text-xs"
>
{item.nextEpisode.name}
</Text>
)}
</View>
</Animated.View>
</GestureDetector>
</Link.Trigger>
<Link.Preview />
</Link>
</Pressable>
</Link.Trigger>
<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>
</Animated.View>
</GestureDetector>
);
}
@@ -29,8 +29,7 @@ export function HorizontalPosterRow({
items: PosterRowItem[];
isLoading?: boolean;
}) {
const { handleQuickAdd, addingKey, failedKey, resetError } =
usePosterActions();
const { handleQuickAdd, addingKey } = usePosterActions();
const keyExtractor = useCallback((item: PosterRowItem) => item.id, []);
const renderItem = useCallback(
({ item }: { item: PosterRowItem }) => (
@@ -46,11 +45,9 @@ export function HorizontalPosterRow({
episodeProgress={item.episodeProgress}
onQuickAdd={handleQuickAdd}
isAdding={addingKey === item.id}
failedKey={failedKey}
onQuickAddFailed={resetError}
/>
),
[addingKey, failedKey, handleQuickAdd, resetError],
[addingKey, handleQuickAdd],
);
if (isLoading) {
@@ -13,6 +13,9 @@ import Animated, {
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;
@@ -161,6 +164,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
</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>
@@ -13,6 +13,9 @@ 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";
export interface HeroBannerItem {
id: string;
@@ -121,6 +124,22 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
</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>
+96 -124
View File
@@ -9,7 +9,7 @@ import {
IconStarFilled,
} from "@tabler/icons-react-native";
import { Link } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -20,7 +20,6 @@ import Animated, {
withSpring,
} from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import * as ContextMenu from "zeego/context-menu";
import { Image } from "@/components/ui/image";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Skeleton } from "@/components/ui/skeleton";
@@ -44,8 +43,6 @@ interface PosterCardProps {
width?: number;
onQuickAdd: (id: string) => void;
isAdding?: boolean;
failedKey?: string | null;
onQuickAddFailed?: () => void;
}
export function PosterCard({
@@ -61,8 +58,6 @@ export function PosterCard({
width = 140,
onQuickAdd,
isAdding,
failedKey,
onQuickAddFailed,
}: PosterCardProps) {
const primaryColor = useCSSVariable("--color-primary") as string;
const watchlistColor = useCSSVariable("--color-status-watchlist") as string;
@@ -77,20 +72,6 @@ export function PosterCard({
const reduceMotion = useReducedMotion();
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(() => ({
transform: [
@@ -101,20 +82,19 @@ export function PosterCard({
}));
const handleQuickAddPress = useCallback(() => {
if (localStatus || isAdding) return;
setLocalStatus("watchlist");
if (userStatus || isAdding) return;
onQuickAdd(id);
}, [localStatus, isAdding, onQuickAdd, id]);
}, [userStatus, isAdding, onQuickAdd, id]);
const year = releaseDate?.slice(0, 4);
const imageHeight = width * 1.5;
const statusLabel =
localStatus === "completed"
userStatus === "completed"
? "completed"
: localStatus === "in_progress"
: userStatus === "in_progress"
? "watching"
: localStatus === "watchlist"
: userStatus === "watchlist"
? "on watchlist"
: undefined;
const cardAccessibilityLabel = [
@@ -129,7 +109,7 @@ export function PosterCard({
const cardContent = (
<View
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" }}
>
@@ -153,15 +133,15 @@ export function PosterCard({
)}
{/* Status indicator */}
{localStatus && (
{userStatus && (
<View
accessible={false}
className="absolute top-2 right-2 size-[30px] items-center justify-center rounded-full"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
{localStatus === "completed" ? (
{userStatus === "completed" ? (
<IconCheckbox size={16} color="white" />
) : localStatus === "in_progress" ? (
) : userStatus === "in_progress" ? (
<IconPlayerPlayFilled size={16} color="white" />
) : (
<IconBookmarkFilled size={16} color="white" />
@@ -190,10 +170,10 @@ export function PosterCard({
{/* Metadata */}
<View className="px-2.5 pt-2 pb-2.5">
<View className="flex-row items-center gap-1.5">
{localStatus && (
{userStatus && (
<View
className="size-1.5 rounded-full"
style={{ backgroundColor: statusColors[localStatus] }}
style={{ backgroundColor: statusColors[userStatus] }}
/>
)}
<Text
@@ -228,7 +208,7 @@ export function PosterCard({
</View>
</View>
);
const quickAddButton = !localStatus ? (
const quickAddButton = !userStatus ? (
<Pressable
onPress={handleQuickAddPress}
disabled={!!isAdding}
@@ -262,97 +242,89 @@ export function PosterCard({
const titleHref = `/title/${id}` as `/title/${string}`;
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref} asChild>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
</ContextMenu.Trigger>
<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" && (
<ContextMenu.Item
key="watching"
onSelect={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(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "play.fill" }} />
<ContextMenu.ItemTitle>Mark as Watching</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{type === "movie" && (
<ContextMenu.Item
key="watched"
onSelect={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(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "checkmark.circle" }} />
<ContextMenu.ItemTitle>Mark as Watched</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{localStatus && (
<ContextMenu.Item
key="remove"
destructive
onSelect={async () => {
await client.titles.updateStatus({ id, status: null });
setLocalStatus(null);
toast.success("Removed from library");
queryClient.invalidateQueries({
queryKey: orpc.titles.key(),
});
queryClient.invalidateQueries({
queryKey: orpc.dashboard.key(),
});
}}
>
<ContextMenu.ItemIcon ios={{ name: "trash" }} />
<ContextMenu.ItemTitle>Remove from Library</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
<GestureDetector gesture={pressGesture}>
<Animated.View style={[animatedStyle, { width }]}>
<View>
<Link href={titleHref} asChild>
<Link.Trigger withAppleZoom>
<Pressable
accessibilityRole="button"
accessibilityLabel={cardAccessibilityLabel}
>
{cardContent}
</Pressable>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
{!userStatus && (
<Link.MenuAction
title="Add to Watchlist"
icon="bookmark"
onPress={handleQuickAddPress}
/>
)}
{userStatus !== "in_progress" && (
<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(),
});
}}
/>
)}
{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(),
});
}}
/>
)}
{userStatus && (
<Link.MenuAction
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(),
});
}}
/>
)}
</Link.Menu>
</Link>
{quickAddButton}
</View>
</Animated.View>
</GestureDetector>
);
}
+4 -17
View File
@@ -1,5 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { toast } from "@/lib/toast";
@@ -25,26 +24,14 @@ export function usePosterActions() {
}),
);
const handleQuickAdd = useCallback(
(id: string) => {
quickAddMutation.mutate({ id });
},
[quickAddMutation.mutate],
);
const handleQuickAdd = (id: string) => {
quickAddMutation.mutate({ id });
};
const addingKey =
quickAddMutation.isPending && quickAddMutation.variables
? quickAddMutation.variables.id
: null;
const failedKey =
quickAddMutation.isError && quickAddMutation.variables
? quickAddMutation.variables.id
: null;
const resetError = useCallback(() => {
quickAddMutation.reset();
}, [quickAddMutation.reset]);
return { handleQuickAdd, addingKey, failedKey, resetError };
return { handleQuickAdd, addingKey };
}