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:
2026-03-13 19:08:13 -04:00
parent 35bdfc171e
commit b4c2a1d343
12 changed files with 578 additions and 13 deletions
+1
View File
@@ -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:",
+2 -12
View File
@@ -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();
},
},
]);
+18 -1
View File
@@ -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 }}>
+19
View File
@@ -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>
);
}
+83
View File
@@ -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();
});
+3
View File
@@ -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=="],