/** * User Detail Screen * Shows comprehensive user information with web feature parity * Query keys include selectedServerId for proper cache isolation per media server */ import { View, ScrollView, RefreshControl, Pressable, ActivityIndicator, Image } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { formatDistanceToNow, format } from 'date-fns'; import { Crown, Play, Clock, AlertTriangle, Globe, MapPin, Smartphone, Monitor, Tv, ChevronRight, Users, Zap, Check, Film, Music, XCircle, User, Bot, type LucideIcon, } from 'lucide-react-native'; import { useEffect, useState } from 'react'; import { api, getServerUrl } from '@/lib/api'; import { useMediaServer } from '@/providers/MediaServerProvider'; import { Text } from '@/components/ui/text'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { UserAvatar } from '@/components/ui/user-avatar'; import { cn } from '@/lib/utils'; import { colors } from '@/lib/theme'; import type { Session, ViolationWithDetails, UserLocation, UserDevice, RuleType, TerminationLogWithDetails, } from '@tracearr/shared'; const PAGE_SIZE = 10; // Safe date parsing helper - handles string dates from API function safeParseDate(date: Date | string | null | undefined): Date | null { if (!date) return null; const parsed = new Date(date); return isNaN(parsed.getTime()) ? null : parsed; } // Safe format distance helper function safeFormatDistanceToNow(date: Date | string | null | undefined): string { const parsed = safeParseDate(date); if (!parsed) return 'Unknown'; return formatDistanceToNow(parsed, { addSuffix: true }); } // Safe format date helper function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string { const parsed = safeParseDate(date); if (!parsed) return 'Unknown'; return format(parsed, formatStr); } // Rule type icons mapping const ruleIcons: Record = { impossible_travel: MapPin, simultaneous_locations: Users, device_velocity: Zap, concurrent_streams: Monitor, geo_restriction: Globe, }; // Rule type display names const ruleLabels: Record = { impossible_travel: 'Impossible Travel', simultaneous_locations: 'Simultaneous Locations', device_velocity: 'Device Velocity', concurrent_streams: 'Concurrent Streams', geo_restriction: 'Geo Restriction', }; function TrustScoreBadge({ score, showLabel = false }: { score: number; showLabel?: boolean }) { const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success'; const label = score < 50 ? 'Low' : score < 75 ? 'Medium' : 'High'; return ( {score} {showLabel && ( {label} Trust )} ); } function StatCard({ icon: Icon, label, value, subValue }: { icon: LucideIcon; label: string; value: string | number; subValue?: string; }) { return ( {label} {value} {subValue && {subValue}} ); } function SeverityBadge({ severity }: { severity: string }) { const variant = severity === 'critical' || severity === 'high' ? 'destructive' : severity === 'warning' ? 'warning' : 'default'; return ( {severity} ); } function formatDuration(ms: number | null): string { if (!ms) return '-'; const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) return `${hours}h ${minutes}m`; if (minutes > 0) return `${minutes}m ${seconds}s`; return `${seconds}s`; } function LocationCard({ location }: { location: UserLocation }) { const locationText = [location.city, location.region, location.country] .filter(Boolean) .join(', ') || 'Unknown Location'; return ( {locationText} {location.sessionCount} {location.sessionCount === 1 ? 'session' : 'sessions'} {' • '} {safeFormatDistanceToNow(location.lastSeenAt)} ); } function DeviceCard({ device }: { device: UserDevice }) { const deviceName = device.playerName || device.device || device.product || 'Unknown Device'; const platform = device.platform || 'Unknown Platform'; return ( {deviceName} {platform} • {device.sessionCount} {device.sessionCount === 1 ? 'session' : 'sessions'} Last seen {safeFormatDistanceToNow(device.lastSeenAt)} ); } function getMediaIcon(mediaType: string): typeof Film { switch (mediaType) { case 'movie': return Film; case 'episode': return Tv; case 'track': return Music; default: return Film; } } function SessionCard({ session, onPress, serverUrl }: { session: Session; onPress?: () => void; serverUrl: string | null }) { const locationText = [session.geoCity, session.geoCountry].filter(Boolean).join(', '); const MediaIcon = getMediaIcon(session.mediaType); // Build poster URL - need serverId and thumbPath const hasPoster = serverUrl && session.thumbPath && session.serverId; const posterUrl = hasPoster ? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath!)}&width=80&height=120` : null; // Determine display state - show "Watched" for completed sessions that reached 80%+ const getDisplayState = () => { if (session.watched) return { label: 'Watched', variant: 'success' as const }; if (session.state === 'playing') return { label: 'Playing', variant: 'success' as const }; if (session.state === 'paused') return { label: 'Paused', variant: 'warning' as const }; if (session.state === 'stopped') return { label: 'Stopped', variant: 'secondary' as const }; return { label: session.state || 'Unknown', variant: 'secondary' as const }; }; const displayState = getDisplayState(); return ( {/* Poster */} {posterUrl ? ( ) : ( )} {/* Content */} {session.mediaTitle} {session.mediaType} {displayState.label} {formatDuration(session.durationMs)} {session.platform || 'Unknown'} {locationText && ( {locationText} )} ); } function ViolationCard({ violation, onAcknowledge, }: { violation: ViolationWithDetails; onAcknowledge: () => void; }) { const ruleType = violation.rule?.type as RuleType | undefined; const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule'; const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle; const timeAgo = safeFormatDistanceToNow(violation.createdAt); return ( {ruleName} {timeAgo} {!violation.acknowledgedAt ? ( Acknowledge ) : ( Acknowledged )} ); } function TerminationCard({ termination }: { termination: TerminationLogWithDetails }) { const timeAgo = safeFormatDistanceToNow(termination.createdAt); const isManual = termination.trigger === 'manual'; return ( {isManual ? ( ) : ( )} {termination.mediaTitle ?? 'Unknown Media'} {termination.mediaType ?? 'unknown'} • {timeAgo} {isManual ? 'Manual' : 'Rule'} {isManual ? `By @${termination.triggeredByUsername ?? 'Unknown'}` : termination.ruleName ?? 'Unknown rule'} {termination.reason && ( Reason: {termination.reason} )} {termination.success ? ( <> Success ) : ( <> Failed )} ); } export default function UserDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const navigation = useNavigation(); const router = useRouter(); const queryClient = useQueryClient(); const { selectedServerId } = useMediaServer(); const [serverUrl, setServerUrl] = useState(null); // Load server URL for image proxy useEffect(() => { void getServerUrl().then(setServerUrl); }, []); // Fetch user detail - query keys include selectedServerId for cache isolation const { data: user, isLoading: userLoading, refetch: refetchUser, isRefetching: userRefetching, } = useQuery({ queryKey: ['user', id, selectedServerId], queryFn: () => api.users.get(id), enabled: !!id, }); // Update header title with username useEffect(() => { if (user?.username) { navigation.setOptions({ title: user.username }); } }, [user?.username, navigation]); // Fetch user sessions const { data: sessionsData, isLoading: sessionsLoading, fetchNextPage: fetchMoreSessions, hasNextPage: hasMoreSessions, isFetchingNextPage: fetchingMoreSessions, } = useInfiniteQuery({ queryKey: ['user', id, 'sessions', selectedServerId], queryFn: ({ pageParam = 1 }) => api.users.sessions(id, { page: pageParam, pageSize: PAGE_SIZE }), initialPageParam: 1, getNextPageParam: (lastPage: { page: number; totalPages: number }) => { if (lastPage.page < lastPage.totalPages) { return lastPage.page + 1; } return undefined; }, enabled: !!id, }); // Fetch user violations const { data: violationsData, isLoading: violationsLoading, fetchNextPage: fetchMoreViolations, hasNextPage: hasMoreViolations, isFetchingNextPage: fetchingMoreViolations, } = useInfiniteQuery({ queryKey: ['violations', { userId: id }, selectedServerId], queryFn: ({ pageParam = 1 }) => api.violations.list({ userId: id, page: pageParam, pageSize: PAGE_SIZE }), initialPageParam: 1, getNextPageParam: (lastPage: { page: number; totalPages: number }) => { if (lastPage.page < lastPage.totalPages) { return lastPage.page + 1; } return undefined; }, enabled: !!id, }); // Fetch user locations const { data: locations, isLoading: locationsLoading } = useQuery({ queryKey: ['user', id, 'locations', selectedServerId], queryFn: () => api.users.locations(id), enabled: !!id, }); // Fetch user devices const { data: devices, isLoading: devicesLoading } = useQuery({ queryKey: ['user', id, 'devices', selectedServerId], queryFn: () => api.users.devices(id), enabled: !!id, }); // Fetch user terminations const { data: terminationsData, isLoading: terminationsLoading, fetchNextPage: fetchMoreTerminations, hasNextPage: hasMoreTerminations, isFetchingNextPage: fetchingMoreTerminations, } = useInfiniteQuery({ queryKey: ['user', id, 'terminations', selectedServerId], queryFn: ({ pageParam = 1 }) => api.users.terminations(id, { page: pageParam, pageSize: PAGE_SIZE }), initialPageParam: 1, getNextPageParam: (lastPage: { page: number; totalPages: number }) => { if (lastPage.page < lastPage.totalPages) { return lastPage.page + 1; } return undefined; }, enabled: !!id, }); // Acknowledge mutation const acknowledgeMutation = useMutation({ mutationFn: api.violations.acknowledge, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] }); }, }); const sessions = sessionsData?.pages.flatMap((page) => page.data) || []; const violations = violationsData?.pages.flatMap((page) => page.data) || []; const terminations = terminationsData?.pages.flatMap((page) => page.data) || []; const totalSessions = sessionsData?.pages[0]?.total || 0; const totalViolations = violationsData?.pages[0]?.total || 0; const totalTerminations = terminationsData?.pages[0]?.total || 0; const handleRefresh = () => { void refetchUser(); void queryClient.invalidateQueries({ queryKey: ['user', id, 'sessions', selectedServerId] }); void queryClient.invalidateQueries({ queryKey: ['violations', { userId: id }, selectedServerId] }); void queryClient.invalidateQueries({ queryKey: ['user', id, 'locations', selectedServerId] }); void queryClient.invalidateQueries({ queryKey: ['user', id, 'devices', selectedServerId] }); void queryClient.invalidateQueries({ queryKey: ['user', id, 'terminations', selectedServerId] }); }; const handleSessionPress = (session: Session) => { router.push(`/session/${session.id}` as never); }; if (userLoading) { return ( ); } if (!user) { return ( User Not Found This user may have been removed. ); } return ( } > {/* User Info Card */} {user.username} {user.role === 'owner' && ( )} {user.email && ( {user.email} )} {/* Stats Grid */} {/* Locations */} Locations {locations?.length || 0} {locations?.length === 1 ? 'location' : 'locations'} {locationsLoading ? ( ) : locations && locations.length > 0 ? ( locations.slice(0, 5).map((location, index) => ( )) ) : ( No locations recorded )} {locations && locations.length > 5 && ( +{locations.length - 5} more locations )} {/* Devices */} Devices {devices?.length || 0} {devices?.length === 1 ? 'device' : 'devices'} {devicesLoading ? ( ) : devices && devices.length > 0 ? ( devices.slice(0, 5).map((device, index) => ( )) ) : ( No devices recorded )} {devices && devices.length > 5 && ( +{devices.length - 5} more devices )} {/* Recent Sessions */} Recent Sessions {totalSessions} total {sessionsLoading ? ( ) : sessions.length > 0 ? ( <> {sessions.map((session) => ( handleSessionPress(session)} /> ))} {hasMoreSessions && ( void fetchMoreSessions()} disabled={fetchingMoreSessions} > {fetchingMoreSessions ? ( ) : ( Load More )} )} ) : ( No sessions found )} {/* Violations */} Violations {totalViolations} total {violationsLoading ? ( ) : violations.length > 0 ? ( <> {violations.map((violation) => ( acknowledgeMutation.mutate(violation.id)} /> ))} {hasMoreViolations && ( void fetchMoreViolations()} disabled={fetchingMoreViolations} > {fetchingMoreViolations ? ( ) : ( Load More )} )} ) : ( No violations )} {/* Termination History */} Termination History {totalTerminations} total {terminationsLoading ? ( ) : terminations.length > 0 ? ( <> {terminations.map((termination) => ( ))} {hasMoreTerminations && ( void fetchMoreTerminations()} disabled={fetchingMoreTerminations} > {fetchingMoreTerminations ? ( ) : ( Load More )} )} ) : ( No stream terminations )} ); }