mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
perf(native): memoize list headers and card components to reduce unnecessary re-renders
- Wrap `ContinueWatchingCard` with `memo()` so it only re-renders when its `item` prop changes - Extract `ListHeaderComponent` in `RecentlyViewedList` into a `useMemo` to avoid recreating the JSX tree on every render - Move `listHeader` in the person detail screen from a plain JSX variable to `useMemo`, and relocate `departmentLabels` inside it so the `t` reference is stable - Store the "copied" reset timer in a `useRef` in `IntegrationCard` and clear it on unmount to prevent setState-after-unmount warnings
This commit is contained in:
@@ -46,13 +46,6 @@ function calculateAge(birthday: string, deathday?: string | null): number {
|
|||||||
|
|
||||||
export default function PersonDetailScreen() {
|
export default function PersonDetailScreen() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const departmentLabels: Record<string, string> = {
|
|
||||||
Acting: t`Actor`,
|
|
||||||
Directing: t`Director`,
|
|
||||||
Writing: t`Writer`,
|
|
||||||
Production: t`Producer`,
|
|
||||||
Editing: t`Editor`,
|
|
||||||
};
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const headerHeight = useHeaderHeight();
|
const headerHeight = useHeaderHeight();
|
||||||
@@ -138,6 +131,98 @@ export default function PersonDetailScreen() {
|
|||||||
[columnWidth, userStatuses, handleQuickAdd, addingKey],
|
[columnWidth, userStatuses, handleQuickAdd, addingKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const listHeader = useMemo(() => {
|
||||||
|
if (!person) return null;
|
||||||
|
|
||||||
|
const departmentLabels: Record<string, string> = {
|
||||||
|
Acting: t`Actor`,
|
||||||
|
Directing: t`Director`,
|
||||||
|
Writing: t`Writer`,
|
||||||
|
Production: t`Producer`,
|
||||||
|
Editing: t`Editor`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Profile hero */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
className="items-center"
|
||||||
|
style={{
|
||||||
|
paddingTop: useAutomaticInsets ? 16 : insets.top + 56,
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="bg-secondary size-[120px] overflow-hidden rounded-full">
|
||||||
|
{person.profilePath && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: person.profilePath }}
|
||||||
|
thumbHash={person.profileThumbHash}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="font-display text-foreground mt-4 text-center text-3xl">
|
||||||
|
{person.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{person.knownForDepartment ? (
|
||||||
|
<View className="bg-secondary mt-2 rounded-full px-3 py-1">
|
||||||
|
<Text className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||||
|
{departmentLabels[person.knownForDepartment] ?? person.knownForDepartment}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{person.birthday || person.placeOfBirth ? (
|
||||||
|
<View className="mt-3 items-center gap-1.5">
|
||||||
|
{person.birthday ? (
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<ScaledIcon icon={IconCalendar} size={14} color={primaryColor} />
|
||||||
|
<Text selectable className="text-muted-foreground text-sm">
|
||||||
|
{formatDate(person.birthday)}
|
||||||
|
<Text className="text-muted-foreground/60 text-sm">
|
||||||
|
{(() => {
|
||||||
|
const age = calculateAge(person.birthday, person.deathday);
|
||||||
|
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{person.placeOfBirth ? (
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<ScaledIcon icon={IconMapPin} size={14} color={primaryColor} />
|
||||||
|
<Text selectable className="text-muted-foreground text-sm">
|
||||||
|
{person.placeOfBirth}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Biography */}
|
||||||
|
{person.biography ? (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
||||||
|
<View className="mb-6 px-4">
|
||||||
|
<ExpandableText text={person.biography} maxLines={4} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Filmography section header */}
|
||||||
|
{filmography.length > 0 && (
|
||||||
|
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="px-4">
|
||||||
|
<SectionHeader title={t`Filmography`} icon={IconMovie} />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [person, filmography.length, insets.top, useAutomaticInsets, primaryColor, t]);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<ModalLayout>
|
<ModalLayout>
|
||||||
@@ -202,86 +287,6 @@ export default function PersonDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const listHeader = (
|
|
||||||
<>
|
|
||||||
{/* Profile hero */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.duration(400)}
|
|
||||||
className="items-center"
|
|
||||||
style={{
|
|
||||||
paddingTop: useAutomaticInsets ? 16 : insets.top + 56,
|
|
||||||
paddingBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="bg-secondary size-[120px] overflow-hidden rounded-full">
|
|
||||||
{person.profilePath && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: person.profilePath }}
|
|
||||||
thumbHash={person.profileThumbHash}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="font-display text-foreground mt-4 text-center text-3xl">
|
|
||||||
{person.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{person.knownForDepartment ? (
|
|
||||||
<View className="bg-secondary mt-2 rounded-full px-3 py-1">
|
|
||||||
<Text className="text-muted-foreground text-xs tracking-wider uppercase">
|
|
||||||
{departmentLabels[person.knownForDepartment] ?? person.knownForDepartment}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{person.birthday || person.placeOfBirth ? (
|
|
||||||
<View className="mt-3 items-center gap-1.5">
|
|
||||||
{person.birthday ? (
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
|
||||||
<ScaledIcon icon={IconCalendar} size={14} color={primaryColor} />
|
|
||||||
<Text selectable className="text-muted-foreground text-sm">
|
|
||||||
{formatDate(person.birthday)}
|
|
||||||
<Text className="text-muted-foreground/60 text-sm">
|
|
||||||
{(() => {
|
|
||||||
const age = calculateAge(person.birthday, person.deathday);
|
|
||||||
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
|
|
||||||
})()}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{person.placeOfBirth ? (
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
|
||||||
<ScaledIcon icon={IconMapPin} size={14} color={primaryColor} />
|
|
||||||
<Text selectable className="text-muted-foreground text-sm">
|
|
||||||
{person.placeOfBirth}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Biography */}
|
|
||||||
{person.biography ? (
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
|
|
||||||
<View className="mb-6 px-4">
|
|
||||||
<ExpandableText text={person.biography} maxLines={4} />
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Filmography section header */}
|
|
||||||
{filmography.length > 0 && (
|
|
||||||
<Animated.View entering={FadeInDown.duration(300).delay(200)} className="px-4">
|
|
||||||
<SectionHeader title={t`Filmography`} icon={IconMovie} />
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalLayout>
|
<ModalLayout>
|
||||||
<FlashList
|
<FlashList
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
|
import { memo } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
@@ -27,7 +28,11 @@ export interface ContinueWatchingItem {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
export const ContinueWatchingCard = memo(function ContinueWatchingCard({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: ContinueWatchingItem;
|
||||||
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { animatedStyle, gesture: tapGesture } = usePressAnimation();
|
const { animatedStyle, gesture: tapGesture } = usePressAnimation();
|
||||||
|
|
||||||
@@ -115,4 +120,4 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
import { IconHistory, IconSearch } from "@tabler/icons-react-native";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
|
||||||
import { useCSSVariable } from "uniwind";
|
import { useCSSVariable } from "uniwind";
|
||||||
@@ -47,6 +47,32 @@ export function RecentlyViewedList() {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
|
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
|
||||||
|
|
||||||
|
const listHeaderComponent = useMemo(
|
||||||
|
() => (
|
||||||
|
<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-foreground text-xl tracking-tight">
|
||||||
|
<Trans>Recently Viewed</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleClear}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
|
>
|
||||||
|
<Text className="text-primary text-sm">
|
||||||
|
<Trans>Clear</Trans>
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
),
|
||||||
|
[primaryColor, handleClear],
|
||||||
|
);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
|
<Animated.View entering={FadeIn.duration(400)} className="flex-1 items-center justify-center">
|
||||||
@@ -66,28 +92,7 @@ export function RecentlyViewedList() {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={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-foreground text-xl tracking-tight">
|
|
||||||
<Trans>Recently Viewed</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleClear}
|
|
||||||
hitSlop={8}
|
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
|
||||||
>
|
|
||||||
<Text className="text-primary text-sm">
|
|
||||||
<Trans>Clear</Trans>
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@tabler/icons-react-native";
|
} from "@tabler/icons-react-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
@@ -52,6 +52,8 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const chevronRotation = useSharedValue(0);
|
const chevronRotation = useSharedValue(0);
|
||||||
const setupChevronRotation = useSharedValue(0);
|
const setupChevronRotation = useSharedValue(0);
|
||||||
|
|
||||||
@@ -110,12 +112,19 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) {
|
|||||||
|
|
||||||
const url = connection ? config.buildUrl(connection.token) : null;
|
const url = connection ? config.buildUrl(connection.token) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
await Clipboard.setStringAsync(url);
|
await Clipboard.setStringAsync(url);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
toast.success(t`URL copied to clipboard`);
|
toast.success(t`URL copied to clipboard`);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
|
||||||
|
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||||
}, [url, t]);
|
}, [url, t]);
|
||||||
|
|
||||||
const handleRegenerate = useCallback(() => {
|
const handleRegenerate = useCallback(() => {
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native";
|
import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native";
|
||||||
|
import { memo, useCallback } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
export function EpisodeRow({
|
export const EpisodeRow = memo(function EpisodeRow({
|
||||||
episode,
|
episodeId,
|
||||||
|
episodeNumber,
|
||||||
|
name,
|
||||||
|
airDate,
|
||||||
isWatched,
|
isWatched,
|
||||||
onToggle,
|
onToggle,
|
||||||
accentColor,
|
accentColor,
|
||||||
mutedColor,
|
mutedColor,
|
||||||
}: {
|
}: {
|
||||||
episode: {
|
episodeId: string;
|
||||||
id: string;
|
episodeNumber: number;
|
||||||
episodeNumber: number;
|
name: string | null;
|
||||||
name: string | null;
|
airDate: string | null;
|
||||||
airDate: string | null;
|
|
||||||
};
|
|
||||||
isWatched: boolean;
|
isWatched: boolean;
|
||||||
onToggle: () => void;
|
onToggle: (episodeId: string) => void;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
mutedColor: string;
|
mutedColor: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const episodeLabel = episode.name ?? t`Episode ${episode.episodeNumber}`;
|
const episodeLabel = name ?? t`Episode ${episodeNumber}`;
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => onToggle(episodeId), [onToggle, episodeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onToggle}
|
onPress={handleToggle}
|
||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityState={{ checked: isWatched }}
|
accessibilityState={{ checked: isWatched }}
|
||||||
accessibilityLabel={t`Episode ${episode.episodeNumber}, ${episodeLabel}`}
|
accessibilityLabel={t`Episode ${episodeNumber}, ${episodeLabel}`}
|
||||||
className="border-border flex-row items-center border-b px-4 py-3"
|
className="border-border flex-row items-center border-b px-4 py-3"
|
||||||
style={{ borderBottomWidth: 0.5 }}
|
style={{ borderBottomWidth: 0.5 }}
|
||||||
>
|
>
|
||||||
@@ -45,12 +49,10 @@ export function EpisodeRow({
|
|||||||
className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{episode.episodeNumber}. {episode.name ?? t`Episode ${episode.episodeNumber}`}
|
{episodeNumber}. {name ?? t`Episode ${episodeNumber}`}
|
||||||
</Text>
|
</Text>
|
||||||
{episode.airDate ? (
|
{airDate ? <Text className="text-muted-foreground mt-0.5 text-xs">{airDate}</Text> : null}
|
||||||
<Text className="text-muted-foreground mt-0.5 text-xs">{episode.airDate}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -157,9 +157,12 @@ export function SeasonAccordion({
|
|||||||
{episodes.slice(0, visibleCount).map((episode) => (
|
{episodes.slice(0, visibleCount).map((episode) => (
|
||||||
<EpisodeRow
|
<EpisodeRow
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
episode={episode}
|
episodeId={episode.id}
|
||||||
|
episodeNumber={episode.episodeNumber}
|
||||||
|
name={episode.name}
|
||||||
|
airDate={episode.airDate}
|
||||||
isWatched={watchedEpisodeIds.has(episode.id)}
|
isWatched={watchedEpisodeIds.has(episode.id)}
|
||||||
onToggle={() => handleEpisodeToggle(episode.id)}
|
onToggle={handleEpisodeToggle}
|
||||||
accentColor={titleAccentColor}
|
accentColor={titleAccentColor}
|
||||||
mutedColor={mutedFgColor}
|
mutedColor={mutedFgColor}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,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 } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
@@ -40,7 +40,7 @@ interface PosterCardProps {
|
|||||||
isAdding?: boolean;
|
isAdding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PosterCard({
|
export const PosterCard = memo(function PosterCard({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@@ -243,7 +243,7 @@ export function PosterCard({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
|
export function PosterCardSkeleton({ width = 140 }: { width?: number }) {
|
||||||
const imageHeight = width * 1.5;
|
const imageHeight = width * 1.5;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* Load order matters — each polyfill depends on the ones before it.
|
* Load order matters — each polyfill depends on the ones before it.
|
||||||
* See: https://formatjs.github.io/docs/guides/react-native-hermes
|
* See: https://formatjs.github.io/docs/guides/react-native-hermes
|
||||||
*
|
*
|
||||||
* We use `polyfill-force` to always install the polyfill regardless of
|
* We use `polyfill` (not `polyfill-force`) so Hermes's native Intl
|
||||||
* partial Hermes Intl support, ensuring consistent behavior.
|
* implementations are preserved when available, and only gaps are filled.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 1. getCanonicalLocales (no dependencies)
|
// 1. getCanonicalLocales (no dependencies)
|
||||||
@@ -13,7 +13,7 @@ import "@formatjs/intl-getcanonicallocales/polyfill.js";
|
|||||||
// 2. Locale (depends on getCanonicalLocales)
|
// 2. Locale (depends on getCanonicalLocales)
|
||||||
import "@formatjs/intl-locale/polyfill.js";
|
import "@formatjs/intl-locale/polyfill.js";
|
||||||
// 3. PluralRules (depends on Locale)
|
// 3. PluralRules (depends on Locale)
|
||||||
import "@formatjs/intl-pluralrules/polyfill-force.js";
|
import "@formatjs/intl-pluralrules/polyfill.js";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/en.js";
|
import "@formatjs/intl-pluralrules/locale-data/en.js";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/fr.js";
|
import "@formatjs/intl-pluralrules/locale-data/fr.js";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/de.js";
|
import "@formatjs/intl-pluralrules/locale-data/de.js";
|
||||||
@@ -21,7 +21,7 @@ import "@formatjs/intl-pluralrules/locale-data/es.js";
|
|||||||
import "@formatjs/intl-pluralrules/locale-data/it.js";
|
import "@formatjs/intl-pluralrules/locale-data/it.js";
|
||||||
import "@formatjs/intl-pluralrules/locale-data/pt.js";
|
import "@formatjs/intl-pluralrules/locale-data/pt.js";
|
||||||
// 4. NumberFormat (depends on PluralRules, Locale)
|
// 4. NumberFormat (depends on PluralRules, Locale)
|
||||||
import "@formatjs/intl-numberformat/polyfill-force.js";
|
import "@formatjs/intl-numberformat/polyfill.js";
|
||||||
import "@formatjs/intl-numberformat/locale-data/en.js";
|
import "@formatjs/intl-numberformat/locale-data/en.js";
|
||||||
import "@formatjs/intl-numberformat/locale-data/fr.js";
|
import "@formatjs/intl-numberformat/locale-data/fr.js";
|
||||||
import "@formatjs/intl-numberformat/locale-data/de.js";
|
import "@formatjs/intl-numberformat/locale-data/de.js";
|
||||||
@@ -29,16 +29,15 @@ import "@formatjs/intl-numberformat/locale-data/es.js";
|
|||||||
import "@formatjs/intl-numberformat/locale-data/it.js";
|
import "@formatjs/intl-numberformat/locale-data/it.js";
|
||||||
import "@formatjs/intl-numberformat/locale-data/pt.js";
|
import "@formatjs/intl-numberformat/locale-data/pt.js";
|
||||||
// 5. DateTimeFormat (depends on Locale)
|
// 5. DateTimeFormat (depends on Locale)
|
||||||
import "@formatjs/intl-datetimeformat/polyfill-force.js";
|
import "@formatjs/intl-datetimeformat/polyfill.js";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/en.js";
|
import "@formatjs/intl-datetimeformat/locale-data/en.js";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/fr.js";
|
import "@formatjs/intl-datetimeformat/locale-data/fr.js";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/de.js";
|
import "@formatjs/intl-datetimeformat/locale-data/de.js";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/es.js";
|
import "@formatjs/intl-datetimeformat/locale-data/es.js";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/it.js";
|
import "@formatjs/intl-datetimeformat/locale-data/it.js";
|
||||||
import "@formatjs/intl-datetimeformat/locale-data/pt.js";
|
import "@formatjs/intl-datetimeformat/locale-data/pt.js";
|
||||||
import "@formatjs/intl-datetimeformat/add-all-tz.js";
|
|
||||||
// 6. RelativeTimeFormat (depends on PluralRules, Locale)
|
// 6. RelativeTimeFormat (depends on PluralRules, Locale)
|
||||||
import "@formatjs/intl-relativetimeformat/polyfill-force.js";
|
import "@formatjs/intl-relativetimeformat/polyfill.js";
|
||||||
import "@formatjs/intl-relativetimeformat/locale-data/en.js";
|
import "@formatjs/intl-relativetimeformat/locale-data/en.js";
|
||||||
import "@formatjs/intl-relativetimeformat/locale-data/fr.js";
|
import "@formatjs/intl-relativetimeformat/locale-data/fr.js";
|
||||||
import "@formatjs/intl-relativetimeformat/locale-data/de.js";
|
import "@formatjs/intl-relativetimeformat/locale-data/de.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user