diff --git a/apps/native/src/app/person/[id].tsx b/apps/native/src/app/person/[id].tsx index d411541..fa94fc9 100644 --- a/apps/native/src/app/person/[id].tsx +++ b/apps/native/src/app/person/[id].tsx @@ -46,13 +46,6 @@ function calculateAge(birthday: string, deathday?: string | null): number { export default function PersonDetailScreen() { const { t } = useLingui(); - const departmentLabels: Record = { - Acting: t`Actor`, - Directing: t`Director`, - Writing: t`Writer`, - Production: t`Producer`, - Editing: t`Editor`, - }; const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); @@ -138,6 +131,98 @@ export default function PersonDetailScreen() { [columnWidth, userStatuses, handleQuickAdd, addingKey], ); + const listHeader = useMemo(() => { + if (!person) return null; + + const departmentLabels: Record = { + Acting: t`Actor`, + Directing: t`Director`, + Writing: t`Writer`, + Production: t`Producer`, + Editing: t`Editor`, + }; + + return ( + <> + {/* Profile hero */} + + + {person.profilePath && ( + + )} + + + + {person.name} + + + {person.knownForDepartment ? ( + + + {departmentLabels[person.knownForDepartment] ?? person.knownForDepartment} + + + ) : null} + + {person.birthday || person.placeOfBirth ? ( + + {person.birthday ? ( + + + + {formatDate(person.birthday)} + + {(() => { + const age = calculateAge(person.birthday, person.deathday); + return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`; + })()} + + + + ) : null} + {person.placeOfBirth ? ( + + + + {person.placeOfBirth} + + + ) : null} + + ) : null} + + + {/* Biography */} + {person.biography ? ( + + + + + + ) : null} + + {/* Filmography section header */} + {filmography.length > 0 && ( + + + + )} + + ); + }, [person, filmography.length, insets.top, useAutomaticInsets, primaryColor, t]); + if (isPending) { return ( @@ -202,86 +287,6 @@ export default function PersonDetailScreen() { ); } - const listHeader = ( - <> - {/* Profile hero */} - - - {person.profilePath && ( - - )} - - - - {person.name} - - - {person.knownForDepartment ? ( - - - {departmentLabels[person.knownForDepartment] ?? person.knownForDepartment} - - - ) : null} - - {person.birthday || person.placeOfBirth ? ( - - {person.birthday ? ( - - - - {formatDate(person.birthday)} - - {(() => { - const age = calculateAge(person.birthday, person.deathday); - return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`; - })()} - - - - ) : null} - {person.placeOfBirth ? ( - - - - {person.placeOfBirth} - - - ) : null} - - ) : null} - - - {/* Biography */} - {person.biography ? ( - - - - - - ) : null} - - {/* Filmography section header */} - {filmography.length > 0 && ( - - - - )} - - ); - return ( ); -} +}); diff --git a/apps/native/src/components/search/recently-viewed-list.tsx b/apps/native/src/components/search/recently-viewed-list.tsx index e3557a6..fd6bef7 100644 --- a/apps/native/src/components/search/recently-viewed-list.tsx +++ b/apps/native/src/components/search/recently-viewed-list.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { FlashList } from "@shopify/flash-list"; 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 Animated, { FadeIn, FadeInDown } from "react-native-reanimated"; import { useCSSVariable } from "uniwind"; @@ -47,6 +47,32 @@ export function RecentlyViewedList() { const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []); + const listHeaderComponent = useMemo( + () => ( + + + + + Recently Viewed + + + ({ opacity: pressed ? 0.6 : 1 })} + > + + Clear + + + + ), + [primaryColor, handleClear], + ); + if (items.length === 0) { return ( @@ -66,28 +92,7 @@ export function RecentlyViewedList() { renderItem={renderItem} keyboardShouldPersistTaps="handled" contentInsetAdjustmentBehavior="automatic" - ListHeaderComponent={ - - - - - Recently Viewed - - - ({ opacity: pressed ? 0.6 : 1 })} - > - - Clear - - - - } + ListHeaderComponent={listHeaderComponent} /> ); diff --git a/apps/native/src/components/settings/integration-card.tsx b/apps/native/src/components/settings/integration-card.tsx index 6174ba1..fb1488a 100644 --- a/apps/native/src/components/settings/integration-card.tsx +++ b/apps/native/src/components/settings/integration-card.tsx @@ -9,7 +9,7 @@ import { } from "@tabler/icons-react-native"; import { useMutation } from "@tanstack/react-query"; 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 Animated, { FadeIn, @@ -52,6 +52,8 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) { const [setupOpen, setSetupOpen] = useState(false); const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + const chevronRotation = useSharedValue(0); const setupChevronRotation = useSharedValue(0); @@ -110,12 +112,19 @@ export function IntegrationCard({ config, connection }: IntegrationCardProps) { const url = connection ? config.buildUrl(connection.token) : null; + useEffect(() => { + return () => { + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + }; + }, []); + const handleCopy = useCallback(async () => { if (!url) return; await Clipboard.setStringAsync(url); setCopied(true); 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]); const handleRegenerate = useCallback(() => { diff --git a/apps/native/src/components/titles/episode-row.tsx b/apps/native/src/components/titles/episode-row.tsx index 53aae31..40b96a5 100644 --- a/apps/native/src/components/titles/episode-row.tsx +++ b/apps/native/src/components/titles/episode-row.tsx @@ -1,37 +1,41 @@ import { useLingui } from "@lingui/react/macro"; import { IconCircleCheckFilled, IconCircleDashed } from "@tabler/icons-react-native"; +import { memo, useCallback } from "react"; import { Pressable, View } from "react-native"; import { ScaledIcon } from "@/components/ui/scaled-icon"; import { Text } from "@/components/ui/text"; -export function EpisodeRow({ - episode, +export const EpisodeRow = memo(function EpisodeRow({ + episodeId, + episodeNumber, + name, + airDate, isWatched, onToggle, accentColor, mutedColor, }: { - episode: { - id: string; - episodeNumber: number; - name: string | null; - airDate: string | null; - }; + episodeId: string; + episodeNumber: number; + name: string | null; + airDate: string | null; isWatched: boolean; - onToggle: () => void; + onToggle: (episodeId: string) => void; accentColor: string; mutedColor: string; }) { 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 ( @@ -45,12 +49,10 @@ export function EpisodeRow({ className={`font-sans text-sm font-medium ${isWatched ? "text-muted-foreground" : "text-foreground"}`} numberOfLines={1} > - {episode.episodeNumber}. {episode.name ?? t`Episode ${episode.episodeNumber}`} + {episodeNumber}. {name ?? t`Episode ${episodeNumber}`} - {episode.airDate ? ( - {episode.airDate} - ) : null} + {airDate ? {airDate} : null} ); -} +}); diff --git a/apps/native/src/components/titles/season-accordion.tsx b/apps/native/src/components/titles/season-accordion.tsx index 8ad77b2..1337ecc 100644 --- a/apps/native/src/components/titles/season-accordion.tsx +++ b/apps/native/src/components/titles/season-accordion.tsx @@ -157,9 +157,12 @@ export function SeasonAccordion({ {episodes.slice(0, visibleCount).map((episode) => ( handleEpisodeToggle(episode.id)} + onToggle={handleEpisodeToggle} accentColor={titleAccentColor} mutedColor={mutedFgColor} /> diff --git a/apps/native/src/components/ui/poster-card.tsx b/apps/native/src/components/ui/poster-card.tsx index ef67720..f007312 100644 --- a/apps/native/src/components/ui/poster-card.tsx +++ b/apps/native/src/components/ui/poster-card.tsx @@ -10,7 +10,7 @@ import { IconStarFilled, } from "@tabler/icons-react-native"; import { Link } from "expo-router"; -import { useCallback } from "react"; +import { memo, useCallback } from "react"; import { Pressable, View } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; @@ -40,7 +40,7 @@ interface PosterCardProps { isAdding?: boolean; } -export function PosterCard({ +export const PosterCard = memo(function PosterCard({ id, title, type, @@ -243,7 +243,7 @@ export function PosterCard({ ); -} +}); export function PosterCardSkeleton({ width = 140 }: { width?: number }) { const imageHeight = width * 1.5; diff --git a/apps/native/src/lib/intl-polyfills.ts b/apps/native/src/lib/intl-polyfills.ts index 30bfffd..4915dcc 100644 --- a/apps/native/src/lib/intl-polyfills.ts +++ b/apps/native/src/lib/intl-polyfills.ts @@ -4,8 +4,8 @@ * Load order matters — each polyfill depends on the ones before it. * See: https://formatjs.github.io/docs/guides/react-native-hermes * - * We use `polyfill-force` to always install the polyfill regardless of - * partial Hermes Intl support, ensuring consistent behavior. + * We use `polyfill` (not `polyfill-force`) so Hermes's native Intl + * implementations are preserved when available, and only gaps are filled. */ // 1. getCanonicalLocales (no dependencies) @@ -13,7 +13,7 @@ import "@formatjs/intl-getcanonicallocales/polyfill.js"; // 2. Locale (depends on getCanonicalLocales) import "@formatjs/intl-locale/polyfill.js"; // 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/fr.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/pt.js"; // 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/fr.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/pt.js"; // 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/fr.js"; import "@formatjs/intl-datetimeformat/locale-data/de.js"; import "@formatjs/intl-datetimeformat/locale-data/es.js"; import "@formatjs/intl-datetimeformat/locale-data/it.js"; import "@formatjs/intl-datetimeformat/locale-data/pt.js"; -import "@formatjs/intl-datetimeformat/add-all-tz.js"; // 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/fr.js"; import "@formatjs/intl-relativetimeformat/locale-data/de.js";