refactor(native): replace runOnJS with scheduleOnRN and custom timeAgo with date-fns

- Swap `runOnJS` for `scheduleOnRN` from `react-native-worklets` in
  gesture handler `.onEnd()` callbacks (HeroBanner, PosterCard)
- Delete custom `timeAgo` util; use `date-fns` `formatDistanceToNow`
  in integration status strings
- Enhance person detail screen: formatted birth date with age/death
  calculation, place of birth with MapPin icon, department label
  mapping, CalendarIcon for dates
- Fix `ExpandableText` truncation detection by measuring line count
  on a hidden full-text node rather than the clamped one; add
  `actionColor` prop so title accent color can theme the toggle
- Polish PosterCard metadata row: remove icon opacity, tighten gaps
This commit is contained in:
2026-03-13 18:11:05 -04:00
parent baf203516a
commit 35bdfc171e
10 changed files with 83 additions and 70 deletions
+1
View File
@@ -33,6 +33,7 @@
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.24",
"better-auth": "catalog:",
"date-fns": "catalog:",
"expo": "55.0.6",
"expo-application": "55.0.9",
"expo-clipboard": "55.0.8",
+51 -15
View File
@@ -1,9 +1,12 @@
import {
IconAlertTriangle,
IconCalendar,
IconMapPin,
IconMovie,
IconUser,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { format, parseISO } from "date-fns";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback } from "react";
import { FlatList, Pressable, useWindowDimensions, View } from "react-native";
@@ -21,6 +24,25 @@ import { orpc } from "@/lib/orpc";
const FILMOGRAPHY_GAP = 12;
const FILMOGRAPHY_PADDING = 16;
function calculateAge(birthday: string, deathday?: string | null): number {
const birth = new Date(birthday);
const end = deathday ? new Date(deathday) : new Date();
let age = end.getFullYear() - birth.getFullYear();
const m = end.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) {
age--;
}
return age;
}
const departmentLabels: Record<string, string> = {
Acting: "Actor",
Directing: "Director",
Writing: "Writer",
Production: "Producer",
Editing: "Editor",
};
export default function PersonDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
@@ -30,6 +52,7 @@ export default function PersonDetailScreen() {
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2;
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
const { data, isPending, isError } = useQuery(
orpc.people.detail.queryOptions({ input: { id } }),
@@ -52,14 +75,6 @@ export default function PersonDetailScreen() {
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
width={undefined}
/>
{credit.character ? (
<Text
numberOfLines={1}
className="mt-1 text-center text-[11px] text-muted-foreground"
>
as {credit.character}
</Text>
) : null}
</View>
),
[columnWidth, data?.userStatuses],
@@ -165,17 +180,38 @@ export default function PersonDetailScreen() {
{person.knownForDepartment ? (
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
<Text className="text-muted-foreground text-xs">
{person.knownForDepartment}
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
{departmentLabels[person.knownForDepartment] ??
person.knownForDepartment}
</Text>
</View>
) : null}
{person.birthday || person.deathday ? (
<Text selectable className="mt-2 text-[13px] text-muted-foreground">
{person.birthday?.slice(0, 4)}
{person.deathday ? `${person.deathday.slice(0, 4)}` : ""}
</Text>
{person.birthday || person.placeOfBirth ? (
<View className="mt-3 items-center gap-1.5">
{person.birthday ? (
<View className="flex-row items-center gap-1.5">
<IconCalendar size={14} color={primaryColor} />
<Text selectable className="text-[13px] text-muted-foreground">
{format(parseISO(person.birthday), "MMMM d, yyyy")}
{(() => {
const age = calculateAge(person.birthday, person.deathday);
return person.deathday
? ` (died at ${age})`
: ` (age ${age})`;
})()}
</Text>
</View>
) : null}
{person.placeOfBirth ? (
<View className="flex-row items-center gap-1.5">
<IconMapPin size={14} color={primaryColor} />
<Text selectable className="text-[13px] text-muted-foreground">
{person.placeOfBirth}
</Text>
</View>
) : null}
</View>
) : null}
</Animated.View>
+1 -1
View File
@@ -457,7 +457,7 @@ export default function TitleDetailScreen() {
{title.overview ? (
<Animated.View entering={FadeIn.duration(300).delay(300)}>
<View className="mt-5 px-4">
<ExpandableText text={title.overview} />
<ExpandableText text={title.overview} actionColor={titleAccent} />
</View>
</Animated.View>
) : null}
@@ -7,11 +7,11 @@ import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
@@ -60,7 +60,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
runOnJS(handlePress)();
scheduleOnRN(handlePress);
});
const useGlass = isLiquidGlassAvailable();
@@ -6,11 +6,11 @@ import { View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { Text } from "@/components/ui/text";
@@ -59,7 +59,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
runOnJS(handlePress)();
scheduleOnRN(handlePress);
});
return (
@@ -1,3 +1,4 @@
import { formatDistanceToNow } from "date-fns";
import type { SvgProps } from "react-native-svg";
import {
EmbyIcon,
@@ -7,7 +8,6 @@ import {
SonarrIcon,
} from "@/components/settings/icons";
import { getServerUrl } from "@/lib/server-url";
import { timeAgo } from "@/utils/time-ago";
export interface IntegrationConfig {
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
@@ -23,13 +23,13 @@ export interface IntegrationConfig {
function webhookStatus(lastEventAt: string | null): string {
return lastEventAt
? `Last event ${timeAgo(lastEventAt)}`
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
: "Ready — nothing received yet";
}
function listStatus(lastEventAt: string | null): string {
return lastEventAt
? `Last polled ${timeAgo(lastEventAt)}`
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
: "Ready — not polled yet";
}
@@ -6,9 +6,11 @@ import { Text } from "@/components/ui/text";
export function ExpandableText({
text,
maxLines = 3,
actionColor,
}: {
text: string;
maxLines?: number;
actionColor?: string;
}) {
const prevTextRef = useRef(text);
let expanded: boolean;
@@ -27,7 +29,7 @@ export function ExpandableText({
needsTruncation = needsTruncationState;
}
const onTextLayout = useCallback(
const onMeasureLayout = useCallback(
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
setNeedsTruncation(e.nativeEvent.lines.length > maxLines);
},
@@ -36,17 +38,26 @@ export function ExpandableText({
return (
<View>
{/* Hidden text without numberOfLines to measure true line count */}
<Text
onTextLayout={onMeasureLayout}
className="pointer-events-none absolute text-[14px] leading-[22px] opacity-0"
>
{text}
</Text>
<Text
selectable
numberOfLines={expanded ? undefined : maxLines}
onTextLayout={onTextLayout}
className="text-[14px] text-foreground leading-[22px]"
>
{text}
</Text>
{needsTruncation && (
<Pressable onPress={() => setExpanded(!expanded)} className="mt-1">
<Text className="font-sans-medium text-[13px] text-primary">
<Text
className="font-sans-medium text-[13px] text-primary"
style={actionColor ? { color: actionColor } : undefined}
>
{expanded ? "Show less" : "Show more"}
</Text>
</Pressable>
@@ -15,11 +15,11 @@ import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind";
import * as ContextMenu from "zeego/context-menu";
import { Image } from "@/components/ui/image";
@@ -210,19 +210,19 @@ export function PosterCard({
{title}
</Text>
</View>
<View className="mt-1 flex-row items-center gap-2">
<View className="mt-1 flex-row items-center gap-1">
{type === "movie" ? (
<IconMovie size={12} color={primaryColor} opacity={0.6} />
<IconMovie size={12} color={primaryColor} />
) : (
<IconDeviceTv size={12} color={primaryColor} opacity={0.6} />
<IconDeviceTv size={12} color={primaryColor} />
)}
{year ? (
<Text className="text-[11px] text-muted-foreground">{year}</Text>
) : null}
{voteAverage != null && voteAverage > 0 && (
<View className="ml-auto flex-row items-center gap-0.5">
<IconStarFilled size={10} color={primaryColor} opacity={0.8} />
<Text className="text-[11px] text-primary/80">
<View className="ml-auto flex-row items-center gap-1">
<IconStarFilled size={10} color={primaryColor} />
<Text className="text-[11px] text-primary">
{voteAverage.toFixed(1)}
</Text>
</View>
@@ -336,7 +336,7 @@ export function PosterCard({
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
})
.onEnd(() => {
runOnJS(handleResolve)();
scheduleOnRN(handleResolve);
});
return (
-36
View File
@@ -1,36 +0,0 @@
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
const WEEK = 604800;
const MONTH = 2592000;
const YEAR = 31536000;
export function timeAgo(dateString: string): string {
const seconds = Math.floor(
(Date.now() - new Date(dateString).getTime()) / 1000,
);
if (seconds < MINUTE) return "just now";
if (seconds < HOUR) {
const m = Math.floor(seconds / MINUTE);
return `${m} ${m === 1 ? "minute" : "minutes"} ago`;
}
if (seconds < DAY) {
const h = Math.floor(seconds / HOUR);
return `${h} ${h === 1 ? "hour" : "hours"} ago`;
}
if (seconds < WEEK) {
const d = Math.floor(seconds / DAY);
return `${d} ${d === 1 ? "day" : "days"} ago`;
}
if (seconds < MONTH) {
const w = Math.floor(seconds / WEEK);
return `${w} ${w === 1 ? "week" : "weeks"} ago`;
}
if (seconds < YEAR) {
const mo = Math.floor(seconds / MONTH);
return `${mo} ${mo === 1 ? "month" : "months"} ago`;
}
const y = Math.floor(seconds / YEAR);
return `${y} ${y === 1 ? "year" : "years"} ago`;
}
+1
View File
@@ -33,6 +33,7 @@
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.24",
"better-auth": "catalog:",
"date-fns": "catalog:",
"expo": "55.0.6",
"expo-application": "55.0.9",
"expo-clipboard": "55.0.8",