mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat(native): add recently viewed history to search screen
Track titles and people visited in the native app and surface them
in the search tab when the query is empty, replacing the static
empty-state icon.
- Add `lib/recently-viewed.ts` — MMKV-backed store (max 20 items)
with `addRecentlyViewed`, `removeItem`, `clearAll`, and a
`useRecentlyViewed` hook
- Track visits via `useEffect` in `title/[id].tsx` and
`person/[id].tsx` once data resolves
- `RecentlyViewedList` — platform-split component:
- iOS (`.ios.tsx`): native SwiftUI List via `@expo/ui` with
system SF Symbols, swipe-to-delete, and a "Clear" button
- Android/fallback: FlatList with `SwipeableRow` for delete
- Add `SwipeableRow` component using `ReanimatedSwipeable`
- Clear recently viewed history on sign-out (both `HeaderAvatar`
and Settings screen)
- Add `@expo/ui@55.0.2` dependency
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@expo-google-fonts/dm-serif-display": "0.4.2",
|
||||
"@expo-google-fonts/geist-mono": "0.4.1",
|
||||
"@expo/metro-runtime": "55.0.6",
|
||||
"@expo/ui": "55.0.2",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { IconSearch } from "@tabler/icons-react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
|
||||
import {
|
||||
type SearchResultItem,
|
||||
SearchResultRow,
|
||||
@@ -19,7 +18,6 @@ import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const { navigate } = useRouter();
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||
|
||||
@@ -141,15 +139,7 @@ export default function SearchScreen() {
|
||||
/>
|
||||
|
||||
{debouncedQuery.length === 0 ? (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<IconSearch size={64} color={mutedForeground} />
|
||||
<Text className="mt-3 text-[15px] text-muted-foreground">
|
||||
Search for movies, shows, or people
|
||||
</Text>
|
||||
</Animated.View>
|
||||
<RecentlyViewedList />
|
||||
) : searchResults.isPending ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Spinner colorClassName="accent-primary" />
|
||||
|
||||
@@ -45,6 +45,7 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { isAnalyticsEnabled, setAnalyticsEnabled } from "@/lib/posthog";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { clearRecentlyViewed } from "@/lib/recently-viewed";
|
||||
import { getServerUrl } from "@/lib/server-url";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -177,6 +178,7 @@ export default function SettingsScreen() {
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
queryClient.clear();
|
||||
clearRecentlyViewed();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { FlatList, Pressable, useWindowDimensions, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -20,6 +20,7 @@ import { SectionHeader } from "@/components/ui/section-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { orpc } from "@/lib/orpc";
|
||||
import { addRecentlyViewed } from "@/lib/recently-viewed";
|
||||
|
||||
const FILMOGRAPHY_GAP = 12;
|
||||
const FILMOGRAPHY_PADDING = 16;
|
||||
@@ -61,6 +62,22 @@ export default function PersonDetailScreen() {
|
||||
const person = data?.person;
|
||||
const filmography = data?.filmography ?? [];
|
||||
|
||||
const personName = person?.name;
|
||||
const personProfilePath = person?.profilePath ?? null;
|
||||
const personDepartment = person?.knownForDepartment ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (personName) {
|
||||
addRecentlyViewed({
|
||||
id,
|
||||
type: "person",
|
||||
title: personName,
|
||||
imagePath: personProfilePath,
|
||||
subtitle: personDepartment,
|
||||
});
|
||||
}
|
||||
}, [id, personName, personProfilePath, personDepartment]);
|
||||
|
||||
const renderFilmographyItem = useCallback(
|
||||
({ item: credit }: { item: (typeof filmography)[number] }) => (
|
||||
<View style={{ width: columnWidth }}>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Text } from "@/components/ui/text";
|
||||
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";
|
||||
|
||||
export default function TitleDetailScreen() {
|
||||
@@ -134,6 +135,24 @@ export default function TitleDetailScreen() {
|
||||
const palette = title?.colorPalette ?? null;
|
||||
useTitleTheme(palette);
|
||||
|
||||
const titleName = title?.title;
|
||||
const titleType = title?.type;
|
||||
const titlePosterPath = title?.posterPath ?? null;
|
||||
const titleYear =
|
||||
(title?.releaseDate ?? title?.firstAirDate)?.slice(0, 4) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (titleName && titleType) {
|
||||
addRecentlyViewed({
|
||||
id,
|
||||
type: titleType,
|
||||
title: titleName,
|
||||
imagePath: titlePosterPath,
|
||||
subtitle: titleYear,
|
||||
});
|
||||
}
|
||||
}, [id, titleName, titleType, titlePosterPath, titleYear]);
|
||||
|
||||
const seasons = detail.data?.seasons ?? [];
|
||||
const cast = detail.data?.cast ?? [];
|
||||
const availability = detail.data?.availability ?? [];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { clearRecentlyViewed } from "@/lib/recently-viewed";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export function HeaderAvatar() {
|
||||
@@ -63,6 +64,7 @@ export function HeaderAvatar() {
|
||||
onPress: () => {
|
||||
authClient.signOut();
|
||||
queryClient.clear();
|
||||
clearRecentlyViewed();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Button,
|
||||
Host,
|
||||
HStack,
|
||||
Image,
|
||||
List,
|
||||
Section,
|
||||
Spacer,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@expo/ui/swift-ui";
|
||||
import {
|
||||
font,
|
||||
foregroundStyle,
|
||||
listStyle,
|
||||
onTapGesture,
|
||||
scrollContentBackground,
|
||||
} from "@expo/ui/swift-ui/modifiers";
|
||||
import { IconSearch } from "@tabler/icons-react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import type { SFSymbol } from "sf-symbols-typescript";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Text as RNText } from "@/components/ui/text";
|
||||
import {
|
||||
type RecentlyViewedItem,
|
||||
useRecentlyViewed,
|
||||
} from "@/lib/recently-viewed";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const SF_SYMBOL: Record<RecentlyViewedItem["type"], SFSymbol> = {
|
||||
movie: "film",
|
||||
tv: "tv",
|
||||
person: "person.fill",
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<RecentlyViewedItem["type"], string> = {
|
||||
movie: "Movie",
|
||||
tv: "TV Show",
|
||||
person: "Person",
|
||||
};
|
||||
|
||||
export function RecentlyViewedList() {
|
||||
const { navigate } = useRouter();
|
||||
const { items, removeItem, clearAll } = useRecentlyViewed();
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const handlePress = useCallback(
|
||||
(item: RecentlyViewedItem) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (item.type === "person") {
|
||||
navigate(`/person/${item.id}`);
|
||||
} else {
|
||||
navigate(`/title/${item.id}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(indices: number[]) => {
|
||||
for (const index of indices) {
|
||||
const item = items[index];
|
||||
if (item) removeItem(item.id);
|
||||
}
|
||||
},
|
||||
[items, removeItem],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
Alert.alert(
|
||||
"Clear Recently Viewed?",
|
||||
"This will remove all items from your history.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Clear",
|
||||
style: "destructive",
|
||||
onPress: () => clearAll(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [clearAll]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<IconSearch size={64} color={mutedForeground} />
|
||||
<RNText className="mt-3 text-[15px] text-muted-foreground">
|
||||
Search for movies, shows, or people
|
||||
</RNText>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<Host colorScheme="dark" style={{ flex: 1 }}>
|
||||
<List
|
||||
modifiers={[listStyle("plain"), scrollContentBackground("hidden")]}
|
||||
>
|
||||
<Section
|
||||
header={
|
||||
<HStack>
|
||||
<Image systemName="clock.arrow.circlepath" size={14} />
|
||||
<Text modifiers={[font({ weight: "semibold", size: 14 })]}>
|
||||
Recently Viewed
|
||||
</Text>
|
||||
<Spacer />
|
||||
<Button
|
||||
label="Clear"
|
||||
onPress={handleClear}
|
||||
modifiers={[
|
||||
font({ size: 14 }),
|
||||
foregroundStyle({
|
||||
type: "hierarchical",
|
||||
style: "secondary",
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<List.ForEach onDelete={handleDelete}>
|
||||
{items.map((item) => (
|
||||
<HStack
|
||||
key={item.id}
|
||||
spacing={12}
|
||||
alignment="center"
|
||||
modifiers={[onTapGesture(() => handlePress(item))]}
|
||||
>
|
||||
<Image
|
||||
systemName={SF_SYMBOL[item.type]}
|
||||
size={20}
|
||||
modifiers={[
|
||||
foregroundStyle({
|
||||
type: "hierarchical",
|
||||
style: "secondary",
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
<VStack alignment="leading" spacing={2}>
|
||||
<Text
|
||||
modifiers={[
|
||||
font({ weight: "medium", size: 16 }),
|
||||
foregroundStyle({
|
||||
type: "hierarchical",
|
||||
style: "primary",
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
modifiers={[
|
||||
font({ size: 13 }),
|
||||
foregroundStyle({
|
||||
type: "hierarchical",
|
||||
style: "secondary",
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{TYPE_LABEL[item.type]}
|
||||
{item.subtitle ? ` · ${item.subtitle}` : ""}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
))}
|
||||
</List.ForEach>
|
||||
</Section>
|
||||
</List>
|
||||
</Host>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { Alert, FlatList, Pressable, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { RecentlyViewedRow } from "@/components/search/recently-viewed-row";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
type RecentlyViewedItem,
|
||||
useRecentlyViewed,
|
||||
} from "@/lib/recently-viewed";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
export function RecentlyViewedList() {
|
||||
const { navigate } = useRouter();
|
||||
const { items, removeItem, clearAll } = useRecentlyViewed();
|
||||
const [mutedForeground, primaryColor] = useCSSVariable([
|
||||
"--color-muted-foreground",
|
||||
"--color-primary",
|
||||
]) as [string, string];
|
||||
|
||||
const handlePress = useCallback(
|
||||
(item: RecentlyViewedItem) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (item.type === "person") {
|
||||
navigate(`/person/${item.id}`);
|
||||
} else {
|
||||
navigate(`/title/${item.id}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
removeItem(id);
|
||||
},
|
||||
[removeItem],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
Alert.alert(
|
||||
"Clear Recently Viewed?",
|
||||
"This will remove all items from your history.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Clear",
|
||||
style: "destructive",
|
||||
onPress: () => clearAll(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [clearAll]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: RecentlyViewedItem }) => (
|
||||
<RecentlyViewedRow
|
||||
item={item}
|
||||
onPress={handlePress}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
),
|
||||
[handlePress, handleDelete],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
className="flex-1 items-center justify-center"
|
||||
>
|
||||
<IconSearch size={64} color={mutedForeground} />
|
||||
<Text className="mt-3 text-[15px] text-muted-foreground">
|
||||
Search for movies, shows, or people
|
||||
</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(400)} className="flex-1">
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
className="mb-1 flex-row items-center justify-between px-4 pt-2 pb-1"
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<IconHistory size={20} color={primaryColor} />
|
||||
<Text className="font-display text-[20px] text-foreground tracking-tight">
|
||||
Recently Viewed
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={handleClear}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||
>
|
||||
<Text className="text-[13px] text-primary">Clear</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
|
||||
import { memo } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { SwipeableRow } from "@/components/ui/swipeable-row";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { RecentlyViewedItem } from "@/lib/recently-viewed";
|
||||
|
||||
const TypeIcon = {
|
||||
movie: IconMovie,
|
||||
tv: IconDeviceTv,
|
||||
person: IconUser,
|
||||
} as const;
|
||||
|
||||
const TypeLabel = {
|
||||
movie: "Movie",
|
||||
tv: "TV",
|
||||
person: "Person",
|
||||
} as const;
|
||||
|
||||
export const RecentlyViewedRow = memo(function RecentlyViewedRow({
|
||||
item,
|
||||
onPress,
|
||||
onDelete,
|
||||
}: {
|
||||
item: RecentlyViewedItem;
|
||||
onPress: (item: RecentlyViewedItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
const Icon = TypeIcon[item.type];
|
||||
|
||||
return (
|
||||
<SwipeableRow onDelete={() => onDelete(item.id)}>
|
||||
<Pressable
|
||||
onPress={() => onPress(item)}
|
||||
className="flex-row items-center bg-background px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: "rgba(255,255,255,0.08)",
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<View
|
||||
className="mr-3 overflow-hidden bg-secondary"
|
||||
style={{
|
||||
width: item.type === "person" ? 48 : 48,
|
||||
height: item.type === "person" ? 48 : 72,
|
||||
borderRadius: item.type === "person" ? 24 : 10,
|
||||
borderCurve: item.type === "person" ? undefined : "continuous",
|
||||
}}
|
||||
>
|
||||
{item.imagePath ? (
|
||||
<Image
|
||||
source={{ uri: item.imagePath }}
|
||||
className="h-full w-full"
|
||||
contentFit="cover"
|
||||
recyclingKey={`rv-${item.id}`}
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Icon size={20} color={mutedForeground} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Text content */}
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-sans-medium text-[15px] text-foreground"
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View className="mt-1 flex-row items-center gap-2">
|
||||
<View className="rounded-full bg-secondary px-2 py-0.5">
|
||||
<Text className="text-[10px] text-muted-foreground">
|
||||
{TypeLabel[item.type]}
|
||||
</Text>
|
||||
</View>
|
||||
{item.subtitle ? (
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</SwipeableRow>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IconTrash } from "@tabler/icons-react-native";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { View } from "react-native";
|
||||
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
} from "react-native-reanimated";
|
||||
import * as Haptics from "@/utils/haptics";
|
||||
|
||||
const ACTION_WIDTH = 80;
|
||||
|
||||
function RightAction({ drag }: { drag: SharedValue<number> }) {
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(drag.value, [0, -ACTION_WIDTH], [0.6, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return { transform: [{ scale }] };
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
className="items-center justify-center bg-destructive"
|
||||
style={{ width: ACTION_WIDTH }}
|
||||
>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<IconTrash size={22} color="#fff" />
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SwipeableRowProps extends PropsWithChildren {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function SwipeableRow({ onDelete, children }: SwipeableRowProps) {
|
||||
return (
|
||||
<ReanimatedSwipeable
|
||||
friction={2}
|
||||
rightThreshold={40}
|
||||
overshootRight={false}
|
||||
renderRightActions={(_progress, drag) => <RightAction drag={drag} />}
|
||||
onSwipeableOpen={(direction) => {
|
||||
if (direction === "left") {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReanimatedSwipeable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { storage } from "@/lib/mmkv";
|
||||
import { onServerUrlChange } from "@/lib/server-url";
|
||||
|
||||
const STORAGE_KEY = "recently_viewed";
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
export interface RecentlyViewedItem {
|
||||
id: string;
|
||||
type: "movie" | "tv" | "person";
|
||||
title: string;
|
||||
imagePath: string | null;
|
||||
subtitle: string | null;
|
||||
viewedAt: number;
|
||||
}
|
||||
|
||||
// --- In-memory cache synced with MMKV ---
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let items: RecentlyViewedItem[] = (() => {
|
||||
const raw = storage.getString(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw) as RecentlyViewedItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
function persist(next: RecentlyViewedItem[]) {
|
||||
items = next;
|
||||
storage.set(STORAGE_KEY, JSON.stringify(next));
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function addRecentlyViewed(item: Omit<RecentlyViewedItem, "viewedAt">) {
|
||||
const filtered = items.filter((i) => i.id !== item.id);
|
||||
const next = [{ ...item, viewedAt: Date.now() }, ...filtered].slice(
|
||||
0,
|
||||
MAX_ITEMS,
|
||||
);
|
||||
persist(next);
|
||||
}
|
||||
|
||||
export function removeRecentlyViewed(id: string) {
|
||||
persist(items.filter((i) => i.id !== id));
|
||||
}
|
||||
|
||||
export function clearRecentlyViewed() {
|
||||
persist([]);
|
||||
}
|
||||
|
||||
// --- React hook ---
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
listeners.add(callback);
|
||||
return () => {
|
||||
listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useRecentlyViewed() {
|
||||
const data = useSyncExternalStore(subscribe, getSnapshot);
|
||||
return {
|
||||
items: data,
|
||||
addItem: addRecentlyViewed,
|
||||
removeItem: removeRecentlyViewed,
|
||||
clearAll: clearRecentlyViewed,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear recently-viewed when the user switches servers — stored IDs
|
||||
// are server-specific and become dead links on a different backend.
|
||||
onServerUrlChange(() => {
|
||||
clearRecentlyViewed();
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
"@expo-google-fonts/dm-serif-display": "0.4.2",
|
||||
"@expo-google-fonts/geist-mono": "0.4.1",
|
||||
"@expo/metro-runtime": "55.0.6",
|
||||
"@expo/ui": "55.0.2",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
@@ -667,6 +668,8 @@
|
||||
|
||||
"@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="],
|
||||
|
||||
"@expo/ui": ["@expo/ui@55.0.2", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-nVWFz0/7w/+Tr0/Jd3TTTY3HUrMSQzOfbuL1nl257zGfJzp2cGmHNuaRlh3Yi72NcVlFNLRbZzLdH3Jwa1R0jg=="],
|
||||
|
||||
"@expo/vector-icons": ["@expo/vector-icons@15.1.1", "", { "peerDependencies": { "expo-font": ">=14.0.4", "react": "*", "react-native": "*" } }, "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw=="],
|
||||
|
||||
"@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.6", "", {}, "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="],
|
||||
|
||||
Reference in New Issue
Block a user