Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

8
apps/mobile/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Tracearr Mobile Environment Variables
# Copy this file to .env and fill in your values
# Your Tracearr server URL (set during QR pairing, but can override for development)
# EXPO_PUBLIC_API_URL=http://localhost:3000
# EAS Project ID (from expo.dev)
EXPO_PUBLIC_PROJECT_ID=your-eas-project-id

View File

@@ -0,0 +1,5 @@
babel.config.js
metro.config.js
postcss.config.mjs
.expo/
plugins/

45
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env
.env*.local
# credentials (generated in CI)
credentials/
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

34
apps/mobile/app.config.js Normal file
View File

@@ -0,0 +1,34 @@
/**
* Dynamic Expo config that extends app.json
* Allows injecting secrets from environment variables at build time
*/
const baseConfig = require('./app.json');
module.exports = ({ config }) => {
// Merge base config with dynamic values
return {
...baseConfig.expo,
...config,
plugins: [
// Keep existing plugins but update expo-maps with API key from env
...baseConfig.expo.plugins.map((plugin) => {
// Handle expo-maps plugin
if (Array.isArray(plugin) && plugin[0] === 'expo-maps') {
return [
'expo-maps',
{
...plugin[1],
android: {
...plugin[1]?.android,
// Inject Google Maps API key from EAS secrets or env var
googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY || '',
},
},
];
}
return plugin;
}),
],
};
};

102
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,102 @@
{
"expo": {
"name": "Tracearr",
"slug": "tracearr",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "tracearr",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon-transparent.png",
"resizeMode": "contain",
"backgroundColor": "#051723"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tracearr.app",
"infoPlist": {
"UIBackgroundModes": [
"remote-notification",
"fetch"
],
"NSCameraUsageDescription": "Tracearr needs camera access to scan QR codes for server pairing.",
"NSLocationWhenInUseUsageDescription": "Location permission is not required for this app.",
"ITSAppUsesNonExemptEncryption": false,
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true,
"NSAllowsLocalNetworking": true
},
"NSLocalNetworkUsageDescription": "Tracearr needs local network access to connect to your self-hosted server."
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon-transparent.png",
"backgroundColor": "#051723"
},
"edgeToEdgeEnabled": true,
"package": "com.tracearr.app",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
"VIBRATE",
"android.permission.CAMERA",
"android.permission.POST_NOTIFICATIONS"
]
},
"plugins": [
[
"./plugins/withGradleProperties",
{
"org.gradle.jvmargs": "-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8",
"org.gradle.daemon": "false"
}
],
"expo-router",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#18D1E7",
"defaultChannel": "violations",
"enableBackgroundRemoteNotifications": true
}
],
[
"expo-camera",
{
"cameraPermission": "Allow Tracearr to access your camera for QR code scanning.",
"recordAudioAndroid": false
}
],
[
"expo-maps",
{
"requestLocationPermission": false
}
],
"expo-secure-store",
"react-native-quick-crypto"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
},
"buildNumber": "1"
},
"owner": "gallopo-solutions",
"runtimeVersion": {
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/2e0b9595-ac62-493f-9a10-4f8758bb4b2d"
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* Auth layout - for login/pairing screens
*/
import { Stack } from 'expo-router';
import { colors } from '@/lib/theme';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background.dark },
}}
>
<Stack.Screen name="pair" options={{ title: 'Connect to Server' }} />
</Stack>
);
}

View File

@@ -0,0 +1,408 @@
/**
* Pairing screen - QR code scanner or manual entry
* Supports both initial pairing and adding additional servers
*/
import { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { ChevronLeft } from 'lucide-react-native';
import { useAuthStore } from '@/lib/authStore';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
interface QRPairingPayload {
url: string;
token: string;
}
export default function PairScreen() {
const router = useRouter();
const [permission, requestPermission] = useCameraPermissions();
const [manualMode, setManualMode] = useState(false);
const [serverUrl, setServerUrl] = useState('');
const [token, setToken] = useState('');
const [scanned, setScanned] = useState(false);
const scanLockRef = useRef(false); // Synchronous lock to prevent race conditions
const { addServer, isAuthenticated, servers, isLoading, error, clearError } = useAuthStore();
// Check if this is adding an additional server vs first-time pairing
const isAddingServer = isAuthenticated && servers.length > 0;
const handleBarCodeScanned = async ({ data }: { data: string }) => {
// Use ref for synchronous check - state updates are async and cause race conditions
if (scanLockRef.current || isLoading) return;
scanLockRef.current = true; // Immediate synchronous lock
setScanned(true);
try {
// Parse tracearr://pair?data=<base64>
// First check if it even looks like our URL format
if (!data.startsWith('tracearr://pair')) {
// Silently ignore non-Tracearr QR codes (don't spam alerts)
setTimeout(() => {
scanLockRef.current = false;
setScanned(false);
}, 2000);
return;
}
const url = new URL(data);
const base64Data = url.searchParams.get('data');
if (!base64Data) {
throw new Error('Invalid QR code: missing pairing data');
}
// Decode and parse payload with proper error handling
let payload: QRPairingPayload;
try {
const decoded = atob(base64Data);
payload = JSON.parse(decoded) as QRPairingPayload;
} catch {
throw new Error('Invalid QR code format. Please generate a new code.');
}
// Validate payload has required fields
if (!payload.url || typeof payload.url !== 'string') {
throw new Error('Invalid QR code: missing server URL');
}
if (!payload.token || typeof payload.token !== 'string') {
throw new Error('Invalid QR code: missing pairing token');
}
// Validate URL format
if (!payload.url.startsWith('http://') && !payload.url.startsWith('https://')) {
throw new Error('Invalid server URL in QR code');
}
await addServer(payload.url, payload.token);
// Navigate to tabs after successful pairing
router.replace('/(tabs)');
} catch (err) {
Alert.alert('Pairing Failed', err instanceof Error ? err.message : 'Invalid QR code');
// Add cooldown before allowing another scan
setTimeout(() => {
scanLockRef.current = false;
setScanned(false);
}, 3000);
}
};
const handleManualPair = async () => {
if (!serverUrl.trim() || !token.trim()) {
Alert.alert('Missing Fields', 'Please enter both server URL and token');
return;
}
const trimmedUrl = serverUrl.trim();
// Validate URL format
if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
Alert.alert('Invalid URL', 'Server URL must start with http:// or https://');
return;
}
// Validate URL is well-formed
try {
new URL(trimmedUrl);
} catch {
Alert.alert('Invalid URL', 'Please enter a valid server URL');
return;
}
clearError();
try {
await addServer(trimmedUrl, token.trim());
// Navigate to tabs after successful pairing
router.replace('/(tabs)');
} catch {
// Error is handled by the store
}
};
const handleBack = () => {
router.back();
};
if (manualMode) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Back button for adding servers */}
{isAddingServer && (
<Pressable style={styles.backButton} onPress={handleBack}>
<ChevronLeft size={24} color={colors.text.primary.dark} />
<Text style={styles.backText}>Back</Text>
</Pressable>
)}
<View style={styles.header}>
<Text style={styles.title}>
{isAddingServer ? 'Add Server' : 'Connect to Server'}
</Text>
<Text style={styles.subtitle}>
Enter your Tracearr server URL and mobile access token
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
value={serverUrl}
onChangeText={setServerUrl}
placeholder="https://tracearr.example.com"
placeholderTextColor={colors.text.muted.dark}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isLoading}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Access Token</Text>
<TextInput
style={styles.input}
value={token}
onChangeText={setToken}
placeholder="trr_mob_..."
placeholderTextColor={colors.text.muted.dark}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
editable={!isLoading}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<Pressable
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleManualPair}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'Connecting...' : 'Connect'}
</Text>
</Pressable>
<Pressable
style={styles.linkButton}
onPress={() => setManualMode(false)}
disabled={isLoading}
>
<Text style={styles.linkText}>Scan QR Code Instead</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Back button for adding servers */}
{isAddingServer && (
<Pressable style={styles.backButton} onPress={handleBack}>
<ChevronLeft size={24} color={colors.text.primary.dark} />
<Text style={styles.backText}>Back</Text>
</Pressable>
)}
<View style={styles.header}>
<Text style={styles.title}>
{isAddingServer ? 'Add Server' : 'Welcome to Tracearr'}
</Text>
<Text style={styles.subtitle}>
Open Settings Mobile App in your Tracearr dashboard and scan the QR code
</Text>
</View>
<View style={styles.cameraContainer}>
{permission?.granted ? (
<CameraView
style={styles.camera}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
>
<View style={styles.overlay}>
<View style={styles.scanFrame} />
</View>
</CameraView>
) : (
<View style={styles.permissionContainer}>
<Text style={styles.permissionText}>
Camera permission is required to scan QR codes
</Text>
<Pressable style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant Permission</Text>
</Pressable>
</View>
)}
</View>
<View style={styles.footer}>
<Pressable style={styles.linkButton} onPress={() => setManualMode(true)}>
<Text style={styles.linkText}>Enter URL and Token Manually</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
padding: spacing.lg,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginTop: spacing.sm,
},
backText: {
fontSize: typography.fontSize.base,
color: colors.text.primary.dark,
marginLeft: spacing.xs,
},
header: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: spacing.lg,
alignItems: 'center',
},
title: {
fontSize: typography.fontSize['2xl'],
fontWeight: 'bold',
color: colors.text.primary.dark,
marginBottom: spacing.sm,
textAlign: 'center',
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.text.secondary.dark,
textAlign: 'center',
lineHeight: 22,
},
cameraContainer: {
flex: 1,
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
borderRadius: borderRadius.xl,
overflow: 'hidden',
backgroundColor: colors.card.dark,
},
camera: {
flex: 1,
},
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
},
scanFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: colors.cyan.core,
borderRadius: borderRadius.lg,
backgroundColor: 'transparent',
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
permissionText: {
fontSize: typography.fontSize.base,
color: colors.text.secondary.dark,
textAlign: 'center',
marginBottom: spacing.lg,
},
footer: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
alignItems: 'center',
},
form: {
flex: 1,
gap: spacing.md,
},
inputGroup: {
gap: spacing.xs,
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: '500',
color: colors.text.secondary.dark,
},
input: {
backgroundColor: colors.card.dark,
borderWidth: 1,
borderColor: colors.border.dark,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: typography.fontSize.base,
color: colors.text.primary.dark,
},
button: {
backgroundColor: colors.cyan.core,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.md,
alignItems: 'center',
marginTop: spacing.sm,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.blue.core,
},
linkButton: {
paddingVertical: spacing.md,
alignItems: 'center',
},
linkText: {
fontSize: typography.fontSize.base,
color: colors.cyan.core,
},
errorText: {
fontSize: typography.fontSize.sm,
color: colors.error,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,116 @@
/**
* Main tab navigation layout
*/
import { Tabs } from 'expo-router';
import {
LayoutDashboard,
Activity,
Users,
Bell,
Settings,
type LucideIcon,
} from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '@/lib/theme';
import { ServerSelector } from '@/components/ServerSelector';
interface TabIconProps {
icon: LucideIcon;
focused: boolean;
}
function TabIcon({ icon: Icon, focused }: TabIconProps) {
return (
<Icon
size={24}
color={focused ? colors.cyan.core : colors.text.muted.dark}
strokeWidth={focused ? 2.5 : 2}
/>
);
}
export default function TabLayout() {
const insets = useSafeAreaInsets();
// Dynamic tab bar height: base height + safe area bottom inset
const tabBarHeight = 60 + insets.bottom;
return (
<Tabs
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
tabBarStyle: {
backgroundColor: colors.card.dark,
borderTopColor: colors.border.dark,
borderTopWidth: 1,
height: tabBarHeight,
paddingBottom: insets.bottom,
paddingTop: 8,
},
tabBarActiveTintColor: colors.cyan.core,
tabBarInactiveTintColor: colors.text.muted.dark,
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Dashboard',
tabBarIcon: ({ focused }) => (
<TabIcon icon={LayoutDashboard} focused={focused} />
),
}}
/>
<Tabs.Screen
name="activity"
options={{
title: 'Activity',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Activity',
tabBarIcon: ({ focused }) => (
<TabIcon icon={Activity} focused={focused} />
),
}}
/>
<Tabs.Screen
name="users"
options={{
title: 'Users',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Users',
tabBarIcon: ({ focused }) => <TabIcon icon={Users} focused={focused} />,
}}
/>
<Tabs.Screen
name="alerts"
options={{
title: 'Alerts',
headerTitle: () => <ServerSelector />,
tabBarLabel: 'Alerts',
tabBarIcon: ({ focused }) => <TabIcon icon={Bell} focused={focused} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarLabel: 'Settings',
tabBarIcon: ({ focused }) => (
<TabIcon icon={Settings} focused={focused} />
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,157 @@
/**
* Activity tab - streaming statistics and charts
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { useState } from 'react';
import { View, ScrollView, RefreshControl } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { colors } from '@/lib/theme';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { PeriodSelector, type StatsPeriod } from '@/components/ui/period-selector';
import {
PlaysChart,
PlatformChart,
DayOfWeekChart,
HourOfDayChart,
QualityChart,
} from '@/components/charts';
function ChartSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
{children}
</View>
);
}
export default function ActivityScreen() {
const [period, setPeriod] = useState<StatsPeriod>('month');
const { selectedServerId } = useMediaServer();
// Fetch all stats data with selected period - query keys include selectedServerId for cache isolation
const {
data: playsData,
refetch: refetchPlays,
isRefetching: isRefetchingPlays,
} = useQuery({
queryKey: ['stats', 'plays', period, selectedServerId],
queryFn: () => api.stats.plays({ period, serverId: selectedServerId ?? undefined }),
});
const { data: dayOfWeekData, refetch: refetchDayOfWeek } = useQuery({
queryKey: ['stats', 'dayOfWeek', period, selectedServerId],
queryFn: () => api.stats.playsByDayOfWeek({ period, serverId: selectedServerId ?? undefined }),
});
const { data: hourOfDayData, refetch: refetchHourOfDay } = useQuery({
queryKey: ['stats', 'hourOfDay', period, selectedServerId],
queryFn: () => api.stats.playsByHourOfDay({ period, serverId: selectedServerId ?? undefined }),
});
const { data: platformsData, refetch: refetchPlatforms } = useQuery({
queryKey: ['stats', 'platforms', period, selectedServerId],
queryFn: () => api.stats.platforms({ period, serverId: selectedServerId ?? undefined }),
});
const { data: qualityData, refetch: refetchQuality } = useQuery({
queryKey: ['stats', 'quality', period, selectedServerId],
queryFn: () => api.stats.quality({ period, serverId: selectedServerId ?? undefined }),
});
const handleRefresh = () => {
void refetchPlays();
void refetchDayOfWeek();
void refetchHourOfDay();
void refetchPlatforms();
void refetchQuality();
};
// Period labels for display
const periodLabels: Record<StatsPeriod, string> = {
week: 'Last 7 Days',
month: 'Last 30 Days',
year: 'Last Year',
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView
className="flex-1"
contentContainerClassName="p-4 pt-3"
refreshControl={
<RefreshControl
refreshing={isRefetchingPlays}
onRefresh={handleRefresh}
tintColor={colors.cyan.core}
/>
}
>
{/* Header with Period Selector */}
<View className="flex-row items-center justify-between mb-4">
<View>
<Text className="text-lg font-semibold">Activity</Text>
<Text className="text-sm text-muted-foreground">{periodLabels[period]}</Text>
</View>
<PeriodSelector value={period} onChange={setPeriod} />
</View>
{/* Plays Over Time */}
<ChartSection title="Plays Over Time">
<PlaysChart data={playsData?.data || []} height={180} />
</ChartSection>
{/* Day of Week & Hour of Day in a row on larger screens */}
<View className="flex-row gap-3 mb-4">
<View className="flex-1">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
By Day
</Text>
<DayOfWeekChart data={dayOfWeekData?.data || []} height={160} />
</View>
</View>
<View className="mb-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
By Hour
</Text>
<HourOfDayChart data={hourOfDayData?.data || []} height={160} />
</View>
{/* Platform Breakdown */}
<ChartSection title="Platforms">
<PlatformChart data={platformsData?.data || []} />
</ChartSection>
{/* Quality Breakdown */}
<ChartSection title="Playback Quality">
{qualityData ? (
<QualityChart
directPlay={qualityData.directPlay}
transcode={qualityData.transcode}
directPlayPercent={qualityData.directPlayPercent}
transcodePercent={qualityData.transcodePercent}
height={120}
/>
) : (
<Card className="h-[120px] items-center justify-center">
<Text className="text-muted-foreground">Loading...</Text>
</Card>
)}
</ChartSection>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,326 @@
/**
* Alerts tab - violations with infinite scroll
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInfiniteQuery, useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { formatDistanceToNow } from 'date-fns';
import {
MapPin,
Users,
Zap,
Monitor,
Globe,
AlertTriangle,
Check,
type LucideIcon,
} from 'lucide-react-native';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/user-avatar';
import { colors } from '@/lib/theme';
import type { ViolationWithDetails, RuleType, UnitSystem } from '@tracearr/shared';
import { formatSpeed } from '@tracearr/shared';
const PAGE_SIZE = 50;
// Rule type icons mapping
const ruleIcons: Record<RuleType, LucideIcon> = {
impossible_travel: MapPin,
simultaneous_locations: Users,
device_velocity: Zap,
concurrent_streams: Monitor,
geo_restriction: Globe,
};
// Rule type display names
const ruleLabels: Record<RuleType, string> = {
impossible_travel: 'Impossible Travel',
simultaneous_locations: 'Simultaneous Locations',
device_velocity: 'Device Velocity',
concurrent_streams: 'Concurrent Streams',
geo_restriction: 'Geo Restriction',
};
// Format violation data into readable description based on rule type
function getViolationDescription(violation: ViolationWithDetails, unitSystem: UnitSystem = 'metric'): string {
const data = violation.data;
const ruleType = violation.rule?.type;
if (!data || !ruleType) {
return 'Rule violation detected';
}
switch (ruleType) {
case 'impossible_travel': {
const from = data.fromCity || data.fromLocation || 'unknown location';
const to = data.toCity || data.toLocation || 'unknown location';
const speed = typeof data.calculatedSpeedKmh === 'number'
? formatSpeed(data.calculatedSpeedKmh, unitSystem)
: 'impossible speed';
return `Traveled from ${from} to ${to} at ${speed}`;
}
case 'simultaneous_locations': {
const locations = data.locations as string[] | undefined;
const count = data.locationCount as number | undefined;
if (locations && locations.length > 0) {
return `Active from ${locations.length} locations: ${locations.slice(0, 2).join(', ')}${locations.length > 2 ? '...' : ''}`;
}
if (count) {
return `Streaming from ${count} different locations simultaneously`;
}
return 'Streaming from multiple locations simultaneously';
}
case 'device_velocity': {
const ipCount = data.ipCount as number | undefined;
const windowHours = data.windowHours as number | undefined;
if (ipCount && windowHours) {
return `${ipCount} different IPs used in ${windowHours}h window`;
}
return 'Too many unique devices in short period';
}
case 'concurrent_streams': {
const streamCount = data.streamCount as number | undefined;
const maxStreams = data.maxStreams as number | undefined;
if (streamCount && maxStreams) {
return `${streamCount} concurrent streams (limit: ${maxStreams})`;
}
return 'Exceeded concurrent stream limit';
}
case 'geo_restriction': {
const country = data.country as string | undefined;
const blockedCountry = data.blockedCountry as string | undefined;
if (country || blockedCountry) {
return `Streaming from blocked region: ${country || blockedCountry}`;
}
return 'Streaming from restricted location';
}
default:
return 'Rule violation detected';
}
}
function SeverityBadge({ severity }: { severity: string }) {
const variant =
severity === 'critical' || severity === 'high'
? 'destructive'
: severity === 'warning'
? 'warning'
: 'default';
return (
<Badge variant={variant} className="capitalize">
{severity}
</Badge>
);
}
function RuleIcon({ ruleType }: { ruleType: RuleType | undefined }) {
const IconComponent = ruleType ? ruleIcons[ruleType] : AlertTriangle;
return (
<View className="w-8 h-8 rounded-lg bg-surface items-center justify-center">
<IconComponent size={16} color={colors.cyan.core} />
</View>
);
}
function ViolationCard({
violation,
onAcknowledge,
onPress,
unitSystem,
}: {
violation: ViolationWithDetails;
onAcknowledge: () => void;
onPress: () => void;
unitSystem: UnitSystem;
}) {
const username = violation.user?.username || 'Unknown User';
const ruleType = violation.rule?.type as RuleType | undefined;
const ruleName = ruleType ? ruleLabels[ruleType] : violation.rule?.name || 'Unknown Rule';
const description = getViolationDescription(violation, unitSystem);
const timeAgo = formatDistanceToNow(new Date(violation.createdAt), { addSuffix: true });
return (
<Pressable onPress={onPress} className="active:opacity-80">
<Card className="mb-3">
{/* Header: User + Severity */}
<View className="flex-row justify-between items-start mb-3">
<Pressable
className="flex-row items-center gap-2.5 flex-1 active:opacity-70"
onPress={onPress}
>
<UserAvatar
thumbUrl={violation.user?.thumbUrl}
username={username}
size={40}
/>
<View className="flex-1">
<Text className="text-base font-semibold">{username}</Text>
<Text className="text-xs text-muted-foreground">{timeAgo}</Text>
</View>
</Pressable>
<SeverityBadge severity={violation.severity} />
</View>
{/* Content: Rule Type with Icon + Description */}
<View className="flex-row items-start gap-3 mb-3">
<RuleIcon ruleType={ruleType} />
<View className="flex-1">
<Text className="text-sm font-medium text-cyan-core mb-1">
{ruleName}
</Text>
<Text className="text-sm text-secondary leading-5" numberOfLines={2}>
{description}
</Text>
</View>
</View>
{/* Action Button */}
{!violation.acknowledgedAt ? (
<Pressable
className="flex-row items-center justify-center gap-2 bg-cyan-core/15 py-2.5 rounded-lg active:opacity-70"
onPress={(e) => {
e.stopPropagation();
onAcknowledge();
}}
>
<Check size={16} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-cyan-core">Acknowledge</Text>
</Pressable>
) : (
<View className="flex-row items-center justify-center gap-2 bg-success/10 py-2.5 rounded-lg">
<Check size={16} color={colors.success} />
<Text className="text-sm text-success">Acknowledged</Text>
</View>
)}
</Card>
</Pressable>
);
}
export default function AlertsScreen() {
const router = useRouter();
const queryClient = useQueryClient();
const { selectedServerId } = useMediaServer();
// Fetch settings for unit system preference
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.settings.get,
staleTime: 1000 * 60 * 5, // 5 minutes
});
const unitSystem = settings?.unitSystem ?? 'metric';
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['violations', selectedServerId],
queryFn: ({ pageParam = 1 }) =>
api.violations.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
});
const acknowledgeMutation = useMutation({
mutationFn: api.violations.acknowledge,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['violations', selectedServerId] });
},
});
// Flatten all pages into single array
const violations = data?.pages.flatMap((page) => page.data) || [];
const unacknowledgedCount = violations.filter((v) => !v.acknowledgedAt).length;
const total = data?.pages[0]?.total || 0;
const handleEndReached = () => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
};
const handleViolationPress = (violation: ViolationWithDetails) => {
// Navigate to user detail page
if (violation.user?.id) {
router.push(`/user/${violation.user.id}` as never);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<FlatList
data={violations}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ViolationCard
violation={item}
onAcknowledge={() => acknowledgeMutation.mutate(item.id)}
onPress={() => handleViolationPress(item)}
unitSystem={unitSystem}
/>
)}
contentContainerClassName="p-4 pt-3"
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.cyan.core}
/>
}
ListHeaderComponent={
<View className="flex-row justify-between items-center mb-3">
<View>
<Text className="text-lg font-semibold">Alerts</Text>
<Text className="text-sm text-muted-foreground">
{total} {total === 1 ? 'violation' : 'violations'} total
</Text>
</View>
{unacknowledgedCount > 0 && (
<View className="bg-destructive/20 px-3 py-1.5 rounded-lg">
<Text className="text-sm font-medium text-destructive">
{unacknowledgedCount} pending
</Text>
</View>
)}
</View>
}
ListFooterComponent={
isFetchingNextPage ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" color={colors.cyan.core} />
</View>
) : null
}
ListEmptyComponent={
<View className="items-center py-16">
<View className="w-20 h-20 rounded-full bg-success/10 border border-success/20 items-center justify-center mb-4">
<Check size={32} color={colors.success} />
</View>
<Text className="text-xl font-semibold mb-2">All Clear</Text>
<Text className="text-sm text-muted-foreground text-center px-8 leading-5">
No rule violations have been detected. Your users are behaving nicely!
</Text>
</View>
}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,208 @@
/**
* Dashboard tab - overview of streaming activity
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, ScrollView, RefreshControl } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { Ionicons } from '@expo/vector-icons';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { useServerStatistics } from '@/hooks/useServerStatistics';
import { StreamMap } from '@/components/map/StreamMap';
import { NowPlayingCard } from '@/components/sessions';
import { ServerResourceCard } from '@/components/server/ServerResourceCard';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { colors } from '@/lib/theme';
/**
* Compact stat pill for dashboard summary bar
*/
function StatPill({
icon,
value,
unit,
color = colors.text.secondary.dark,
}: {
icon: keyof typeof Ionicons.glyphMap;
value: string | number;
unit?: string;
color?: string;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card.dark,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
gap: 6,
}}
>
<Ionicons name={icon} size={14} color={color} />
<Text style={{ fontSize: 13, fontWeight: '600', color: colors.text.primary.dark }}>
{value}
</Text>
{unit && (
<Text style={{ fontSize: 11, color: colors.text.muted.dark }}>{unit}</Text>
)}
</View>
);
}
export default function DashboardScreen() {
const router = useRouter();
const { selectedServerId, selectedServer } = useMediaServer();
const {
data: stats,
refetch,
isRefetching,
} = useQuery({
queryKey: ['dashboard', 'stats', selectedServerId],
queryFn: () => api.stats.dashboard(selectedServerId ?? undefined),
});
const { data: activeSessions } = useQuery({
queryKey: ['sessions', 'active', selectedServerId],
queryFn: () => api.sessions.active(selectedServerId ?? undefined),
staleTime: 1000 * 15, // 15 seconds - match web
refetchInterval: 1000 * 30, // 30 seconds - fallback if WebSocket events missed
});
// Only show server resources for Plex servers
const isPlexServer = selectedServer?.type === 'plex';
// Poll server statistics only when dashboard is visible and we have a Plex server
const {
latest: serverResources,
isLoadingData: resourcesLoading,
error: resourcesError,
} = useServerStatistics(selectedServerId ?? undefined, isPlexServer);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView
className="flex-1"
contentContainerClassName="pb-8"
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.cyan.core} />
}
>
{/* Today's Stats Bar */}
{stats && (
<View className="px-4 pt-3 pb-2">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<Text style={{ fontSize: 11, color: colors.text.muted.dark, fontWeight: '600', marginRight: 2 }}>
TODAY
</Text>
<StatPill icon="play-circle-outline" value={stats.todayPlays} unit="plays" />
<StatPill icon="time-outline" value={stats.watchTimeHours} unit="hrs" />
<StatPill
icon="warning-outline"
value={stats.alertsLast24h}
unit="alerts"
color={stats.alertsLast24h > 0 ? colors.warning : colors.text.muted.dark}
/>
</View>
</View>
)}
{/* Now Playing - Active Streams */}
<View className="px-4 mb-4">
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
<Ionicons name="tv-outline" size={18} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Now Playing
</Text>
</View>
{activeSessions && activeSessions.length > 0 && (
<View
style={{
backgroundColor: 'rgba(24, 209, 231, 0.15)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
}}
>
<Text style={{ color: colors.cyan.core, fontSize: 12, fontWeight: '600' }}>
{activeSessions.length} {activeSessions.length === 1 ? 'stream' : 'streams'}
</Text>
</View>
)}
</View>
{activeSessions && activeSessions.length > 0 ? (
<View>
{activeSessions.map((session) => (
<NowPlayingCard
key={session.id}
session={session}
onPress={() => router.push(`/session/${session.id}` as never)}
/>
))}
</View>
) : (
<Card className="py-8">
<View className="items-center">
<View
style={{
backgroundColor: colors.surface.dark,
padding: 16,
borderRadius: 999,
marginBottom: 12,
}}
>
<Ionicons name="tv-outline" size={32} color={colors.text.muted.dark} />
</View>
<Text className="text-base font-semibold">No active streams</Text>
<Text className="text-sm text-muted-foreground mt-1">Streams will appear here when users start watching</Text>
</View>
</Card>
)}
</View>
{/* Stream Map - only show when there are active streams */}
{activeSessions && activeSessions.length > 0 && (
<View className="px-4 mb-4">
<View className="flex-row items-center gap-2 mb-3">
<Ionicons name="location-outline" size={18} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Stream Locations
</Text>
</View>
<StreamMap sessions={activeSessions} height={200} />
</View>
)}
{/* Server Resources - only show if Plex server is active */}
{isPlexServer && (
<View className="px-4">
<View className="flex-row items-center gap-2 mb-3">
<Ionicons name="server-outline" size={18} color={colors.cyan.core} />
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Server Resources
</Text>
</View>
<ServerResourceCard
latest={serverResources}
isLoading={resourcesLoading}
error={resourcesError}
/>
</View>
)}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,299 @@
/**
* Settings tab - notifications, app info
* Server selection is handled via the global header selector
*/
import { View, ScrollView, Pressable, Switch, Alert, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronRight, LogOut } from 'lucide-react-native';
import { useAuthStore } from '@/lib/authStore';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { colors } from '@/lib/theme';
import Constants from 'expo-constants';
function SettingsRow({
label,
value,
onPress,
showChevron,
leftIcon,
rightIcon,
destructive,
}: {
label: string;
value?: string;
onPress?: () => void;
showChevron?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
destructive?: boolean;
}) {
const content = (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
<View className="flex-row items-center flex-1">
{leftIcon && <View className="mr-3">{leftIcon}</View>}
<Text className={cn('text-base flex-1', destructive && 'text-destructive')}>{label}</Text>
</View>
<View className="flex-row items-center">
{value && <Text className="text-base text-muted-foreground text-right ml-4">{value}</Text>}
{rightIcon}
{showChevron && (
<ChevronRight size={20} color={colors.text.muted.dark} className="ml-2" />
)}
</View>
</View>
);
if (onPress) {
return (
<Pressable onPress={onPress} className="active:opacity-70 active:bg-background">
{content}
</Pressable>
);
}
return content;
}
function SettingsToggle({
label,
description,
value,
onValueChange,
disabled,
isLoading,
}: {
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
isLoading?: boolean;
}) {
return (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[48px]">
<View className="flex-1 mr-4">
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
{description && (
<Text className={cn('text-xs text-muted-foreground mt-0.5', disabled && 'opacity-50')}>
{description}
</Text>
)}
</View>
{isLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
/>
)}
</View>
);
}
function SettingsSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-6 px-4">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
<Card className="p-0 overflow-hidden">{children}</Card>
</View>
);
}
function Divider() {
return <View className="h-px bg-border ml-4" />;
}
export default function SettingsScreen() {
const router = useRouter();
const queryClient = useQueryClient();
const {
activeServerId,
activeServer,
isLoading: isAuthLoading,
logout,
} = useAuthStore();
const appVersion = Constants.expoConfig?.version || '1.0.0';
// Fetch notification preferences
const {
data: preferences,
isLoading: isLoadingPrefs,
error: prefsError,
} = useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: api.notifications.getPreferences,
staleTime: 1000 * 60, // 1 minute
enabled: !!activeServerId,
});
// Update mutation for quick toggle
const updateMutation = useMutation({
mutationFn: api.notifications.updatePreferences,
onMutate: async (newData) => {
await queryClient.cancelQueries({
queryKey: ['notifications', 'preferences'],
});
const previousData = queryClient.getQueryData([
'notifications',
'preferences',
]);
queryClient.setQueryData(
['notifications', 'preferences'],
(old: typeof preferences) => (old ? { ...old, ...newData } : old)
);
return { previousData };
},
onError: (_err, _newData, context) => {
if (context?.previousData) {
queryClient.setQueryData(
['notifications', 'preferences'],
context.previousData
);
}
},
onSettled: () => {
void queryClient.invalidateQueries({
queryKey: ['notifications', 'preferences'],
});
},
});
const handleDisconnect = () => {
Alert.alert(
'Disconnect from Server',
'Are you sure you want to disconnect? You will need to scan a QR code to reconnect.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: () => {
void (async () => {
await queryClient.cancelQueries();
await logout();
queryClient.clear();
})();
},
},
]
);
};
const handleTogglePush = (value: boolean) => {
updateMutation.mutate({ pushEnabled: value });
};
const navigateToNotificationSettings = () => {
router.push('/settings/notifications');
};
// Count enabled notification events for summary
const enabledEventCount = preferences
? [
preferences.onViolationDetected,
preferences.onStreamStarted,
preferences.onStreamStopped,
preferences.onConcurrentStreams,
preferences.onNewDevice,
preferences.onTrustScoreChanged,
preferences.onServerDown,
preferences.onServerUp,
].filter(Boolean).length
: 0;
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark }}
edges={['left', 'right', 'bottom']}
>
<ScrollView className="flex-1" contentContainerClassName="py-4">
{/* Connected Server Info */}
{activeServer && (
<SettingsSection title="Connected Server">
<SettingsRow label="Name" value={activeServer.name} />
<Divider />
<SettingsRow label="URL" value={activeServer.url} />
<Divider />
<SettingsRow
label="Disconnect"
onPress={handleDisconnect}
leftIcon={<LogOut size={20} color={colors.error} />}
destructive
/>
</SettingsSection>
)}
{/* Notification Settings */}
{activeServerId && (
<SettingsSection title="Notifications">
{prefsError ? (
<View className="px-4 py-3">
<Text className="text-destructive text-sm">
Failed to load notification settings
</Text>
</View>
) : (
<>
<SettingsToggle
label="Push Notifications"
description="Receive alerts on this device"
value={preferences?.pushEnabled ?? false}
onValueChange={handleTogglePush}
isLoading={isLoadingPrefs}
disabled={updateMutation.isPending}
/>
<Divider />
<SettingsRow
label="Notification Settings"
value={
preferences?.pushEnabled ? `${enabledEventCount} events enabled` : 'Disabled'
}
onPress={navigateToNotificationSettings}
showChevron
/>
<Text className="text-xs text-muted-foreground px-4 py-2 leading-4">
Configure which events trigger notifications, quiet hours, and filters.
</Text>
</>
)}
</SettingsSection>
)}
{/* App Info */}
<SettingsSection title="About">
<SettingsRow label="App Version" value={appVersion} />
<Divider />
<SettingsRow
label="Build"
value={(Constants.expoConfig?.extra?.buildNumber as string | undefined) ?? 'dev'}
/>
</SettingsSection>
{/* Loading indicator */}
{isAuthLoading && (
<View className="px-4 py-8 items-center">
<ActivityIndicator color={colors.cyan.core} />
</View>
)}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,149 @@
/**
* Users tab - user list with infinite scroll
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { View, FlatList, RefreshControl, Pressable, ActivityIndicator } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { api } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { UserAvatar } from '@/components/ui/user-avatar';
import { cn } from '@/lib/utils';
import { colors } from '@/lib/theme';
import type { ServerUserWithIdentity } from '@tracearr/shared';
const PAGE_SIZE = 50;
function TrustScoreBadge({ score }: { score: number }) {
const variant = score < 50 ? 'destructive' : score < 75 ? 'warning' : 'success';
return (
<View
className={cn(
'px-2 py-1 rounded-sm min-w-[40px] items-center',
variant === 'destructive' && 'bg-destructive/20',
variant === 'warning' && 'bg-warning/20',
variant === 'success' && 'bg-success/20'
)}
>
<Text
className={cn(
'text-sm font-semibold',
variant === 'destructive' && 'text-destructive',
variant === 'warning' && 'text-warning',
variant === 'success' && 'text-success'
)}
>
{score}
</Text>
</View>
);
}
function UserCard({ user, onPress }: { user: ServerUserWithIdentity; onPress: () => void }) {
return (
<Pressable onPress={onPress}>
<Card className="flex-row items-center justify-between mb-2 p-3">
<View className="flex-row items-center gap-3 flex-1">
<UserAvatar thumbUrl={user.thumbUrl} username={user.username} size={48} />
<View className="flex-1">
<Text className="text-base font-semibold">{user.username}</Text>
<Text className="text-sm text-muted-foreground mt-0.5">
{user.role === 'owner' ? 'Owner' : 'User'}
</Text>
</View>
</View>
<TrustScoreBadge score={user.trustScore} />
</Card>
</Pressable>
);
}
export default function UsersScreen() {
const router = useRouter();
const { selectedServerId } = useMediaServer();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['users', selectedServerId],
queryFn: ({ pageParam = 1 }) =>
api.users.list({ page: pageParam, pageSize: PAGE_SIZE, serverId: selectedServerId ?? undefined }),
initialPageParam: 1,
getNextPageParam: (lastPage: { page: number; totalPages: number }) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
});
// Flatten all pages into single array
const users = data?.pages.flatMap((page) => page.data) || [];
const total = data?.pages[0]?.total || 0;
const handleEndReached = () => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<UserCard
user={item}
onPress={() => router.push(`/user/${item.id}` as never)}
/>
)}
contentContainerClassName="p-4 pt-3"
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.cyan.core}
/>
}
ListHeaderComponent={
<View className="flex-row justify-between items-center mb-3">
<Text className="text-lg font-semibold">Users</Text>
<Text className="text-sm text-muted-foreground">
{total} {total === 1 ? 'user' : 'users'}
</Text>
</View>
}
ListFooterComponent={
isFetchingNextPage ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" color={colors.cyan.core} />
</View>
) : null
}
ListEmptyComponent={
<View className="items-center py-12">
<View className="w-16 h-16 rounded-full bg-card border border-border items-center justify-center mb-4">
<Text className="text-2xl text-muted-foreground">0</Text>
</View>
<Text className="text-lg font-semibold mb-1">No Users</Text>
<Text className="text-sm text-muted-foreground text-center px-4">
Users will appear here after syncing with your media server
</Text>
</View>
}
/>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,71 @@
/**
* 404 Not Found screen
*/
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
export default function NotFoundScreen() {
const router = useRouter();
return (
<>
<Stack.Screen options={{ title: 'Page Not Found' }} />
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.errorCode}>404</Text>
<Text style={styles.title}>Page Not Found</Text>
<Text style={styles.subtitle}>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</Text>
<Pressable style={styles.button} onPress={() => router.replace('/')}>
<Text style={styles.buttonText}>Go Home</Text>
</Pressable>
</View>
</SafeAreaView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
},
content: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
errorCode: {
fontSize: 72,
fontWeight: 'bold',
color: colors.cyan.core,
marginBottom: spacing.md,
},
title: {
fontSize: typography.fontSize['2xl'],
fontWeight: 'bold',
color: colors.text.primary.dark,
marginBottom: spacing.sm,
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.text.secondary.dark,
textAlign: 'center',
marginBottom: spacing.xl,
},
button: {
backgroundColor: colors.cyan.core,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.md,
},
buttonText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.blue.core,
},
});

128
apps/mobile/app/_layout.tsx Normal file
View File

@@ -0,0 +1,128 @@
/**
* Root layout - handles auth state and navigation
*/
import { Buffer } from 'buffer';
global.Buffer = Buffer;
import '../global.css';
import { useEffect, useState } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { QueryProvider } from '@/providers/QueryProvider';
import { SocketProvider } from '@/providers/SocketProvider';
import { MediaServerProvider } from '@/providers/MediaServerProvider';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useAuthStore } from '@/lib/authStore';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { colors } from '@/lib/theme';
function RootLayoutNav() {
const { isAuthenticated, isLoading, initialize } = useAuthStore();
const segments = useSegments();
const router = useRouter();
const [hasInitialized, setHasInitialized] = useState(false);
// Initialize push notifications (only when authenticated)
usePushNotifications();
// Initialize auth state on mount
useEffect(() => {
void initialize().finally(() => setHasInitialized(true));
}, [initialize]);
// Handle navigation based on auth state
// Note: We allow authenticated users to access (auth)/pair for adding servers
// Wait for initialization to complete before redirecting (prevents hot reload issues)
useEffect(() => {
if (isLoading || !hasInitialized) return;
const inAuthGroup = segments[0] === '(auth)';
if (!isAuthenticated && !inAuthGroup) {
// Not authenticated and not on auth screen - redirect to pair
router.replace('/(auth)/pair');
}
// Don't redirect away from pair if authenticated - user might be adding a server
}, [isAuthenticated, isLoading, hasInitialized, segments, router]);
// Show loading screen while initializing
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.cyan.core} />
</View>
);
}
return (
<>
<StatusBar style="light" backgroundColor={colors.background.dark} translucent={false} />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background.dark },
animation: 'fade',
}}
>
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="user"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen
name="session"
options={{
headerShown: false,
presentation: 'card',
}}
/>
<Stack.Screen
name="settings"
options={{
headerShown: false,
presentation: 'modal',
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
</>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={styles.container}>
<SafeAreaProvider>
<ErrorBoundary>
<QueryProvider>
<SocketProvider>
<MediaServerProvider>
<RootLayoutNav />
</MediaServerProvider>
</SocketProvider>
</QueryProvider>
</ErrorBoundary>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.dark,
},
});

View File

@@ -0,0 +1,524 @@
/**
* Session detail screen
* Shows comprehensive information about a specific session/stream
* Query keys include selectedServerId for proper cache isolation per media server
*/
import { useLocalSearchParams, useRouter } from 'expo-router';
import { View, Text, ScrollView, Pressable, ActivityIndicator, Image, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { useState, useEffect } from 'react';
import {
Play,
Pause,
Square,
User,
Server,
MapPin,
Smartphone,
Clock,
Gauge,
Tv,
Film,
Music,
Zap,
Globe,
Wifi,
X,
} from 'lucide-react-native';
import { api, getServerUrl } from '@/lib/api';
import { useMediaServer } from '@/providers/MediaServerProvider';
import { colors } from '@/lib/theme';
import { Badge } from '@/components/ui/badge';
import type { SessionWithDetails, SessionState, MediaType } from '@tracearr/shared';
// 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 date helper
function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string {
const parsed = safeParseDate(date);
if (!parsed) return 'Unknown';
return format(parsed, formatStr);
}
// Get state icon, color, and badge variant
function getStateInfo(state: SessionState, watched?: boolean): {
icon: typeof Play;
color: string;
label: string;
variant: 'success' | 'warning' | 'secondary';
} {
// Show "Watched" for completed sessions where user watched 80%+
if (watched && state === 'stopped') {
return { icon: Play, color: colors.success, label: 'Watched', variant: 'success' };
}
switch (state) {
case 'playing':
return { icon: Play, color: colors.success, label: 'Playing', variant: 'success' };
case 'paused':
return { icon: Pause, color: colors.warning, label: 'Paused', variant: 'warning' };
case 'stopped':
return { icon: Square, color: colors.text.secondary.dark, label: 'Stopped', variant: 'secondary' };
default:
return { icon: Square, color: colors.text.secondary.dark, label: 'Unknown', variant: 'secondary' };
}
}
// Get media type icon
function getMediaIcon(mediaType: MediaType): typeof Film {
switch (mediaType) {
case 'movie':
return Film;
case 'episode':
return Tv;
case 'track':
return Music;
default:
return Film;
}
}
// Format duration
function formatDuration(ms: number | null): string {
if (ms === null) return '-';
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}
// Format bitrate
function formatBitrate(bitrate: number | null): string {
if (bitrate === null) return '-';
if (bitrate >= 1000) {
return `${(bitrate / 1000).toFixed(1)} Mbps`;
}
return `${bitrate} Kbps`;
}
// Info card component
function InfoCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="bg-card rounded-xl p-4 mb-4">
<Text className="text-muted-foreground text-sm font-medium mb-3">{title}</Text>
{children}
</View>
);
}
// Info row component
function InfoRow({
icon: Icon,
label,
value,
valueColor,
}: {
icon: typeof Play;
label: string;
value: string;
valueColor?: string;
}) {
return (
<View className="flex-row items-center py-2 border-b border-border last:border-b-0">
<Icon size={18} color={colors.text.secondary.dark} />
<Text className="text-muted-foreground text-sm ml-3 flex-1">{label}</Text>
<Text
className="text-sm font-medium"
style={{ color: valueColor || colors.text.primary.dark }}
>
{value}
</Text>
</View>
);
}
// Progress bar component
function ProgressBar({
progress,
total,
}: {
progress: number | null;
total: number | null;
}) {
if (progress === null || total === null || total === 0) {
return null;
}
const percentage = Math.min((progress / total) * 100, 100);
return (
<View style={{ marginTop: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(progress)}</Text>
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(total)}</Text>
</View>
<View style={{ backgroundColor: '#27272a', height: 8, borderRadius: 4, overflow: 'hidden' }}>
<View
style={{
backgroundColor: colors.cyan.core,
height: '100%',
borderRadius: 4,
width: `${percentage}%`
}}
/>
</View>
<Text style={{ color: '#71717a', fontSize: 12, textAlign: 'center', marginTop: 4 }}>
{percentage.toFixed(1)}% watched
</Text>
</View>
);
}
export default function SessionDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { selectedServerId } = useMediaServer();
const [serverUrl, setServerUrl] = useState<string | null>(null);
// Load server URL for image paths
useEffect(() => {
void getServerUrl().then(setServerUrl);
}, []);
// Terminate session mutation
const terminateMutation = useMutation({
mutationFn: ({ sessionId, reason }: { sessionId: string; reason?: string }) =>
api.sessions.terminate(sessionId, reason),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
Alert.alert('Stream Terminated', 'The playback session has been stopped.');
router.back();
},
onError: (error: Error) => {
Alert.alert('Failed to Terminate', error.message);
},
});
// Handle terminate button press
const handleTerminate = () => {
Alert.prompt(
'Terminate Stream',
'Enter an optional message to show the user (leave empty to skip):',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Terminate',
style: 'destructive',
onPress: (reason: string | undefined) => {
terminateMutation.mutate({ sessionId: id, reason: reason?.trim() || undefined });
},
},
],
'plain-text',
'',
'default'
);
};
const {
data: session,
isLoading,
error,
} = useQuery<SessionWithDetails>({
queryKey: ['session', id, selectedServerId],
queryFn: async () => {
console.log('[SessionDetail] Fetching session:', id);
try {
const result = await api.sessions.get(id);
console.log('[SessionDetail] Received session data:', JSON.stringify(result, null, 2));
return result;
} catch (err) {
console.error('[SessionDetail] API error:', err);
throw err;
}
},
enabled: !!id,
});
// Debug logging
useEffect(() => {
console.log('[SessionDetail] State:', { id, isLoading, hasError: !!error, hasSession: !!session });
if (error) {
console.error('[SessionDetail] Query error:', error);
}
if (session) {
console.log('[SessionDetail] Session fields:', {
id: session.id,
username: session.username,
mediaTitle: session.mediaTitle,
state: session.state,
});
}
}, [id, isLoading, error, session]);
if (isLoading) {
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center' }}
edges={['left', 'right', 'bottom']}
>
<ActivityIndicator size="large" color={colors.cyan.core} />
</SafeAreaView>
);
}
if (error || !session) {
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center', padding: 16 }}
edges={['left', 'right', 'bottom']}
>
<Text style={{ color: '#f87171', textAlign: 'center' }}>
{error instanceof Error ? error.message : 'Failed to load session'}
</Text>
</SafeAreaView>
);
}
const stateInfo = getStateInfo(session.state, session.watched);
const MediaIcon = getMediaIcon(session.mediaType);
// Format media title with episode info
const getMediaTitle = (): string => {
if (session.mediaType === 'episode' && session.grandparentTitle) {
const episodeInfo = session.seasonNumber && session.episodeNumber
? `S${session.seasonNumber}E${session.episodeNumber}`
: '';
return `${session.grandparentTitle}${episodeInfo ? `${episodeInfo}` : ''}`;
}
return session.mediaTitle;
};
const getSubtitle = (): string => {
if (session.mediaType === 'episode') {
return session.mediaTitle; // Episode title
}
if (session.year) {
return String(session.year);
}
return '';
};
// Get location string
const getLocation = (): string => {
const parts = [session.geoCity, session.geoRegion, session.geoCountry].filter(Boolean);
return parts.join(', ') || 'Unknown';
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
{/* Media Header */}
<View className="bg-card rounded-xl p-4 mb-4">
{/* Terminate button - top right */}
<View className="absolute top-2 right-2 z-10">
<Pressable
onPress={handleTerminate}
disabled={terminateMutation.isPending}
className="w-8 h-8 rounded-full bg-destructive/10 items-center justify-center active:opacity-70"
style={{ opacity: terminateMutation.isPending ? 0.5 : 1 }}
>
<X size={18} color="#ef4444" />
</Pressable>
</View>
<View className="flex-row items-start">
{/* Poster/Thumbnail */}
<View className="w-20 h-28 bg-surface rounded-lg mr-4 overflow-hidden">
{session.thumbPath && serverUrl ? (
<Image
source={{ uri: `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=160&height=224` }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
) : (
<View className="w-full h-full justify-center items-center">
<MediaIcon size={32} color={colors.text.secondary.dark} />
</View>
)}
</View>
{/* Media Info */}
<View className="flex-1">
<View className="flex-row items-center mb-2">
<Badge variant={stateInfo.variant}>
{stateInfo.label}
</Badge>
</View>
<Text className="text-white text-lg font-semibold" numberOfLines={2}>
{getMediaTitle()}
</Text>
{getSubtitle() ? (
<Text className="text-muted-foreground text-sm mt-1" numberOfLines={1}>
{getSubtitle()}
</Text>
) : null}
<View className="flex-row items-center mt-2">
<MediaIcon size={14} color={colors.text.secondary.dark} />
<Text className="text-muted-foreground text-xs ml-1 capitalize">
{session.mediaType}
</Text>
</View>
</View>
</View>
{/* Progress bar */}
<ProgressBar progress={session.progressMs} total={session.totalDurationMs} />
</View>
{/* User Card - Tappable */}
<Pressable
onPress={() => router.push(`/user/${session.serverUserId}` as never)}
className="bg-card rounded-xl p-4 mb-4 active:opacity-70"
>
<Text className="text-muted-foreground text-sm font-medium mb-3">User</Text>
<View className="flex-row items-center">
<View className="w-12 h-12 rounded-full bg-surface overflow-hidden">
{session.userThumb ? (
<Image
source={{ uri: session.userThumb }}
className="w-full h-full"
resizeMode="cover"
/>
) : (
<View className="w-full h-full justify-center items-center">
<User size={24} color={colors.text.secondary.dark} />
</View>
)}
</View>
<View className="flex-1 ml-3">
<Text className="text-foreground text-base font-semibold">
{session.username}
</Text>
<Text className="text-muted-foreground text-sm">Tap to view profile</Text>
</View>
<Text className="text-primary text-sm"></Text>
</View>
</Pressable>
{/* Server Info */}
<InfoCard title="Server">
<View className="flex-row items-center">
<Server size={20} color={colors.text.secondary.dark} />
<View className="flex-1 ml-3">
<Text className="text-foreground text-base font-medium">
{session.serverName}
</Text>
<Text className="text-muted-foreground text-sm capitalize">
{session.serverType}
</Text>
</View>
</View>
</InfoCard>
{/* Timing Info */}
<InfoCard title="Timing">
<InfoRow
icon={Clock}
label="Started"
value={safeFormatDate(session.startedAt, 'MMM d, yyyy h:mm a')}
/>
{session.stoppedAt && (
<InfoRow
icon={Square}
label="Stopped"
value={safeFormatDate(session.stoppedAt, 'MMM d, yyyy h:mm a')}
/>
)}
<InfoRow
icon={Play}
label="Watch Time"
value={formatDuration(session.durationMs)}
/>
{(session.pausedDurationMs ?? 0) > 0 && (
<InfoRow
icon={Pause}
label="Paused Time"
value={formatDuration(session.pausedDurationMs)}
/>
)}
</InfoCard>
{/* Location Info */}
<InfoCard title="Location">
<InfoRow icon={Globe} label="IP Address" value={session.ipAddress || 'Unknown'} />
<InfoRow icon={MapPin} label="Location" value={getLocation()} />
{session.geoLat && session.geoLon && (
<InfoRow
icon={MapPin}
label="Coordinates"
value={`${session.geoLat.toFixed(4)}, ${session.geoLon.toFixed(4)}`}
/>
)}
</InfoCard>
{/* Device Info */}
<InfoCard title="Device">
<InfoRow
icon={Smartphone}
label="Player"
value={session.playerName || 'Unknown'}
/>
<InfoRow
icon={Tv}
label="Device"
value={session.device || 'Unknown'}
/>
<InfoRow
icon={Wifi}
label="Platform"
value={session.platform || 'Unknown'}
/>
{session.product && (
<InfoRow icon={Smartphone} label="Product" value={session.product} />
)}
</InfoCard>
{/* Quality Info */}
<InfoCard title="Quality">
<InfoRow
icon={Gauge}
label="Quality"
value={session.quality || 'Unknown'}
/>
<InfoRow
icon={Zap}
label="Transcode"
value={session.isTranscode ? 'Yes' : 'Direct Play'}
valueColor={session.isTranscode ? colors.warning : colors.success}
/>
{session.bitrate && (
<InfoRow icon={Gauge} label="Bitrate" value={formatBitrate(session.bitrate)} />
)}
</InfoCard>
{/* Bottom padding */}
<View className="h-8" />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,43 @@
/**
* Session detail stack navigator layout
* Provides navigation for session detail screens
*/
import { Stack, useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '@/lib/theme';
export default function SessionLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: 'Back',
headerLeft: () => (
<Pressable onPress={() => router.back()} hitSlop={8}>
<ChevronLeft size={28} color={colors.text.primary.dark} />
</Pressable>
),
contentStyle: {
backgroundColor: colors.background.dark,
},
}}
>
<Stack.Screen
name="[id]"
options={{
title: 'Session',
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,43 @@
/**
* Settings stack navigator layout
* Provides navigation between settings sub-screens
*/
import { Stack, useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '@/lib/theme';
export default function SettingsLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: 'Back',
headerLeft: () => (
<Pressable onPress={() => router.back()} hitSlop={8}>
<ChevronLeft size={28} color={colors.text.primary.dark} />
</Pressable>
),
contentStyle: {
backgroundColor: colors.background.dark,
},
}}
>
<Stack.Screen
name="notifications"
options={{
title: 'Notification Settings',
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,538 @@
/**
* Notification Settings Screen
* Per-device push notification configuration
*/
import { View, ScrollView, Switch, Pressable, ActivityIndicator, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Bell,
ShieldAlert,
Play,
Square,
Monitor,
Smartphone,
AlertTriangle,
ServerCrash,
ServerCog,
Moon,
Flame,
type LucideIcon,
} from 'lucide-react-native';
import { Text } from '@/components/ui/text';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { useAuthStore } from '@/lib/authStore';
import { colors } from '@/lib/theme';
import type { NotificationPreferences } from '@tracearr/shared';
// Rule types for violation filtering
const RULE_TYPES = [
{ value: 'impossible_travel', label: 'Impossible Travel' },
{ value: 'simultaneous_locations', label: 'Simultaneous Locations' },
{ value: 'device_velocity', label: 'Device Velocity' },
{ value: 'concurrent_streams', label: 'Concurrent Streams' },
{ value: 'geo_restriction', label: 'Geo Restriction' },
] as const;
// Severity levels
const SEVERITY_LEVELS = [
{ value: 1, label: 'All (Low, Warning, High)' },
{ value: 2, label: 'Warning & High only' },
{ value: 3, label: 'High severity only' },
] as const;
function Divider() {
return <View className="h-px bg-border ml-4" />;
}
function SettingsSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-6">
<Text className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{title}
</Text>
<Card className="p-0 overflow-hidden">{children}</Card>
</View>
);
}
function SettingRow({
icon: Icon,
label,
description,
value,
onValueChange,
disabled,
}: {
icon?: LucideIcon;
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}) {
return (
<View className="flex-row justify-between items-center px-4 py-3 min-h-[52px]">
<View className="flex-1 mr-4">
<View className="flex-row items-center">
{Icon && (
<Icon
size={18}
color={disabled ? colors.text.muted.dark : colors.text.secondary.dark}
style={{ marginRight: 10 }}
/>
)}
<Text className={cn('text-base', disabled && 'opacity-50')}>{label}</Text>
</View>
{description && (
<Text className={cn('text-xs text-muted-foreground mt-0.5', Icon && 'ml-7', disabled && 'opacity-50')}>
{description}
</Text>
)}
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.switch.trackOff, true: colors.switch.trackOn }}
thumbColor={value ? colors.switch.thumbOn : colors.switch.thumbOff}
/>
</View>
);
}
function SelectRow({
label,
value,
options,
onChange,
disabled,
}: {
label: string;
value: number;
options: ReadonlyArray<{ value: number; label: string }>;
onChange: (value: number) => void;
disabled?: boolean;
}) {
const currentOption = options.find((o) => o.value === value);
const handlePress = () => {
if (disabled) return;
Alert.alert(
label,
undefined,
options.map((option) => ({
text: option.label,
onPress: () => onChange(option.value),
style: option.value === value ? 'cancel' : 'default',
}))
);
};
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={cn('px-4 py-3 min-h-[52px]', 'active:opacity-70')}
>
<Text className={cn('text-sm text-muted-foreground mb-1', disabled && 'opacity-50')}>
{label}
</Text>
<Text className={cn('text-base', disabled && 'opacity-50')}>
{currentOption?.label ?? 'Select...'}
</Text>
</Pressable>
);
}
function MultiSelectRow({
selectedValues,
options,
onChange,
disabled,
}: {
selectedValues: string[];
options: ReadonlyArray<{ value: string; label: string }>;
onChange: (values: string[]) => void;
disabled?: boolean;
}) {
const toggleValue = (value: string) => {
if (disabled) return;
if (selectedValues.includes(value)) {
onChange(selectedValues.filter((v) => v !== value));
} else {
onChange([...selectedValues, value]);
}
};
const allSelected = selectedValues.length === 0;
return (
<View className="px-4 py-3">
<View className="flex-row flex-wrap gap-2.5">
<Pressable
onPress={() => onChange([])}
disabled={disabled}
className={cn(
'px-3 py-1.5 rounded-full border',
allSelected
? 'bg-cyan-core border-cyan-core'
: 'border-border bg-card',
disabled && 'opacity-50'
)}
>
<Text
className={cn(
'text-sm',
allSelected ? 'text-background' : 'text-foreground'
)}
>
All Types
</Text>
</Pressable>
{options.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<Pressable
key={option.value}
onPress={() => toggleValue(option.value)}
disabled={disabled}
className={cn(
'px-3 py-1.5 rounded-full border',
isSelected
? 'bg-cyan-core border-cyan-core'
: 'border-border bg-card',
disabled && 'opacity-50'
)}
>
<Text
className={cn(
'text-sm',
isSelected ? 'text-background' : 'text-foreground'
)}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
function RateLimitStatus({
remainingMinute,
remainingHour,
maxPerMinute,
maxPerHour,
}: {
remainingMinute?: number;
remainingHour?: number;
maxPerMinute: number;
maxPerHour: number;
}) {
return (
<View className="px-4 py-3">
<Text className="text-sm text-muted-foreground mb-2">Current Rate Limit Status</Text>
<View className="flex-row gap-4">
<View className="flex-1 p-3 rounded-lg bg-surface">
<Text className="text-xs text-muted-foreground mb-1">Per Minute</Text>
<Text className="text-lg font-semibold">
{remainingMinute ?? maxPerMinute} / {maxPerMinute}
</Text>
</View>
<View className="flex-1 p-3 rounded-lg bg-surface">
<Text className="text-xs text-muted-foreground mb-1">Per Hour</Text>
<Text className="text-lg font-semibold">
{remainingHour ?? maxPerHour} / {maxPerHour}
</Text>
</View>
</View>
</View>
);
}
export default function NotificationSettingsScreen() {
const queryClient = useQueryClient();
const { activeServerId } = useAuthStore();
// Fetch current preferences (per-device, not per-server)
const {
data: preferences,
isLoading,
error,
} = useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: api.notifications.getPreferences,
enabled: !!activeServerId, // Still need auth
});
// Update mutation with optimistic updates
const updateMutation = useMutation({
mutationFn: api.notifications.updatePreferences,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['notifications', 'preferences'] });
const previousData = queryClient.getQueryData<NotificationPreferences>([
'notifications',
'preferences',
]);
queryClient.setQueryData(['notifications', 'preferences'], (old: NotificationPreferences | undefined) =>
old ? { ...old, ...newData } : old
);
return { previousData };
},
onError: (_err, _newData, context) => {
if (context?.previousData) {
queryClient.setQueryData(['notifications', 'preferences'], context.previousData);
}
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
});
// Test notification mutation
const testMutation = useMutation({
mutationFn: api.notifications.sendTest,
onSuccess: (result) => {
Alert.alert(
result.success ? 'Test Sent' : 'Test Failed',
result.message
);
},
onError: (error: Error) => {
Alert.alert('Error', error.message || 'Failed to send test notification');
},
});
const handleUpdate = (
key: keyof Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>,
value: boolean | number | string[]
) => {
updateMutation.mutate({ [key]: value });
};
if (isLoading) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={colors.cyan.core} />
<Text className="mt-4 text-muted-foreground">Loading preferences...</Text>
</View>
</SafeAreaView>
);
}
if (error || !preferences) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-xl font-semibold text-center mb-2">
Unable to Load Preferences
</Text>
<Text className="text-muted-foreground text-center">
{error instanceof Error ? error.message : 'An error occurred'}
</Text>
</View>
</SafeAreaView>
);
}
const pushEnabled = preferences.pushEnabled;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView className="flex-1" contentContainerClassName="p-4">
{/* Master Toggle */}
<SettingsSection title="Push Notifications">
<SettingRow
icon={Bell}
label="Enable Push Notifications"
description="Receive alerts on this device"
value={pushEnabled}
onValueChange={(v) => handleUpdate('pushEnabled', v)}
/>
</SettingsSection>
{/* Event Toggles */}
<SettingsSection title="Notification Events">
<SettingRow
icon={ShieldAlert}
label="Violation Detected"
description="Rule violation triggered"
value={preferences.onViolationDetected}
onValueChange={(v) => handleUpdate('onViolationDetected', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Play}
label="Stream Started"
description="New playback began"
value={preferences.onStreamStarted}
onValueChange={(v) => handleUpdate('onStreamStarted', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Square}
label="Stream Stopped"
description="Playback ended"
value={preferences.onStreamStopped}
onValueChange={(v) => handleUpdate('onStreamStopped', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Monitor}
label="Concurrent Streams"
description="User exceeded stream limit"
value={preferences.onConcurrentStreams}
onValueChange={(v) => handleUpdate('onConcurrentStreams', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={Smartphone}
label="New Device"
description="New device detected for user"
value={preferences.onNewDevice}
onValueChange={(v) => handleUpdate('onNewDevice', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={AlertTriangle}
label="Trust Score Changed"
description="User trust score degraded"
value={preferences.onTrustScoreChanged}
onValueChange={(v) => handleUpdate('onTrustScoreChanged', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={ServerCrash}
label="Server Down"
description="Media server unreachable"
value={preferences.onServerDown}
onValueChange={(v) => handleUpdate('onServerDown', v)}
disabled={!pushEnabled}
/>
<Divider />
<SettingRow
icon={ServerCog}
label="Server Up"
description="Media server back online"
value={preferences.onServerUp}
onValueChange={(v) => handleUpdate('onServerUp', v)}
disabled={!pushEnabled}
/>
</SettingsSection>
{/* Violation Filters - Only show if violation notifications are enabled */}
{pushEnabled && preferences.onViolationDetected && (
<SettingsSection title="Violation Filters">
<MultiSelectRow
selectedValues={preferences.violationRuleTypes}
options={RULE_TYPES}
onChange={(values) => handleUpdate('violationRuleTypes', values)}
/>
<Divider />
<SelectRow
label="Minimum Severity"
value={preferences.violationMinSeverity}
options={SEVERITY_LEVELS}
onChange={(value) => handleUpdate('violationMinSeverity', value)}
/>
</SettingsSection>
)}
{/* Quiet Hours */}
<SettingsSection title="Quiet Hours">
<SettingRow
icon={Moon}
label="Enable Quiet Hours"
description="Pause non-critical notifications during set hours"
value={preferences.quietHoursEnabled}
onValueChange={(v) => handleUpdate('quietHoursEnabled', v)}
disabled={!pushEnabled}
/>
{pushEnabled && preferences.quietHoursEnabled && (
<>
<Divider />
<View className="px-4 py-3">
<View className="flex-row justify-between items-center">
<View>
<Text className="text-sm text-muted-foreground">Start Time</Text>
<Text className="text-base">{preferences.quietHoursStart ?? '23:00'}</Text>
</View>
<Text className="text-muted-foreground mx-4">to</Text>
<View>
<Text className="text-sm text-muted-foreground">End Time</Text>
<Text className="text-base">{preferences.quietHoursEnd ?? '08:00'}</Text>
</View>
</View>
<Text className="text-xs text-muted-foreground mt-2">
Timezone: {preferences.quietHoursTimezone || 'UTC'}
</Text>
</View>
<Divider />
<SettingRow
icon={Flame}
label="Override for Critical"
description="High-severity violations still notify during quiet hours"
value={preferences.quietHoursOverrideCritical}
onValueChange={(v) => handleUpdate('quietHoursOverrideCritical', v)}
/>
</>
)}
</SettingsSection>
{/* Rate Limiting */}
<SettingsSection title="Rate Limiting">
<RateLimitStatus
remainingMinute={preferences.rateLimitStatus?.remainingMinute}
remainingHour={preferences.rateLimitStatus?.remainingHour}
maxPerMinute={preferences.maxPerMinute}
maxPerHour={preferences.maxPerHour}
/>
<Divider />
<View className="px-4 py-2">
<Text className="text-xs text-muted-foreground leading-4">
Rate limits prevent notification spam. Current limits: {preferences.maxPerMinute}/min, {preferences.maxPerHour}/hour.
</Text>
</View>
</SettingsSection>
{/* Test Notification */}
<View className="mt-2 mb-4">
<Button
onPress={() => testMutation.mutate()}
disabled={!pushEnabled || testMutation.isPending}
className={cn(!pushEnabled && 'opacity-50')}
>
{testMutation.isPending ? (
<ActivityIndicator size="small" color={colors.background.dark} />
) : (
<Text className="text-background font-semibold">Send Test Notification</Text>
)}
</Button>
<Text className="text-xs text-muted-foreground text-center mt-2">
Verify that push notifications are working correctly
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,815 @@
/**
* 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<RuleType, LucideIcon> = {
impossible_travel: MapPin,
simultaneous_locations: Users,
device_velocity: Zap,
concurrent_streams: Monitor,
geo_restriction: Globe,
};
// Rule type display names
const ruleLabels: Record<RuleType, string> = {
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 (
<View className="flex-row items-center gap-2">
<View
className={cn(
'px-2.5 py-1 rounded-md min-w-[45px] items-center',
variant === 'destructive' && 'bg-destructive/20',
variant === 'warning' && 'bg-warning/20',
variant === 'success' && 'bg-success/20'
)}
>
<Text
className={cn(
'text-base font-bold',
variant === 'destructive' && 'text-destructive',
variant === 'warning' && 'text-warning',
variant === 'success' && 'text-success'
)}
>
{score}
</Text>
</View>
{showLabel && (
<Text className="text-sm text-muted-foreground">{label} Trust</Text>
)}
</View>
);
}
function StatCard({ icon: Icon, label, value, subValue }: {
icon: LucideIcon;
label: string;
value: string | number;
subValue?: string;
}) {
return (
<View className="flex-1 bg-surface rounded-lg p-3 border border-border">
<View className="flex-row items-center gap-2 mb-1">
<Icon size={14} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{label}</Text>
</View>
<Text className="text-xl font-bold">{value}</Text>
{subValue && <Text className="text-xs text-muted-foreground mt-0.5">{subValue}</Text>}
</View>
);
}
function SeverityBadge({ severity }: { severity: string }) {
const variant =
severity === 'critical' || severity === 'high'
? 'destructive'
: severity === 'warning'
? 'warning'
: 'default';
return (
<Badge variant={variant} className="capitalize">
{severity}
</Badge>
);
}
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 (
<View className="flex-row items-center gap-3 py-3 border-b border-border">
<View className="w-8 h-8 rounded-full bg-cyan-core/10 items-center justify-center">
<MapPin size={16} color={colors.cyan.core} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium">{locationText}</Text>
<Text className="text-xs text-muted-foreground">
{location.sessionCount} {location.sessionCount === 1 ? 'session' : 'sessions'}
{' • '}
{safeFormatDistanceToNow(location.lastSeenAt)}
</Text>
</View>
</View>
);
}
function DeviceCard({ device }: { device: UserDevice }) {
const deviceName = device.playerName || device.device || device.product || 'Unknown Device';
const platform = device.platform || 'Unknown Platform';
return (
<View className="flex-row items-center gap-3 py-3 border-b border-border">
<View className="w-8 h-8 rounded-full bg-cyan-core/10 items-center justify-center">
<Smartphone size={16} color={colors.cyan.core} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium">{deviceName}</Text>
<Text className="text-xs text-muted-foreground">
{platform} {device.sessionCount} {device.sessionCount === 1 ? 'session' : 'sessions'}
</Text>
<Text className="text-xs text-muted-foreground">
Last seen {safeFormatDistanceToNow(device.lastSeenAt)}
</Text>
</View>
</View>
);
}
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 (
<Pressable onPress={onPress} className="py-3 border-b border-border active:opacity-70">
<View className="flex-row">
{/* Poster */}
<View className="w-10 h-14 rounded-md bg-surface overflow-hidden mr-3">
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
) : (
<View className="w-full h-full items-center justify-center">
<MediaIcon size={18} color={colors.text.muted.dark} />
</View>
)}
</View>
{/* Content */}
<View className="flex-1">
<View className="flex-row justify-between items-start mb-1">
<View className="flex-1 mr-2">
<Text className="text-sm font-medium" numberOfLines={1}>
{session.mediaTitle}
</Text>
<Text className="text-xs text-muted-foreground capitalize">{session.mediaType}</Text>
</View>
<Badge variant={displayState.variant}>
{displayState.label}
</Badge>
</View>
<View className="flex-row items-center gap-4 mt-1">
<View className="flex-row items-center gap-1">
<Clock size={12} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{formatDuration(session.durationMs)}</Text>
</View>
<View className="flex-row items-center gap-1">
<Tv size={12} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{session.platform || 'Unknown'}</Text>
</View>
{locationText && (
<View className="flex-row items-center gap-1">
<Globe size={12} color={colors.text.muted.dark} />
<Text className="text-xs text-muted-foreground">{locationText}</Text>
</View>
)}
</View>
</View>
</View>
</Pressable>
);
}
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 (
<View className="py-3 border-b border-border">
<View className="flex-row justify-between items-start mb-2">
<View className="flex-row items-center gap-2 flex-1">
<View className="w-7 h-7 rounded-md bg-surface items-center justify-center">
<IconComponent size={14} color={colors.cyan.core} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium">{ruleName}</Text>
<Text className="text-xs text-muted-foreground">{timeAgo}</Text>
</View>
</View>
<SeverityBadge severity={violation.severity} />
</View>
{!violation.acknowledgedAt ? (
<Pressable
className="flex-row items-center justify-center gap-1.5 bg-cyan-core/15 py-2 rounded-md mt-2 active:opacity-70"
onPress={onAcknowledge}
>
<Check size={14} color={colors.cyan.core} />
<Text className="text-xs font-semibold text-cyan-core">Acknowledge</Text>
</Pressable>
) : (
<View className="flex-row items-center gap-1.5 mt-2">
<Check size={14} color={colors.success} />
<Text className="text-xs text-success">Acknowledged</Text>
</View>
)}
</View>
);
}
function TerminationCard({ termination }: { termination: TerminationLogWithDetails }) {
const timeAgo = safeFormatDistanceToNow(termination.createdAt);
const isManual = termination.trigger === 'manual';
return (
<View className="py-3 border-b border-border">
<View className="flex-row justify-between items-start mb-2">
<View className="flex-row items-center gap-2 flex-1">
<View className="w-7 h-7 rounded-md bg-surface items-center justify-center">
{isManual ? (
<User size={14} color={colors.cyan.core} />
) : (
<Bot size={14} color={colors.cyan.core} />
)}
</View>
<View className="flex-1">
<Text className="text-sm font-medium" numberOfLines={1}>
{termination.mediaTitle ?? 'Unknown Media'}
</Text>
<Text className="text-xs text-muted-foreground capitalize">
{termination.mediaType ?? 'unknown'} {timeAgo}
</Text>
</View>
</View>
<Badge variant={isManual ? 'default' : 'secondary'}>
{isManual ? 'Manual' : 'Rule'}
</Badge>
</View>
<View className="ml-9">
<Text className="text-xs text-muted-foreground">
{isManual
? `By @${termination.triggeredByUsername ?? 'Unknown'}`
: termination.ruleName ?? 'Unknown rule'}
</Text>
{termination.reason && (
<Text className="text-xs text-muted-foreground mt-1" numberOfLines={2}>
Reason: {termination.reason}
</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
{termination.success ? (
<>
<Check size={12} color={colors.success} />
<Text className="text-xs text-success">Success</Text>
</>
) : (
<>
<XCircle size={12} color={colors.error} />
<Text className="text-xs text-destructive">Failed</Text>
</>
)}
</View>
</View>
</View>
);
}
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<string | null>(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 (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={colors.cyan.core} />
</View>
</SafeAreaView>
);
}
if (!user) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-xl font-semibold text-center mb-2">User Not Found</Text>
<Text className="text-muted-foreground text-center">This user may have been removed.</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
<ScrollView
className="flex-1"
contentContainerClassName="p-4"
refreshControl={
<RefreshControl
refreshing={userRefetching}
onRefresh={handleRefresh}
tintColor={colors.cyan.core}
/>
}
>
{/* User Info Card */}
<Card className="mb-4">
<View className="flex-row items-start gap-4">
<UserAvatar
thumbUrl={user.thumbUrl}
username={user.username}
size={64}
/>
<View className="flex-1">
<View className="flex-row items-center gap-2 mb-1">
<Text className="text-xl font-bold">{user.username}</Text>
{user.role === 'owner' && (
<Crown size={18} color={colors.warning} />
)}
</View>
{user.email && (
<Text className="text-sm text-muted-foreground mb-2">{user.email}</Text>
)}
<TrustScoreBadge score={user.trustScore} showLabel />
</View>
</View>
</Card>
{/* Stats Grid */}
<View className="flex-row gap-3 mb-4">
<StatCard
icon={Play}
label="Sessions"
value={totalSessions}
/>
<StatCard
icon={AlertTriangle}
label="Violations"
value={totalViolations}
/>
</View>
<View className="flex-row gap-3 mb-4">
<StatCard
icon={Clock}
label="Joined"
value={safeFormatDate(user.createdAt, 'MMM d, yyyy')}
/>
<StatCard
icon={Globe}
label="Locations"
value={locations?.length || 0}
/>
</View>
{/* Locations */}
<Card className="mb-4">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Locations</CardTitle>
<Text className="text-xs text-muted-foreground">
{locations?.length || 0} {locations?.length === 1 ? 'location' : 'locations'}
</Text>
</View>
</CardHeader>
<CardContent>
{locationsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : locations && locations.length > 0 ? (
locations.slice(0, 5).map((location, index) => (
<LocationCard key={`${location.city}-${location.country}-${index}`} location={location} />
))
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">No locations recorded</Text>
)}
{locations && locations.length > 5 && (
<View className="pt-3 items-center">
<Text className="text-xs text-muted-foreground">
+{locations.length - 5} more locations
</Text>
</View>
)}
</CardContent>
</Card>
{/* Devices */}
<Card className="mb-4">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Devices</CardTitle>
<Text className="text-xs text-muted-foreground">
{devices?.length || 0} {devices?.length === 1 ? 'device' : 'devices'}
</Text>
</View>
</CardHeader>
<CardContent>
{devicesLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : devices && devices.length > 0 ? (
devices.slice(0, 5).map((device, index) => (
<DeviceCard key={device.deviceId || index} device={device} />
))
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">No devices recorded</Text>
)}
{devices && devices.length > 5 && (
<View className="pt-3 items-center">
<Text className="text-xs text-muted-foreground">
+{devices.length - 5} more devices
</Text>
</View>
)}
</CardContent>
</Card>
{/* Recent Sessions */}
<Card className="mb-4">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Recent Sessions</CardTitle>
<Text className="text-xs text-muted-foreground">{totalSessions} total</Text>
</View>
</CardHeader>
<CardContent>
{sessionsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : sessions.length > 0 ? (
<>
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
serverUrl={serverUrl}
onPress={() => handleSessionPress(session)}
/>
))}
{hasMoreSessions && (
<Pressable
className="py-3 items-center active:opacity-70"
onPress={() => void fetchMoreSessions()}
disabled={fetchingMoreSessions}
>
{fetchingMoreSessions ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<View className="flex-row items-center gap-1">
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
<ChevronRight size={16} color={colors.cyan.core} />
</View>
)}
</Pressable>
)}
</>
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">No sessions found</Text>
)}
</CardContent>
</Card>
{/* Violations */}
<Card className="mb-8">
<CardHeader>
<View className="flex-row justify-between items-center">
<CardTitle>Violations</CardTitle>
<Text className="text-xs text-muted-foreground">{totalViolations} total</Text>
</View>
</CardHeader>
<CardContent>
{violationsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : violations.length > 0 ? (
<>
{violations.map((violation) => (
<ViolationCard
key={violation.id}
violation={violation}
onAcknowledge={() => acknowledgeMutation.mutate(violation.id)}
/>
))}
{hasMoreViolations && (
<Pressable
className="py-3 items-center active:opacity-70"
onPress={() => void fetchMoreViolations()}
disabled={fetchingMoreViolations}
>
{fetchingMoreViolations ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<View className="flex-row items-center gap-1">
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
<ChevronRight size={16} color={colors.cyan.core} />
</View>
)}
</Pressable>
)}
</>
) : (
<View className="py-4 items-center">
<View className="w-12 h-12 rounded-full bg-success/10 items-center justify-center mb-2">
<Check size={24} color={colors.success} />
</View>
<Text className="text-sm text-muted-foreground">No violations</Text>
</View>
)}
</CardContent>
</Card>
{/* Termination History */}
<Card className="mb-8">
<CardHeader>
<View className="flex-row justify-between items-center">
<View className="flex-row items-center gap-2">
<XCircle size={18} color={colors.text.primary.dark} />
<CardTitle>Termination History</CardTitle>
</View>
<Text className="text-xs text-muted-foreground">{totalTerminations} total</Text>
</View>
</CardHeader>
<CardContent>
{terminationsLoading ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : terminations.length > 0 ? (
<>
{terminations.map((termination) => (
<TerminationCard key={termination.id} termination={termination} />
))}
{hasMoreTerminations && (
<Pressable
className="py-3 items-center active:opacity-70"
onPress={() => void fetchMoreTerminations()}
disabled={fetchingMoreTerminations}
>
{fetchingMoreTerminations ? (
<ActivityIndicator size="small" color={colors.cyan.core} />
) : (
<View className="flex-row items-center gap-1">
<Text className="text-sm text-cyan-core font-medium">Load More</Text>
<ChevronRight size={16} color={colors.cyan.core} />
</View>
)}
</Pressable>
)}
</>
) : (
<Text className="text-sm text-muted-foreground py-4 text-center">
No stream terminations
</Text>
)}
</CardContent>
</Card>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -0,0 +1,43 @@
/**
* User detail stack navigator layout
* Provides navigation for user detail screens
*/
import { Stack, useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { ChevronLeft } from 'lucide-react-native';
import { colors } from '@/lib/theme';
export default function UserLayout() {
const router = useRouter();
return (
<Stack
screenOptions={{
headerShown: true,
headerStyle: {
backgroundColor: colors.background.dark,
},
headerTintColor: colors.text.primary.dark,
headerTitleStyle: {
fontWeight: '600',
},
headerBackTitle: 'Back',
headerLeft: () => (
<Pressable onPress={() => router.back()} hitSlop={8}>
<ChevronLeft size={28} color={colors.text.primary.dark} />
</Pressable>
),
contentStyle: {
backgroundColor: colors.background.dark,
},
}}
>
<Stack.Screen
name="[id]"
options={{
title: 'User',
}}
/>
</Stack>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,19 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
root: ['.'],
alias: {
'@': './src',
},
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
},
],
'react-native-reanimated/plugin',
],
};
};

76
apps/mobile/eas.json Normal file
View File

@@ -0,0 +1,76 @@
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": false
},
"android": {
"buildType": "apk"
}
},
"preview": {
"ios": {
"simulator": false,
"autoIncrement": true
},
"android": {
"buildType": "app-bundle"
},
"env": {
"EXPO_PUBLIC_API_URL": "https://tracearr.example.com"
},
"channel": "preview"
},
"preview-apk": {
"distribution": "internal",
"android": {
"buildType": "apk"
},
"channel": "preview"
},
"production": {
"ios": {
"resourceClass": "m-medium"
},
"android": {
"buildType": "app-bundle"
},
"env": {
"EXPO_PUBLIC_API_URL": "https://tracearr.example.com"
},
"channel": "production",
"autoIncrement": true
}
},
"submit": {
"preview": {
"ios": {
"appleId": "connor.gallopo@me.com",
"ascAppId": "6755941553",
"appleTeamId": "6DA3FJF5G5"
},
"android": {
"serviceAccountKeyPath": "./credentials/google-service-account.json",
"track": "internal",
"releaseStatus": "draft"
}
},
"production": {
"ios": {
"appleId": "connor.gallopo@me.com",
"ascAppId": "6755941553",
"appleTeamId": "6DA3FJF5G5"
},
"android": {
"serviceAccountKeyPath": "./credentials/google-service-account.json",
"track": "production"
}
}
}
}

62
apps/mobile/global.css Normal file
View File

@@ -0,0 +1,62 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";
/* Tracearr brand colors - matching web dark mode exactly */
@theme {
/* Brand colors */
--color-cyan-core: #18D1E7;
--color-cyan-deep: #0EAFC8;
--color-cyan-dark: #0A7C96;
--color-blue-core: #0B1A2E;
--color-blue-steel: #162840;
--color-blue-soft: #1E3A5C;
/* Background colors - matching web dark mode exactly */
--color-background: #050A12;
--color-card: #0B1A2E;
--color-card-foreground: #FFFFFF;
--color-surface: #0F2338;
--color-popover: #162840;
--color-popover-foreground: #FFFFFF;
/* Text colors */
--color-foreground: #FFFFFF;
--color-muted: #162840;
--color-muted-foreground: #94A3B8;
/* Primary (cyan) */
--color-primary: #18D1E7;
--color-primary-foreground: #0B1A2E;
/* Secondary */
--color-secondary: #162840;
--color-secondary-foreground: #FFFFFF;
/* Accent (same as primary) */
--color-accent: #18D1E7;
--color-accent-foreground: #0B1A2E;
/* Form inputs */
--color-input: #162840;
--color-ring: #18D1E7;
--color-border: #162840;
/* Semantic colors */
--color-destructive: #EF4444;
--color-destructive-foreground: #FFFFFF;
--color-success: #22C55E;
--color-warning: #F59E0B;
--color-danger: #EF4444;
/* Icon colors */
--color-icon: #8CA3B8;
--color-icon-active: #18D1E7;
--color-icon-danger: #FF4C4C;
/* Border radius */
--radius-lg: 0.5rem;
--radius-md: calc(var(--radius-lg) - 2px);
--radius-sm: calc(var(--radius-lg) - 4px);
}

View File

@@ -0,0 +1,38 @@
const { getDefaultConfig } = require('expo/metro-config');
const { withNativewind } = require('nativewind/metro');
const path = require('path');
// Find the project and workspace directories
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo (include default watchFolders)
config.watchFolders = [...(config.watchFolders || []), monorepoRoot];
// 2. Let Metro know where to resolve packages from
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
// 3. Enable symlink support for pnpm
config.resolver.unstable_enableSymlinks = true;
// 4. Handle .js imports that should resolve to .ts files (NodeNext compatibility)
// TypeScript with moduleResolution: NodeNext requires .js extensions in imports
// even for .ts source files. Metro needs help resolving these correctly.
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName.startsWith('.') && moduleName.endsWith('.js')) {
const tsModuleName = moduleName.replace(/\.js$/, '.ts');
try {
return context.resolveRequest(context, tsModuleName, platform);
} catch {
// Fall through to default resolution if .ts doesn't exist
}
}
return context.resolveRequest(context, moduleName, platform);
};
module.exports = withNativewind(config, { input: './global.css' });

4
apps/mobile/nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="react-native-css/types" />
// NOTE: This file should not be edited and should be committed with your source code.
// It is generated by react-native-css. If you need to move or disable this file,
// please see the documentation.

86
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "@tracearr/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"dev": "expo start",
"lint": "eslint . --ext .ts,.tsx --ignore-pattern '*.config.*' --ignore-pattern '.expo/'",
"typecheck": "tsc --noEmit",
"build:dev": "eas build --profile development --platform all",
"build:dev:ios": "eas build --profile development --platform ios",
"build:dev:android": "eas build --profile development --platform android",
"build:preview": "eas build --profile preview --platform all",
"build:preview:ios": "eas build --profile preview --platform ios",
"build:preview:android": "eas build --profile preview --platform android",
"build:prod": "eas build --profile production --platform all",
"build:prod:ios": "eas build --profile production --platform ios",
"build:prod:android": "eas build --profile production --platform android",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android",
"update": "eas update"
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/native": "7.1.22",
"@shopify/react-native-skia": "^2.2.12",
"@tanstack/react-query": "5.60.6",
"@tracearr/shared": "workspace:*",
"axios": "^1.12.0",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"expo": "54.0.25",
"expo-camera": "17.0.9",
"expo-constants": "18.0.10",
"expo-dev-client": "~6.0.18",
"expo-device": "8.0.9",
"expo-font": "^14.0.9",
"expo-image": "3.0.10",
"expo-linking": "8.0.9",
"expo-maps": "^0.12.8",
"expo-notifications": "0.32.13",
"expo-router": "6.0.15",
"expo-secure-store": "15.0.7",
"expo-splash-screen": "31.0.11",
"expo-status-bar": "3.0.8",
"expo-system-ui": "6.0.8",
"expo-task-manager": "~14.0.8",
"expo-updates": "~29.0.13",
"expo-web-browser": "15.0.9",
"lucide-react-native": "^0.555.0",
"nativewind": "5.0.0-preview.2",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-css": "^3.0.1",
"react-native-gesture-handler": "2.28.0",
"react-native-quick-crypto": "^1.0.0",
"react-native-reanimated": "4.1.5",
"react-native-safe-area-context": "5.6.2",
"react-native-screens": "4.16.0",
"react-native-svg": "15.15.0",
"react-native-worklets": "0.5.1",
"socket.io-client": "4.8.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^4.1.17",
"victory-native": "41.20.2",
"zustand": "5.0.2"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@tailwindcss/postcss": "^4.1.17",
"@types/react": "19.1.17",
"babel-plugin-module-resolver": "5.0.2",
"babel-preset-expo": "^54.0.7",
"postcss": "^8.5.6",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,26 @@
const { withGradleProperties } = require('expo/config-plugins');
/**
* Config plugin to customize Android gradle.properties
* Used to set JVM memory args for builds with many native dependencies
*/
module.exports = function withCustomGradleProperties(config, props) {
return withGradleProperties(config, (config) => {
for (const [key, value] of Object.entries(props)) {
const existingIndex = config.modResults.findIndex(
(p) => p.type === 'property' && p.key === key
);
if (existingIndex !== -1) {
config.modResults[existingIndex].value = value;
} else {
config.modResults.push({
type: 'property',
key,
value,
});
}
}
return config;
});
};

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,163 @@
/**
* Error Boundary component for catching and displaying React errors
*/
import React, { Component, type ReactNode } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { AlertTriangle, RefreshCw } from 'lucide-react-native';
import { colors } from '@/lib/theme';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({ errorInfo });
// Log error for debugging
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error handler
this.props.onError?.(error, errorInfo);
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render(): ReactNode {
if (this.state.hasError) {
// Custom fallback provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return (
<View style={styles.container}>
<View style={styles.content}>
<AlertTriangle size={48} color={colors.error} strokeWidth={2} />
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>
An unexpected error occurred. Please try again.
</Text>
{__DEV__ && this.state.error && (
<ScrollView style={styles.errorContainer}>
<Text style={styles.errorTitle}>Error Details:</Text>
<Text style={styles.errorText}>{this.state.error.message}</Text>
{this.state.errorInfo?.componentStack && (
<>
<Text style={styles.errorTitle}>Component Stack:</Text>
<Text style={styles.stackText}>
{this.state.errorInfo.componentStack}
</Text>
</>
)}
</ScrollView>
)}
<TouchableOpacity style={styles.button} onPress={this.handleReset}>
<RefreshCw size={20} color={colors.text.primary.dark} />
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.dark,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
content: {
alignItems: 'center',
maxWidth: 320,
},
title: {
fontSize: 20,
fontWeight: '600',
color: colors.text.primary.dark,
marginTop: 16,
marginBottom: 8,
},
message: {
fontSize: 14,
color: colors.text.secondary.dark,
textAlign: 'center',
lineHeight: 20,
},
errorContainer: {
maxHeight: 200,
marginTop: 16,
padding: 12,
backgroundColor: colors.card.dark,
borderRadius: 8,
width: '100%',
},
errorTitle: {
fontSize: 12,
fontWeight: '600',
color: colors.error,
marginBottom: 4,
marginTop: 8,
},
errorText: {
fontSize: 12,
color: colors.text.secondary.dark,
fontFamily: 'monospace',
},
stackText: {
fontSize: 10,
color: colors.text.muted.dark,
fontFamily: 'monospace',
},
button: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.cyan.core,
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
marginTop: 24,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: colors.text.primary.dark,
},
});

View File

@@ -0,0 +1,126 @@
/**
* Server selector component for header
* Tappable button that shows current server, opens modal to switch
*/
import { useState } from 'react';
import {
View,
Text,
TouchableOpacity,
Modal,
Pressable,
ActivityIndicator,
} from 'react-native';
import { Server, ChevronDown, Check } from 'lucide-react-native';
import { useMediaServer } from '../providers/MediaServerProvider';
import { colors } from '../lib/theme';
export function ServerSelector() {
const { servers, selectedServer, selectedServerId, selectServer, isLoading } = useMediaServer();
const [modalVisible, setModalVisible] = useState(false);
// Don't show if loading or no servers
if (isLoading) {
return (
<View className="flex-row items-center px-3">
<ActivityIndicator size="small" color={colors.text.muted.dark} />
</View>
);
}
// Don't show selector if only one server
if (servers.length <= 1) {
if (servers.length === 1) {
return (
<View className="flex-row items-center px-3">
<Server size={16} color={colors.text.primary.dark} />
<Text className="ml-2 text-sm text-white font-medium" numberOfLines={1}>
{servers[0]?.name}
</Text>
</View>
);
}
return null;
}
const handleSelect = (serverId: string) => {
selectServer(serverId);
setModalVisible(false);
};
return (
<>
<TouchableOpacity
onPress={() => setModalVisible(true)}
className="flex-row items-center px-3 py-2"
activeOpacity={0.7}
>
<Server size={16} color={colors.cyan.core} />
<Text className="ml-2 text-sm font-medium text-white" numberOfLines={1}>
{selectedServer?.name ?? 'Select Server'}
</Text>
<ChevronDown size={16} color={colors.text.muted.dark} className="ml-1" />
</TouchableOpacity>
<Modal
visible={modalVisible}
transparent
animationType="fade"
onRequestClose={() => setModalVisible(false)}
>
<Pressable
className="flex-1 justify-center items-center bg-black/60"
onPress={() => setModalVisible(false)}
>
<Pressable
className="w-4/5 max-w-sm bg-gray-900 rounded-xl overflow-hidden"
onPress={(e) => e.stopPropagation()}
>
<View className="px-4 py-3 border-b border-gray-800">
<Text className="text-lg font-semibold text-white">Select Server</Text>
</View>
<View className="py-2">
{servers.map((server) => (
<TouchableOpacity
key={server.id}
onPress={() => handleSelect(server.id)}
className="flex-row items-center justify-between px-4 py-3"
activeOpacity={0.7}
>
<View className="flex-row items-center flex-1">
<Server
size={20}
color={
server.id === selectedServerId
? colors.cyan.core
: colors.text.muted.dark
}
/>
<View className="ml-3 flex-1">
<Text
className={`text-base ${
server.id === selectedServerId
? 'text-cyan-400 font-medium'
: 'text-white'
}`}
numberOfLines={1}
>
{server.name}
</Text>
<Text className="text-xs text-gray-500 capitalize">
{server.type}
</Text>
</View>
</View>
{server.id === selectedServerId && (
<Check size={20} color={colors.cyan.core} />
)}
</TouchableOpacity>
))}
</View>
</Pressable>
</Pressable>
</Modal>
</>
);
}

View File

@@ -0,0 +1,161 @@
/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Bar chart showing plays by day of week with touch interaction
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Bar, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface DayOfWeekChartProps {
data: { day: number; name: string; count: number }[];
height?: number;
}
const DAY_ABBREV = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={5} color={colors.cyan.core} />;
}
export function DayOfWeekChart({ data, height = 180 }: DayOfWeekChartProps) {
const font = useChartFont(10);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
day: number;
count: number;
} | null>(null);
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback(
(day: number, count: number) => {
setDisplayValue({ day: Math.round(day), count: Math.round(count) });
},
[]
);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
// Transform data for victory-native
const chartData = data.map((d) => ({
x: d.day,
count: d.count,
name: d.name,
}));
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No data available</Text>
</View>
);
}
// Find the selected day name from React state
const selectedDay = displayValue
? chartData.find((d) => d.x === displayValue.day)
: null;
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue && selectedDay ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.dayText}>{selectedDay.name}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ left: 25, right: 25, top: 20 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 7, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => DAY_ABBREV[Math.round(value)] || '',
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Bar
points={points.count}
chartBounds={chartBounds}
color={colors.cyan.core}
roundedCorners={{ topLeft: 4, topRight: 4 }}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 18,
},
valueText: {
color: colors.cyan.core,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
dayText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});

View File

@@ -0,0 +1,163 @@
/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Bar chart showing plays by hour of day with touch interaction
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Bar, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface HourOfDayChartProps {
data: { hour: number; count: number }[];
height?: number;
}
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={5} color={colors.purple} />;
}
function formatHour(hour: number): string {
if (hour === 0) return '12am';
if (hour === 12) return '12pm';
return hour < 12 ? `${hour}am` : `${hour - 12}pm`;
}
export function HourOfDayChart({ data, height = 180 }: HourOfDayChartProps) {
const font = useChartFont(9);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
hour: number;
count: number;
} | null>(null);
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback((hour: number, count: number) => {
setDisplayValue({ hour: Math.round(hour), count: Math.round(count) });
}, []);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
// Transform data for victory-native
const chartData = data.map((d) => ({
x: d.hour,
count: d.count,
}));
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No data available</Text>
</View>
);
}
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.hourText}>{formatHour(displayValue.hour)}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ left: 10, right: 10, top: 20 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 6, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => {
const hour = Math.round(value);
// Only show labels for 0, 6, 12, 18 to avoid crowding
if (hour % 6 === 0) {
return formatHour(hour);
}
return '';
},
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Bar
points={points.count}
chartBounds={chartBounds}
color={colors.purple}
roundedCorners={{ topLeft: 2, topRight: 2 }}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 18,
},
valueText: {
color: colors.purple,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
hourText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});

View File

@@ -0,0 +1,130 @@
/**
* Donut chart showing plays by platform (matches web implementation)
* Note: Touch interactions not yet supported on PolarChart (victory-native issue #252)
*/
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Pie, PolarChart } from 'victory-native';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
interface PlatformChartProps {
data: { platform: string; count: number }[];
height?: number;
}
// Colors for pie slices - all visible against dark card background
const CHART_COLORS = [
colors.cyan.core, // #18D1E7 - Cyan
colors.info, // #3B82F6 - Bright Blue (not blue.core which matches bg!)
colors.success, // #22C55E - Green
colors.warning, // #F59E0B - Orange/Yellow
colors.purple, // #8B5CF6 - Purple
colors.error, // #EF4444 - Red
];
export function PlatformChart({ data }: PlatformChartProps) {
// Sort by count and take top 5
const sortedData = [...data]
.sort((a, b) => b.count - a.count)
.slice(0, 5)
.map((d, index) => ({
label: d.platform.replace('Plex for ', '').replace('Jellyfin ', ''),
value: d.count,
color: CHART_COLORS[index % CHART_COLORS.length],
}));
if (sortedData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer]}>
<Text style={styles.emptyText}>No platform data available</Text>
</View>
);
}
const total = sortedData.reduce((sum, item) => sum + item.value, 0);
return (
<View style={styles.container}>
{/* Pie Chart */}
<View style={styles.chartContainer}>
<PolarChart
data={sortedData}
labelKey="label"
valueKey="value"
colorKey="color"
>
<Pie.Chart
innerRadius="50%"
circleSweepDegrees={360}
startAngle={0}
/>
</PolarChart>
</View>
{/* Legend with percentages */}
<View style={styles.legend}>
{sortedData.map((item) => (
<View key={item.label} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
<Text style={styles.legendText} numberOfLines={1}>
{item.label}
</Text>
<Text style={styles.legendPercent}>
{Math.round((item.value / total) * 100)}%
</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
minHeight: 150,
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
chartContainer: {
height: 160,
},
legend: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: spacing.md,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border.dark,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
},
legendText: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
maxWidth: 60,
},
legendPercent: {
fontSize: typography.fontSize.xs,
color: colors.text.secondary.dark,
fontWeight: '500',
},
});

View File

@@ -0,0 +1,164 @@
/* eslint-disable @typescript-eslint/no-deprecated */
/**
* Area chart showing plays over time with touch-to-reveal tooltip
*/
import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CartesianChart, Area, useChartPressState } from 'victory-native';
import { Circle } from '@shopify/react-native-skia';
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import type { SharedValue } from 'react-native-reanimated';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
import { useChartFont } from './useChartFont';
interface PlaysChartProps {
data: { date: string; count: number }[];
height?: number;
}
function ToolTip({ x, y }: { x: SharedValue<number>; y: SharedValue<number> }) {
return <Circle cx={x} cy={y} r={6} color={colors.cyan.core} />;
}
export function PlaysChart({ data, height = 200 }: PlaysChartProps) {
const font = useChartFont(10);
const { state, isActive } = useChartPressState({ x: 0, y: { count: 0 } });
// React state to display values (synced from SharedValues)
const [displayValue, setDisplayValue] = useState<{
index: number;
count: number;
} | null>(null);
// Transform data for victory-native
const chartData = data.map((d, index) => ({
x: index,
count: d.count,
label: d.date,
}));
// Sync SharedValue changes to React state
const updateDisplayValue = useCallback((index: number, count: number) => {
setDisplayValue({ index: Math.round(index), count: Math.round(count) });
}, []);
const clearDisplayValue = useCallback(() => {
setDisplayValue(null);
}, []);
// Watch for changes in chart press state
useAnimatedReaction(
() => ({
active: isActive,
x: state.x.value.value,
y: state.y.count.value.value,
}),
(current, previous) => {
if (current.active) {
runOnJS(updateDisplayValue)(current.x, current.y);
} else if (previous?.active && !current.active) {
runOnJS(clearDisplayValue)();
}
},
[isActive]
);
if (chartData.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No play data available</Text>
</View>
);
}
// Get date label from React state
const dateLabel = displayValue && chartData[displayValue.index]?.label
? new Date(chartData[displayValue.index].label).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
: '';
return (
<View style={[styles.container, { height }]}>
{/* Active value display */}
<View style={styles.valueDisplay}>
{displayValue ? (
<>
<Text style={styles.valueText}>{displayValue.count} plays</Text>
<Text style={styles.dateText}>{dateLabel}</Text>
</>
) : null}
</View>
<CartesianChart
data={chartData}
xKey="x"
yKeys={['count']}
domainPadding={{ top: 20, bottom: 10, left: 5, right: 5 }}
chartPressState={state}
axisOptions={{
font,
tickCount: { x: 5, y: 4 },
lineColor: colors.border.dark,
labelColor: colors.text.muted.dark,
formatXLabel: (value) => {
const item = chartData[Math.round(value)];
if (!item) return '';
const date = new Date(item.label);
return `${date.getMonth() + 1}/${date.getDate()}`;
},
formatYLabel: (value) => String(Math.round(value)),
}}
>
{({ points, chartBounds }) => (
<>
<Area
points={points.count}
y0={chartBounds.bottom}
color={colors.cyan.core}
opacity={0.6}
animate={{ type: 'timing', duration: 500 }}
/>
{isActive && (
<ToolTip x={state.x.position} y={state.y.count.position} />
)}
</>
)}
</CartesianChart>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
valueDisplay: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xs,
marginBottom: spacing.xs,
minHeight: 20,
},
valueText: {
color: colors.cyan.core,
fontSize: typography.fontSize.sm,
fontWeight: '600',
},
dateText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.xs,
},
});

View File

@@ -0,0 +1,107 @@
/**
* Simple chart showing direct play vs transcode breakdown
*/
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { colors, spacing, borderRadius, typography } from '../../lib/theme';
interface QualityChartProps {
directPlay: number;
transcode: number;
directPlayPercent: number;
transcodePercent: number;
height?: number;
}
export function QualityChart({
directPlay,
transcode,
directPlayPercent,
transcodePercent,
height = 120,
}: QualityChartProps) {
const total = directPlay + transcode;
if (total === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No playback data available</Text>
</View>
);
}
return (
<View style={[styles.container, { height }]}>
{/* Progress bar */}
<View style={styles.barContainer}>
<View style={[styles.directBar, { flex: directPlayPercent || 1 }]} />
<View style={[styles.transcodeBar, { flex: transcodePercent || 1 }]} />
</View>
{/* Legend */}
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.success }]} />
<Text style={styles.legendLabel}>Direct Play</Text>
<Text style={styles.legendValue}>{directPlay} ({directPlayPercent}%)</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.warning }]} />
<Text style={styles.legendLabel}>Transcode</Text>
<Text style={styles.legendValue}>{transcode} ({transcodePercent}%)</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.md,
justifyContent: 'center',
},
emptyContainer: {
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
barContainer: {
flexDirection: 'row',
height: 24,
borderRadius: borderRadius.md,
overflow: 'hidden',
marginBottom: spacing.md,
},
directBar: {
backgroundColor: colors.success,
},
transcodeBar: {
backgroundColor: colors.warning,
},
legend: {
gap: spacing.sm,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
},
legendLabel: {
flex: 1,
color: colors.text.primary.dark,
fontSize: typography.fontSize.sm,
},
legendValue: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
});

View File

@@ -0,0 +1,6 @@
export { PlaysChart } from './PlaysChart';
export { PlatformChart } from './PlatformChart';
export { DayOfWeekChart } from './DayOfWeekChart';
export { HourOfDayChart } from './HourOfDayChart';
export { QualityChart } from './QualityChart';
export { useChartFont } from './useChartFont';

View File

@@ -0,0 +1,13 @@
/**
* Hook to load fonts for chart axis labels
* Uses @shopify/react-native-skia's useFont with Inter font
*/
import { useFont } from '@shopify/react-native-skia';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const InterMedium = require('../../../assets/fonts/Inter_500Medium.ttf') as number;
export function useChartFont(size: number = 10) {
const font = useFont(InterMedium, size);
return font;
}

View File

@@ -0,0 +1,207 @@
/**
* Interactive map showing active stream locations
* Uses expo-maps with Apple Maps on iOS, Google Maps on Android
*
* Note: expo-maps doesn't support custom tile providers, so we can't
* match the web's dark theme exactly. Using default map styles.
*/
import React, { Component, type ReactNode } from 'react';
import { View, Text, StyleSheet, Platform } from 'react-native';
import { AppleMaps, GoogleMaps } from 'expo-maps';
import { Ionicons } from '@expo/vector-icons';
import type { ActiveSession } from '@tracearr/shared';
import { colors, borderRadius, typography } from '../../lib/theme';
/**
* Error boundary to catch map crashes (e.g., missing Google Maps API key on Android)
* This prevents the entire app from crashing if the map fails to render
*/
class MapErrorBoundary extends Component<
{ children: ReactNode; height: number },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode; height: number }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('StreamMap crashed:', error.message);
console.error('Component stack:', errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return (
<View style={[styles.container, styles.errorContainer, { height: this.props.height }]}>
<Ionicons name="map-outline" size={32} color={colors.text.muted.dark} />
<Text style={styles.errorText}>Map unavailable</Text>
{__DEV__ && this.state.error && (
<Text style={styles.errorDetail}>{this.state.error.message}</Text>
)}
</View>
);
}
return this.props.children;
}
}
interface StreamMapProps {
sessions: ActiveSession[];
height?: number;
}
/** Session with guaranteed geo coordinates */
type SessionWithLocation = ActiveSession & {
geoLat: number;
geoLon: number;
};
/** Type guard to filter sessions with valid coordinates */
function hasLocation(session: ActiveSession): session is SessionWithLocation {
return session.geoLat != null && session.geoLon != null;
}
export function StreamMap({ sessions, height = 300 }: StreamMapProps) {
// Filter sessions with valid geo coordinates (type guard narrows to SessionWithLocation[])
const sessionsWithLocation = sessions.filter(hasLocation);
if (sessionsWithLocation.length === 0) {
return (
<View style={[styles.container, styles.emptyContainer, { height }]}>
<Text style={styles.emptyText}>No location data available</Text>
</View>
);
}
// Calculate center point from all sessions
const avgLat = sessionsWithLocation.reduce((sum, s) => sum + s.geoLat, 0) / sessionsWithLocation.length;
const avgLon = sessionsWithLocation.reduce((sum, s) => sum + s.geoLon, 0) / sessionsWithLocation.length;
// Create markers for each session with enhanced info
const markers = sessionsWithLocation.map((session) => {
const username = session.user?.username || 'Unknown';
const location = [session.geoCity, session.geoCountry].filter(Boolean).join(', ') || 'Unknown location';
const mediaTitle = session.mediaTitle || 'Unknown';
// Truncate long media titles for snippet
const truncatedTitle = mediaTitle.length > 40
? mediaTitle.substring(0, 37) + '...'
: mediaTitle;
return {
id: session.sessionKey || session.id,
coordinates: {
latitude: session.geoLat,
longitude: session.geoLon,
},
// Title shows username prominently
title: username,
// Snippet shows media and location
snippet: `${truncatedTitle}\n${location}`,
// Use cyan tint to match app theme
tintColor: colors.cyan.core,
// iOS: Use SF Symbol for streaming indicator
...(Platform.OS === 'ios' && {
systemImage: 'play.circle.fill',
}),
};
});
// Calculate appropriate zoom based on marker spread
const calculateZoom = () => {
if (sessionsWithLocation.length === 1) return 10;
// Calculate spread of coordinates
const lats = sessionsWithLocation.map(s => s.geoLat);
const lons = sessionsWithLocation.map(s => s.geoLon);
const latSpread = Math.max(...lats) - Math.min(...lats);
const lonSpread = Math.max(...lons) - Math.min(...lons);
const maxSpread = Math.max(latSpread, lonSpread);
// Adjust zoom based on spread
if (maxSpread > 100) return 2;
if (maxSpread > 50) return 3;
if (maxSpread > 20) return 4;
if (maxSpread > 10) return 5;
if (maxSpread > 5) return 6;
if (maxSpread > 1) return 8;
return 10;
};
const cameraPosition = {
coordinates: {
latitude: avgLat || 39.8283,
longitude: avgLon || -98.5795,
},
zoom: calculateZoom(),
};
// Use platform-specific map component
const MapComponent = Platform.OS === 'ios' ? AppleMaps.View : GoogleMaps.View;
return (
<MapErrorBoundary height={height}>
<View style={[styles.container, { height }]}>
<MapComponent
style={styles.map}
cameraPosition={cameraPosition}
markers={markers.map((m) => ({
id: m.id,
coordinates: m.coordinates,
title: m.title,
snippet: m.snippet,
tintColor: m.tintColor,
...(Platform.OS === 'ios' && m.systemImage && { systemImage: m.systemImage }),
}))}
uiSettings={{
compassEnabled: false,
scaleBarEnabled: false,
rotationGesturesEnabled: false,
tiltGesturesEnabled: false,
}}
/>
</View>
</MapErrorBoundary>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: borderRadius.lg,
overflow: 'hidden',
backgroundColor: colors.card.dark,
},
map: {
flex: 1,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
},
errorContainer: {
justifyContent: 'center',
alignItems: 'center',
gap: 8,
},
errorText: {
color: colors.text.muted.dark,
fontSize: typography.fontSize.sm,
fontWeight: '500',
},
errorDetail: {
color: colors.error,
fontSize: typography.fontSize.xs,
textAlign: 'center',
paddingHorizontal: 16,
marginTop: 4,
},
});

View File

@@ -0,0 +1,246 @@
/**
* Server resource monitoring card (CPU + RAM)
* Displays real-time server resource utilization with progress bars
* Note: Section header is rendered by parent - this is just the card content
*/
import { View, Animated, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useEffect, useRef } from 'react';
import { Text } from '@/components/ui/text';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
// Bar colors matching web app
const BAR_COLORS = {
process: '#00b4e4', // Plex-style cyan for "Plex Media Server"
system: '#cc7b9f', // Pink/purple for "System"
};
interface ResourceBarProps {
label: string;
processValue: number;
systemValue: number;
icon: keyof typeof Ionicons.glyphMap;
}
function ResourceBar({ label, processValue, systemValue, icon }: ResourceBarProps) {
const processWidth = useRef(new Animated.Value(0)).current;
const systemWidth = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(processWidth, {
toValue: processValue,
duration: 300,
useNativeDriver: false,
}),
Animated.timing(systemWidth, {
toValue: systemValue,
duration: 300,
useNativeDriver: false,
}),
]).start();
}, [processValue, systemValue, processWidth, systemWidth]);
return (
<View style={styles.resourceBar}>
{/* Header row */}
<View style={styles.resourceHeader}>
<Ionicons name={icon} size={14} color={colors.text.secondary.dark} />
<Text style={styles.resourceLabel}>{label}</Text>
</View>
{/* Process bar (Plex Media Server) */}
<View style={styles.barSection}>
<View style={styles.barLabelRow}>
<Text style={styles.barLabelText}>Plex Media Server</Text>
<Text style={styles.barValueText}>{processValue}%</Text>
</View>
<View style={styles.barTrack}>
<Animated.View
style={[
styles.barFill,
{
backgroundColor: BAR_COLORS.process,
width: processWidth.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
}),
},
]}
/>
</View>
</View>
{/* System bar */}
<View style={styles.barSection}>
<View style={styles.barLabelRow}>
<Text style={styles.barLabelText}>System</Text>
<Text style={styles.barValueText}>{systemValue}%</Text>
</View>
<View style={styles.barTrack}>
<Animated.View
style={[
styles.barFill,
{
backgroundColor: BAR_COLORS.system,
width: systemWidth.interpolate({
inputRange: [0, 100],
outputRange: ['0%', '100%'],
}),
},
]}
/>
</View>
</View>
</View>
);
}
interface ServerResourceCardProps {
latest: {
hostCpu: number;
processCpu: number;
hostMemory: number;
processMemory: number;
} | null;
isLoading?: boolean;
error?: Error | null;
}
export function ServerResourceCard({ latest, isLoading, error }: ServerResourceCardProps) {
if (isLoading) {
return (
<View style={styles.container}>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
</View>
);
}
if (error) {
return (
<View style={styles.container}>
<View style={styles.emptyContainer}>
<View style={[styles.emptyIconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.1)' }]}>
<Ionicons name="alert-circle-outline" size={24} color="#ef4444" />
</View>
<Text style={styles.emptyText}>Failed to load</Text>
<Text style={styles.emptySubtext}>{error.message}</Text>
</View>
</View>
);
}
if (!latest) {
return (
<View style={styles.container}>
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<Ionicons name="server-outline" size={24} color={colors.text.muted.dark} />
</View>
<Text style={styles.emptyText}>No resource data</Text>
<Text style={styles.emptySubtext}>Waiting for server statistics...</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ResourceBar
label="CPU"
icon="speedometer-outline"
processValue={latest.processCpu}
systemValue={latest.hostCpu}
/>
<ResourceBar
label="RAM"
icon="hardware-chip-outline"
processValue={latest.processMemory}
systemValue={latest.hostMemory}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
padding: spacing.sm,
},
loadingContainer: {
height: 80,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
},
emptyContainer: {
paddingVertical: spacing.lg,
justifyContent: 'center',
alignItems: 'center',
},
emptyIconContainer: {
backgroundColor: colors.surface.dark,
padding: spacing.sm,
borderRadius: borderRadius.full,
marginBottom: spacing.sm,
},
emptyText: {
fontSize: typography.fontSize.sm,
fontWeight: '600',
color: colors.text.primary.dark,
},
emptySubtext: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
marginTop: 2,
},
resourceBar: {
marginBottom: spacing.sm,
},
resourceHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xs,
},
resourceLabel: {
marginLeft: spacing.xs,
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.text.primary.dark,
},
barSection: {
marginBottom: spacing.xs,
},
barLabelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 3,
},
barLabelText: {
fontSize: 10,
color: colors.text.muted.dark,
},
barValueText: {
fontSize: 10,
fontWeight: '600',
color: colors.text.primary.dark,
},
barTrack: {
height: 4,
backgroundColor: colors.surface.dark,
borderRadius: 2,
overflow: 'hidden',
},
barFill: {
height: '100%',
borderRadius: 2,
},
});

View File

@@ -0,0 +1,256 @@
/**
* Compact card showing an active streaming session
* Displays poster, title, user, progress bar, and play/pause status
*/
import React from 'react';
import { View, Image, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/ui/text';
import { UserAvatar } from '@/components/ui/user-avatar';
import { useAuthStore } from '@/lib/authStore';
import { useEstimatedProgress } from '@/hooks/useEstimatedProgress';
import { colors, spacing, borderRadius, typography } from '@/lib/theme';
import type { ActiveSession } from '@tracearr/shared';
interface NowPlayingCardProps {
session: ActiveSession;
onPress?: (session: ActiveSession) => void;
}
/**
* Format duration in ms to readable string (HH:MM:SS or MM:SS)
*/
function formatDuration(ms: number | null): string {
if (!ms) return '--:--';
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
/**
* Get display title for media (handles TV shows vs movies)
*/
function getMediaDisplay(session: ActiveSession): { title: string; subtitle: string | null } {
if (session.mediaType === 'episode' && session.grandparentTitle) {
// TV Show episode
const episodeInfo =
session.seasonNumber && session.episodeNumber
? `S${session.seasonNumber.toString().padStart(2, '0')}E${session.episodeNumber.toString().padStart(2, '0')}`
: '';
return {
title: session.grandparentTitle,
subtitle: episodeInfo ? `${episodeInfo} · ${session.mediaTitle}` : session.mediaTitle,
};
}
// Movie or music
return {
title: session.mediaTitle,
subtitle: session.year ? `${session.year}` : null,
};
}
export function NowPlayingCard({ session, onPress }: NowPlayingCardProps) {
const { serverUrl } = useAuthStore();
const { title, subtitle } = getMediaDisplay(session);
// Use estimated progress for smooth updates between SSE/poll events
const { estimatedProgressMs, progressPercent } = useEstimatedProgress(session);
// Build poster URL using image proxy
const posterUrl =
serverUrl && session.thumbPath
? `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=80&height=120`
: null;
const isPaused = session.state === 'paused';
const username = session.user?.username || 'Unknown';
const userThumbUrl = session.user?.thumbUrl || null;
return (
<Pressable
style={({ pressed }) => [styles.container, pressed && styles.pressed]}
onPress={() => onPress?.(session)}
>
{/* Main content row */}
<View style={styles.contentRow}>
{/* Poster */}
<View style={styles.posterContainer}>
{posterUrl ? (
<Image source={{ uri: posterUrl }} style={styles.poster} resizeMode="cover" />
) : (
<View style={[styles.poster, styles.posterPlaceholder]}>
<Ionicons name="film-outline" size={24} color={colors.text.muted.dark} />
</View>
)}
{/* Paused overlay */}
{isPaused && (
<View style={styles.pausedOverlay}>
<Ionicons name="pause" size={20} color={colors.text.primary.dark} />
</View>
)}
</View>
{/* Info section */}
<View style={styles.info}>
{/* Title + subtitle */}
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{subtitle && (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
)}
{/* User + time row combined */}
<View style={styles.userTimeRow}>
<View style={styles.userSection}>
<UserAvatar thumbUrl={userThumbUrl} username={username} size={16} />
<Text style={styles.username} numberOfLines={1}>
{username}
</Text>
{session.isTranscode && (
<Ionicons name="flash" size={10} color={colors.warning} />
)}
</View>
<View style={styles.timeSection}>
<View style={[styles.statusDot, isPaused && styles.statusDotPaused]}>
<Ionicons
name={isPaused ? 'pause' : 'play'}
size={6}
color={isPaused ? colors.warning : colors.cyan.core}
/>
</View>
<Text style={[styles.timeText, isPaused && styles.pausedText]}>
{isPaused
? 'Paused'
: `${formatDuration(estimatedProgressMs)} / ${formatDuration(session.totalDurationMs)}`}
</Text>
</View>
</View>
</View>
{/* Chevron */}
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={16} color={colors.text.muted.dark} />
</View>
</View>
{/* Bottom progress bar - full width */}
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${progressPercent}%` }]} />
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.card.dark,
borderRadius: borderRadius.lg,
marginBottom: spacing.sm,
overflow: 'hidden',
},
pressed: {
opacity: 0.7,
},
contentRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
posterContainer: {
position: 'relative',
marginRight: spacing.sm,
},
poster: {
width: 50,
height: 75,
borderRadius: borderRadius.md,
backgroundColor: colors.surface.dark,
},
posterPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
pausedOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
justifyContent: 'center',
gap: 2,
},
title: {
fontSize: typography.fontSize.sm,
fontWeight: '600',
color: colors.text.primary.dark,
lineHeight: 16,
},
subtitle: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
},
userTimeRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 2,
},
userSection: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
flex: 1,
},
username: {
fontSize: typography.fontSize.xs,
color: colors.text.secondary.dark,
},
timeSection: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statusDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: 'rgba(24, 209, 231, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
statusDotPaused: {
backgroundColor: 'rgba(245, 158, 11, 0.15)',
},
timeText: {
fontSize: typography.fontSize.xs,
color: colors.text.muted.dark,
},
pausedText: {
color: colors.warning,
},
progressBar: {
height: 3,
backgroundColor: colors.surface.dark,
},
progressFill: {
height: '100%',
backgroundColor: colors.cyan.core,
},
chevron: {
marginLeft: 4,
opacity: 0.5,
},
});

View File

@@ -0,0 +1 @@
export { NowPlayingCard } from './NowPlayingCard';

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { View, type ViewProps } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Text } from './text';
const badgeVariants = cva('flex-row items-center rounded-full px-2.5 py-0.5', {
variants: {
variant: {
default: 'bg-primary',
secondary: 'bg-secondary',
destructive: 'bg-destructive',
outline: 'border border-border bg-transparent',
success: 'bg-success/15',
warning: 'bg-warning/15',
danger: 'bg-danger/15',
},
},
defaultVariants: {
variant: 'default',
},
});
const badgeTextVariants = cva('text-xs font-semibold', {
variants: {
variant: {
default: 'text-primary-foreground',
secondary: 'text-secondary-foreground',
destructive: 'text-destructive-foreground',
outline: 'text-foreground',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
},
},
defaultVariants: {
variant: 'default',
},
});
interface BadgeProps extends ViewProps, VariantProps<typeof badgeVariants> {
children: React.ReactNode;
}
const Badge: React.FC<BadgeProps> = ({ className, variant, children, ...props }) => (
<View className={cn(badgeVariants({ variant, className }))} {...props}>
{typeof children === 'string' ? (
<Text className={badgeTextVariants({ variant })}>{children}</Text>
) : (
children
)}
</View>
);
export { Badge, badgeVariants, badgeTextVariants };

View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import { Pressable, type PressableProps } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Text } from './text';
const buttonVariants = cva('flex-row items-center justify-center rounded-md', {
variants: {
variant: {
default: 'bg-primary',
destructive: 'bg-destructive',
outline: 'border border-border bg-transparent',
secondary: 'bg-secondary',
ghost: 'bg-transparent',
link: 'bg-transparent',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
const buttonTextVariants = cva('font-medium', {
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-destructive-foreground',
outline: 'text-foreground',
secondary: 'text-secondary-foreground',
ghost: 'text-foreground',
link: 'text-primary underline',
},
size: {
default: 'text-sm',
sm: 'text-xs',
lg: 'text-base',
icon: 'text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
interface ButtonProps extends PressableProps, VariantProps<typeof buttonVariants> {
children: React.ReactNode;
}
const Button = React.forwardRef<React.ComponentRef<typeof Pressable>, ButtonProps>(
({ className, variant, size, children, ...props }, ref) => (
<Pressable ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props}>
{typeof children === 'string' ? (
<Text className={cn(buttonTextVariants({ variant, size }))}>{children}</Text>
) : (
children
)}
</Pressable>
)
);
Button.displayName = 'Button';
export { Button, buttonVariants, buttonTextVariants };

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { View, type ViewProps, type Text as RNText, type TextProps } from 'react-native';
import { cn } from '@/lib/utils';
import { Text } from './text';
const Card = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn('rounded-lg border border-border bg-card p-4', className)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('pb-2', className)} {...props} />
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
<Text ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
<Text ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('pt-2', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<View, ViewProps>(({ className, ...props }, ref) => (
<View ref={ref} className={cn('flex-row items-center pt-4', className)} {...props} />
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@@ -0,0 +1,4 @@
export { Text } from './text';
export { Button, buttonVariants, buttonTextVariants } from './button';
export { Badge, badgeVariants, badgeTextVariants } from './badge';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';

View File

@@ -0,0 +1,68 @@
/**
* Segmented control for selecting time periods (7d, 30d, 1y)
*/
import React from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import { Text } from './text';
import { colors, spacing, borderRadius } from '@/lib/theme';
export type StatsPeriod = 'week' | 'month' | 'year';
interface PeriodSelectorProps {
value: StatsPeriod;
onChange: (value: StatsPeriod) => void;
}
const PERIODS: { value: StatsPeriod; label: string }[] = [
{ value: 'week', label: '7d' },
{ value: 'month', label: '30d' },
{ value: 'year', label: '1y' },
];
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
return (
<View style={styles.container}>
{PERIODS.map((period) => {
const isSelected = value === period.value;
return (
<Pressable
key={period.value}
onPress={() => onChange(period.value)}
style={[styles.button, isSelected && styles.buttonSelected]}
>
<Text
style={[styles.buttonText, isSelected && styles.buttonTextSelected]}
>
{period.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: colors.surface.dark,
borderRadius: borderRadius.lg,
padding: 4,
},
button: {
paddingHorizontal: spacing.md,
paddingVertical: (spacing.xs as number) + 2,
borderRadius: borderRadius.md,
},
buttonSelected: {
backgroundColor: colors.card.dark,
},
buttonText: {
fontSize: 13,
fontWeight: '500',
color: colors.text.muted.dark,
},
buttonTextSelected: {
color: colors.text.primary.dark,
},
});

View File

@@ -0,0 +1,10 @@
import * as React from 'react';
import { Text as RNText, type TextProps } from 'react-native';
import { cn } from '@/lib/utils';
const Text = React.forwardRef<RNText, TextProps>(({ className, ...props }, ref) => (
<RNText ref={ref} className={cn('text-foreground text-base', className)} {...props} />
));
Text.displayName = 'Text';
export { Text };

View File

@@ -0,0 +1,68 @@
/**
* User avatar component with image and fallback to initials
*/
import React from 'react';
import { View, Image, StyleSheet } from 'react-native';
import { Text } from './text';
import { colors } from '@/lib/theme';
interface UserAvatarProps {
/** User's avatar URL (can be null) */
thumbUrl?: string | null;
/** Username for generating initials fallback */
username: string;
/** Size of the avatar (default: 40) */
size?: number;
}
export function UserAvatar({ thumbUrl, username, size = 40 }: UserAvatarProps) {
const initials = username.slice(0, 2).toUpperCase();
const fontSize = Math.max(size * 0.4, 10);
const borderRadiusValue = size / 2;
if (thumbUrl) {
return (
<Image
source={{ uri: thumbUrl }}
style={[
styles.image,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
/>
);
}
return (
<View
style={[
styles.fallback,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
>
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
</View>
);
}
const styles = StyleSheet.create({
image: {
backgroundColor: colors.surface.dark,
},
fallback: {
backgroundColor: colors.cyan.dark,
justifyContent: 'center',
alignItems: 'center',
},
initials: {
fontWeight: '600',
color: colors.text.primary.dark,
},
});

View File

@@ -0,0 +1,76 @@
import { useState, useEffect, useRef } from 'react';
import type { ActiveSession } from '@tracearr/shared';
/**
* Hook that estimates playback progress client-side for smooth UI updates.
*
* When state is "playing", progress increments every second based on elapsed time.
* When state is "paused" or "stopped", progress stays at last known value.
*
* Resets estimation when:
* - Session ID changes
* - Server-side progressMs changes (new data from SSE/poll)
* - State changes
*
* @param session - The active session to estimate progress for
* @returns Object with estimated progressMs and progress percentage
*/
export function useEstimatedProgress(session: ActiveSession) {
const [estimatedProgressMs, setEstimatedProgressMs] = useState(session.progressMs ?? 0);
// Track the last known server values to detect changes
const lastServerProgress = useRef(session.progressMs);
const lastSessionId = useRef(session.id);
const lastState = useRef(session.state);
const estimationStartTime = useRef(Date.now());
const estimationStartProgress = useRef(session.progressMs ?? 0);
// Reset estimation when server data changes
useEffect(() => {
const serverProgressChanged = session.progressMs !== lastServerProgress.current;
const sessionChanged = session.id !== lastSessionId.current;
const stateChanged = session.state !== lastState.current;
if (sessionChanged || serverProgressChanged || stateChanged) {
// Reset to server value
const newProgress = session.progressMs ?? 0;
setEstimatedProgressMs(newProgress);
// Update refs
lastServerProgress.current = session.progressMs;
lastSessionId.current = session.id;
lastState.current = session.state;
estimationStartTime.current = Date.now();
estimationStartProgress.current = newProgress;
}
}, [session.id, session.progressMs, session.state]);
// Tick progress when playing
useEffect(() => {
if (session.state !== 'playing') {
return;
}
const intervalId = setInterval(() => {
const elapsedMs = Date.now() - estimationStartTime.current;
const estimated = estimationStartProgress.current + elapsedMs;
// Cap at total duration if available
const maxProgress = session.totalDurationMs ?? Infinity;
setEstimatedProgressMs(Math.min(estimated, maxProgress));
}, 1000);
return () => clearInterval(intervalId);
}, [session.state, session.totalDurationMs]);
// Calculate percentage
const progressPercent = session.totalDurationMs
? Math.min((estimatedProgressMs / session.totalDurationMs) * 100, 100)
: 0;
return {
estimatedProgressMs,
progressPercent,
isEstimating: session.state === 'playing',
};
}

View File

@@ -0,0 +1,304 @@
/**
* Push notifications hook for violation alerts
*
* Handles push notification registration, foreground notifications,
* background task registration, and payload decryption.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { useSocket } from '../providers/SocketProvider';
import { useMediaServer } from '../providers/MediaServerProvider';
import type { ViolationWithDetails, EncryptedPushPayload } from '@tracearr/shared';
import {
registerBackgroundNotificationTask,
unregisterBackgroundNotificationTask,
} from '../lib/backgroundTasks';
import { decryptPushPayload, isEncryptionAvailable, getDeviceSecret } from '../lib/crypto';
import { api } from '../lib/api';
// Configure notification behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
// Check if notification payload is encrypted
function isEncrypted(data: unknown): data is EncryptedPushPayload {
if (!data || typeof data !== 'object') return false;
const payload = data as Record<string, unknown>;
return (
payload.v === 1 &&
typeof payload.iv === 'string' &&
typeof payload.ct === 'string' &&
typeof payload.tag === 'string'
);
}
export function usePushNotifications() {
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
const [notification, setNotification] = useState<Notifications.Notification | null>(null);
const notificationListener = useRef<Notifications.EventSubscription | null>(null);
const responseListener = useRef<Notifications.EventSubscription | null>(null);
const router = useRouter();
const { socket } = useSocket();
const { selectServer, servers } = useMediaServer();
// Register for push notifications
const registerForPushNotifications = useCallback(async (): Promise<string | null> => {
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
// Check existing permissions
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request permissions if not granted
if (existingStatus !== Notifications.PermissionStatus.GRANTED) {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== Notifications.PermissionStatus.GRANTED) {
console.log('Push notification permission not granted');
return null;
}
// Get Expo push token
try {
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
console.error('No EAS project ID found in app config');
return null;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
return tokenData.data;
} catch (error) {
console.error('Failed to get push token:', error);
return null;
}
}, []);
// Show local notification for violations
const showViolationNotification = useCallback(async (violation: ViolationWithDetails) => {
const ruleTypeLabels: Record<string, string> = {
impossible_travel: 'Impossible Travel',
simultaneous_locations: 'Simultaneous Locations',
device_velocity: 'Device Velocity',
concurrent_streams: 'Concurrent Streams',
geo_restriction: 'Geo Restriction',
};
const severityLabels: Record<string, string> = {
low: 'Low',
warning: 'Warning',
high: 'High',
critical: 'Critical',
};
const title = `${severityLabels[violation.severity] || 'Warning'} Violation`;
const ruleType = violation.rule?.type || '';
const body = `${violation.user?.username || 'Unknown user'}: ${ruleTypeLabels[ruleType] || 'Rule Violation'}`;
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data: {
type: 'violation',
violationId: violation.id,
serverUserId: violation.serverUserId,
},
sound: true,
},
trigger: null, // Show immediately
});
}, []);
// Process notification data (handle encryption if needed)
const processNotificationData = useCallback(
async (data: Record<string, unknown>): Promise<Record<string, unknown>> => {
if (isEncrypted(data) && isEncryptionAvailable()) {
try {
return await decryptPushPayload(data);
} catch (error) {
console.error('Failed to decrypt notification:', error);
return data; // Fall back to encrypted data
}
}
return data;
},
[]
);
// Initialize push notifications
useEffect(() => {
const initializePushNotifications = async () => {
const token = await registerForPushNotifications();
if (token) {
setExpoPushToken(token);
// Register push token with server, including device secret for encryption
try {
const deviceSecret = isEncryptionAvailable() ? await getDeviceSecret() : undefined;
await api.registerPushToken(token, deviceSecret);
console.log('Push token registered with server');
} catch (error) {
console.error('Failed to register push token with server:', error);
}
}
};
void initializePushNotifications();
// Register background notification task
void registerBackgroundNotificationTask();
// Listen for notifications received while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener(
(receivedNotification) => {
// Process/decrypt the notification data if needed
const rawData = receivedNotification.request.content.data;
if (rawData && typeof rawData === 'object') {
void (async () => {
const processedData = await processNotificationData(
rawData as Record<string, unknown>
);
// Update the notification with processed data
const processedNotification = {
...receivedNotification,
request: {
...receivedNotification.request,
content: {
...receivedNotification.request.content,
data: processedData,
},
},
};
setNotification(processedNotification as Notifications.Notification);
})();
} else {
setNotification(receivedNotification);
}
}
);
// Listen for notification taps
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const rawData = response.notification.request.content.data;
void (async () => {
let data = rawData;
// Decrypt if needed
if (rawData && isEncrypted(rawData) && isEncryptionAvailable()) {
try {
data = await decryptPushPayload(rawData);
} catch {
// Use raw data if decryption fails
}
}
// Auto-select the server related to this notification if provided
const notificationServerId = data?.serverId as string | undefined;
if (notificationServerId && servers.some((s) => s.id === notificationServerId)) {
selectServer(notificationServerId);
}
// Navigate based on notification type
if (data?.type === 'violation_detected') {
router.push('/(tabs)/alerts');
} else if (data?.type === 'stream_started' || data?.type === 'stream_stopped') {
router.push('/(tabs)/activity');
} else if (data?.type === 'server_down' || data?.type === 'server_up') {
router.push('/(tabs)');
}
})();
}
);
return () => {
if (notificationListener.current) {
notificationListener.current.remove();
}
if (responseListener.current) {
responseListener.current.remove();
}
// Note: We don't unregister background task on unmount
// as it needs to persist for background notifications
};
}, [registerForPushNotifications, router, processNotificationData, selectServer, servers]);
// Listen for violation events from socket
useEffect(() => {
if (!socket) return;
const handleViolation = (violation: ViolationWithDetails) => {
void showViolationNotification(violation);
};
socket.on('violation:new', handleViolation);
return () => {
socket.off('violation:new', handleViolation);
};
}, [socket, showViolationNotification]);
// Configure Android notification channels for different notification types
useEffect(() => {
if (Platform.OS === 'android') {
// Violations channel - high priority
void Notifications.setNotificationChannelAsync('violations', {
name: 'Violation Alerts',
description: 'Alerts when rule violations are detected',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#22D3EE',
sound: 'default',
});
// Sessions channel - default priority
void Notifications.setNotificationChannelAsync('sessions', {
name: 'Stream Activity',
description: 'Notifications for stream start/stop events',
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 100, 100, 100],
lightColor: '#10B981',
});
// Alerts channel - high priority (server status)
void Notifications.setNotificationChannelAsync('alerts', {
name: 'Server Alerts',
description: 'Server online/offline notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 500],
lightColor: '#EF4444',
sound: 'default',
});
}
}, []);
// Cleanup function for logout
const cleanup = useCallback(async () => {
await unregisterBackgroundNotificationTask();
}, []);
return {
expoPushToken,
notification,
showViolationNotification,
cleanup,
isEncryptionAvailable: isEncryptionAvailable(),
};
}

View File

@@ -0,0 +1,126 @@
/**
* Hook for fetching server resource statistics (CPU/RAM)
* Only polls when:
* 1. App is in foreground (AppState === 'active')
* 2. Dashboard tab is focused (useIsFocused)
*/
import { useRef, useCallback, useEffect, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { useIsFocused } from '@react-navigation/native';
import { useQuery } from '@tanstack/react-query';
import { SERVER_STATS_CONFIG, type ServerResourceDataPoint, type ServerResourceStats } from '@tracearr/shared';
import { api } from '@/lib/api';
/**
* Hook for fetching server resource statistics with fixed 2-minute window
* Polls every 6 seconds, displays last 2 minutes of data (20 points)
*
* @param serverId - Server ID to fetch stats for
* @param enabled - Additional enable condition (e.g., server exists)
*/
export function useServerStatistics(serverId: string | undefined, enabled: boolean = true) {
const isFocused = useIsFocused();
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
// Track app state changes
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
setAppState(nextAppState);
});
return () => {
subscription.remove();
};
}, []);
// Only poll when app is active AND screen is focused
const shouldPoll = enabled && !!serverId && appState === 'active' && isFocused;
// Accumulate data points across polls, keyed by timestamp for deduplication
const dataMapRef = useRef<Map<number, ServerResourceDataPoint>>(new Map());
// Merge new data with existing, keep most recent DATA_POINTS
const mergeData = useCallback((newData: ServerResourceDataPoint[]) => {
const map = dataMapRef.current;
// Add/update data points
for (const point of newData) {
map.set(point.at, point);
}
// Sort by timestamp descending (newest first), keep DATA_POINTS
const sorted = Array.from(map.values())
.sort((a, b) => b.at - a.at)
.slice(0, SERVER_STATS_CONFIG.DATA_POINTS);
// Rebuild map with only kept points
dataMapRef.current = new Map(sorted.map((p) => [p.at, p]));
// Return in ascending order (oldest first) for chart rendering
return sorted.reverse();
}, []);
const query = useQuery<ServerResourceStats>({
queryKey: ['servers', 'statistics', serverId],
queryFn: async (): Promise<ServerResourceStats> => {
if (!serverId) throw new Error('Server ID required');
const response = await api.servers.statistics(serverId);
// Merge with accumulated data
const mergedData = mergeData(response.data);
return {
...response,
data: mergedData,
};
},
enabled: shouldPoll,
// Poll every 6 seconds (matches SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS)
refetchInterval: shouldPoll ? SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000 : false,
// Keep previous data while fetching new
placeholderData: (prev) => prev,
// Data is fresh until next poll
staleTime: (SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000) - 500,
});
// Calculate averages from windowed data
const dataPoints = query.data?.data;
const dataLength = dataPoints?.length ?? 0;
const averages = dataPoints && dataLength > 0
? {
hostCpu: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.hostCpuUtilization, 0) / dataLength
),
processCpu: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.processCpuUtilization, 0) / dataLength
),
hostMemory: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.hostMemoryUtilization, 0) / dataLength
),
processMemory: Math.round(
dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength
),
}
: null;
// Get latest values (most recent data point)
const lastDataPoint = query.data?.data?.[query.data.data.length - 1];
const latest = lastDataPoint
? {
hostCpu: Math.round(lastDataPoint.hostCpuUtilization),
processCpu: Math.round(lastDataPoint.processCpuUtilization),
hostMemory: Math.round(lastDataPoint.hostMemoryUtilization),
processMemory: Math.round(lastDataPoint.processMemoryUtilization),
}
: null;
return {
...query,
averages,
latest,
isPolling: shouldPoll,
// Provide more specific loading state:
// - isLoading: true only on initial fetch with no cached data
// - isFetching: true during any fetch (initial or refetch)
// We want to show loading UI while waiting for first data
isLoadingData: query.isLoading || (query.isFetching && !query.data),
};
}

545
apps/mobile/src/lib/api.ts Normal file
View File

@@ -0,0 +1,545 @@
/**
* API client for Tracearr mobile app
* Uses axios with automatic token refresh
* Supports multiple servers with active server selection
*/
import axios from 'axios';
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { storage } from './storage';
import type {
ActiveSession,
DashboardStats,
ServerUserWithIdentity,
ServerUserDetail,
Session,
SessionWithDetails,
UserLocation,
UserDevice,
Violation,
ViolationWithDetails,
Rule,
Server,
Settings,
MobilePairResponse,
PaginatedResponse,
NotificationPreferences,
NotificationPreferencesWithStatus,
ServerResourceStats,
TerminationLogWithDetails,
} from '@tracearr/shared';
// Cache of API clients per server
const apiClients = new Map<string, AxiosInstance>();
let activeServerId: string | null = null;
/**
* Initialize or get the API client for the active server
*/
export async function getApiClient(): Promise<AxiosInstance> {
const serverId = await storage.getActiveServerId();
if (!serverId) {
throw new Error('No server configured');
}
// If server changed, update active
if (activeServerId !== serverId) {
activeServerId = serverId;
}
// Check cache
const cached = apiClients.get(serverId);
if (cached) {
return cached;
}
// Get server info
const server = await storage.getServer(serverId);
if (!server) {
throw new Error('Server not found');
}
const client = createApiClient(server.url, serverId);
apiClients.set(serverId, client);
return client;
}
/**
* Create a new API client for a given server
*/
export function createApiClient(baseURL: string, serverId: string): AxiosInstance {
const client = axios.create({
baseURL: `${baseURL}/api/v1`,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token for this server
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const credentials = await storage.getServerCredentials(serverId);
if (credentials) {
config.headers.Authorization = `Bearer ${credentials.accessToken}`;
}
return config;
},
(error: unknown) => Promise.reject(error instanceof Error ? error : new Error(String(error)))
);
// Response interceptor - handle token refresh for this server
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// If 401 and not already retrying, attempt token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const credentials = await storage.getServerCredentials(serverId);
if (!credentials?.refreshToken) {
throw new Error('No refresh token');
}
const response = await client.post<{ accessToken: string; refreshToken: string }>(
'/mobile/refresh',
{ refreshToken: credentials.refreshToken }
);
await storage.updateServerTokens(
serverId,
response.data.accessToken,
response.data.refreshToken
);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`;
return await client(originalRequest);
} catch {
// Refresh failed - remove this server's client from cache
apiClients.delete(serverId);
throw new Error('Session expired');
}
}
return Promise.reject(error);
}
);
return client;
}
/**
* Reset the API client cache (call when switching servers or logging out)
*/
export function resetApiClient(): void {
apiClients.clear();
activeServerId = null;
}
/**
* Remove a specific server's client from cache
*/
export function removeApiClient(serverId: string): void {
apiClients.delete(serverId);
}
/**
* Get the current server URL (for building absolute URLs like images)
*/
export async function getServerUrl(): Promise<string | null> {
return storage.getServerUrl();
}
/**
* API methods organized by domain
* All methods use the active server's client
*/
export const api = {
/**
* Pair with server using mobile token
* This is called before we have a client, so it uses direct axios
*/
pair: async (
serverUrl: string,
token: string,
deviceName: string,
deviceId: string,
platform: 'ios' | 'android',
deviceSecret?: string
): Promise<MobilePairResponse> => {
try {
const response = await axios.post<MobilePairResponse>(
`${serverUrl}/api/v1/mobile/pair`,
{ token, deviceName, deviceId, platform, deviceSecret },
{ timeout: 15000 }
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
// Extract server's error message if available
const serverMessage =
error.response?.data?.message ||
error.response?.data?.error;
if (serverMessage) {
throw new Error(serverMessage);
}
// Handle specific HTTP status codes
if (error.response?.status === 429) {
throw new Error('Too many pairing attempts. Please wait a few minutes.');
}
if (error.response?.status === 401) {
throw new Error('Invalid or expired pairing token.');
}
if (error.response?.status === 400) {
throw new Error('Invalid pairing request. Check your token.');
}
// Handle network errors
if (error.code === 'ECONNABORTED') {
throw new Error('Connection timed out. Check your server URL.');
}
if (error.code === 'ERR_NETWORK' || !error.response) {
throw new Error('Cannot reach server. Check URL and network connection.');
}
// Fallback to axios message
throw new Error(error.message);
}
throw error;
}
},
/**
* Register push token for notifications
*/
registerPushToken: async (
expoPushToken: string,
deviceSecret?: string
): Promise<{ success: boolean; updatedSessions: number }> => {
const client = await getApiClient();
const response = await client.post<{ success: boolean; updatedSessions: number }>(
'/mobile/push-token',
{ expoPushToken, deviceSecret }
);
return response.data;
},
/**
* Dashboard stats
*/
stats: {
dashboard: async (serverId?: string): Promise<DashboardStats> => {
const client = await getApiClient();
const response = await client.get<DashboardStats>('/stats/dashboard', {
params: serverId ? { serverId } : undefined,
});
return response.data;
},
plays: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { date: string; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { date: string; count: number }[] }>(
'/stats/plays',
{ params }
);
return response.data;
},
playsByDayOfWeek: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { day: number; name: string; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { day: number; name: string; count: number }[] }>(
'/stats/plays-by-dayofweek',
{ params }
);
return response.data;
},
playsByHourOfDay: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { hour: number; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { hour: number; count: number }[] }>(
'/stats/plays-by-hourofday',
{ params }
);
return response.data;
},
platforms: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { platform: string; count: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { platform: string; count: number }[] }>(
'/stats/platforms',
{ params }
);
return response.data;
},
quality: async (params?: { period?: string; serverId?: string }): Promise<{
directPlay: number;
transcode: number;
total: number;
directPlayPercent: number;
transcodePercent: number;
}> => {
const client = await getApiClient();
const response = await client.get<{
directPlay: number;
transcode: number;
total: number;
directPlayPercent: number;
transcodePercent: number;
}>('/stats/quality', { params });
return response.data;
},
concurrent: async (params?: {
period?: string;
serverId?: string;
}): Promise<{ data: { hour: string; maxConcurrent: number }[] }> => {
const client = await getApiClient();
const response = await client.get<{ data: { hour: string; maxConcurrent: number }[] }>(
'/stats/concurrent',
{ params }
);
return response.data;
},
locations: async (params?: {
serverId?: string;
userId?: string;
}): Promise<{
data: {
latitude: number;
longitude: number;
city: string;
country: string;
playCount: number;
}[];
}> => {
const client = await getApiClient();
const response = await client.get<{
data: {
latitude: number;
longitude: number;
city: string;
country: string;
playCount: number;
}[];
}>('/stats/locations', { params });
return response.data;
},
},
/**
* Sessions
*/
sessions: {
active: async (serverId?: string): Promise<ActiveSession[]> => {
const client = await getApiClient();
const response = await client.get<{ data: ActiveSession[] }>('/sessions/active', {
params: serverId ? { serverId } : undefined,
});
return response.data.data;
},
list: async (params?: {
page?: number;
pageSize?: number;
userId?: string;
serverId?: string;
}) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<ActiveSession>>('/sessions', { params });
return response.data;
},
get: async (id: string): Promise<SessionWithDetails> => {
const client = await getApiClient();
const response = await client.get<SessionWithDetails>(`/sessions/${id}`);
return response.data;
},
terminate: async (
id: string,
reason?: string
): Promise<{ success: boolean; terminationLogId: string; message: string }> => {
const client = await getApiClient();
const response = await client.post<{
success: boolean;
terminationLogId: string;
message: string;
}>(`/mobile/streams/${id}/terminate`, { reason });
return response.data;
},
},
/**
* Users
*/
users: {
list: async (params?: { page?: number; pageSize?: number; serverId?: string }) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<ServerUserWithIdentity>>('/users', {
params,
});
return response.data;
},
get: async (id: string): Promise<ServerUserDetail> => {
const client = await getApiClient();
const response = await client.get<ServerUserDetail>(`/users/${id}`);
return response.data;
},
sessions: async (id: string, params?: { page?: number; pageSize?: number }) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<Session>>(`/users/${id}/sessions`, {
params,
});
return response.data;
},
locations: async (id: string): Promise<UserLocation[]> => {
const client = await getApiClient();
const response = await client.get<{ data: UserLocation[] }>(`/users/${id}/locations`);
return response.data.data;
},
devices: async (id: string): Promise<UserDevice[]> => {
const client = await getApiClient();
const response = await client.get<{ data: UserDevice[] }>(`/users/${id}/devices`);
return response.data.data;
},
terminations: async (
id: string,
params?: { page?: number; pageSize?: number }
): Promise<PaginatedResponse<TerminationLogWithDetails>> => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<TerminationLogWithDetails>>(
`/users/${id}/terminations`,
{ params }
);
return response.data;
},
},
/**
* Violations
*/
violations: {
list: async (params?: {
page?: number;
pageSize?: number;
userId?: string;
severity?: string;
acknowledged?: boolean;
serverId?: string;
}) => {
const client = await getApiClient();
const response = await client.get<PaginatedResponse<ViolationWithDetails>>('/violations', {
params,
});
return response.data;
},
acknowledge: async (id: string): Promise<Violation> => {
const client = await getApiClient();
const response = await client.patch<Violation>(`/violations/${id}`);
return response.data;
},
dismiss: async (id: string): Promise<void> => {
const client = await getApiClient();
await client.delete(`/violations/${id}`);
},
},
/**
* Rules
*/
rules: {
list: async (serverId?: string): Promise<Rule[]> => {
const client = await getApiClient();
const response = await client.get<{ data: Rule[] }>('/rules', {
params: serverId ? { serverId } : undefined,
});
return response.data.data;
},
toggle: async (id: string, isActive: boolean): Promise<Rule> => {
const client = await getApiClient();
const response = await client.patch<Rule>(`/rules/${id}`, { isActive });
return response.data;
},
},
/**
* Servers
*/
servers: {
list: async (): Promise<Server[]> => {
const client = await getApiClient();
const response = await client.get<{ data: Server[] }>('/servers');
return response.data.data;
},
statistics: async (id: string): Promise<ServerResourceStats> => {
const client = await getApiClient();
const response = await client.get<ServerResourceStats>(`/servers/${id}/statistics`);
return response.data;
},
},
/**
* Notification preferences (per-device settings)
*/
notifications: {
/**
* Get notification preferences for current device
* Returns preferences with live rate limit status from Redis
*/
getPreferences: async (): Promise<NotificationPreferencesWithStatus> => {
const client = await getApiClient();
const response = await client.get<NotificationPreferencesWithStatus>(
'/notifications/preferences'
);
return response.data;
},
/**
* Update notification preferences for current device
* Supports partial updates - only send fields you want to change
*/
updatePreferences: async (
data: Partial<
Omit<NotificationPreferences, 'id' | 'mobileSessionId' | 'createdAt' | 'updatedAt'>
>
): Promise<NotificationPreferences> => {
const client = await getApiClient();
const response = await client.patch<NotificationPreferences>(
'/notifications/preferences',
data
);
return response.data;
},
/**
* Send a test notification to verify push is working
*/
sendTest: async (): Promise<{ success: boolean; message: string }> => {
const client = await getApiClient();
const response = await client.post<{ success: boolean; message: string }>(
'/notifications/test'
);
return response.data;
},
},
/**
* Global settings (display preferences, etc.)
*/
settings: {
get: async (): Promise<Settings> => {
const client = await getApiClient();
const response = await client.get<Settings>('/settings');
return response.data;
},
},
};

View File

@@ -0,0 +1,308 @@
/**
* Authentication state store using Zustand
* Supports multiple server connections with active server selection
*/
import { create } from 'zustand';
import { storage, type ServerInfo } from './storage';
import { api, resetApiClient } from './api';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { isEncryptionAvailable, getDeviceSecret } from './crypto';
interface AuthState {
// Multi-server state
servers: ServerInfo[];
activeServerId: string | null;
activeServer: ServerInfo | null;
// Legacy compatibility
isAuthenticated: boolean;
isLoading: boolean;
serverUrl: string | null;
serverName: string | null;
error: string | null;
// Actions
initialize: () => Promise<void>;
pair: (serverUrl: string, token: string) => Promise<void>;
addServer: (serverUrl: string, token: string) => Promise<void>;
removeServer: (serverId: string) => Promise<void>;
selectServer: (serverId: string) => Promise<void>;
/** @deprecated Use removeServer(serverId) instead for clarity. This removes the active server. */
logout: () => Promise<void>;
removeActiveServer: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
// Initial state
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
isLoading: true,
serverUrl: null,
serverName: null,
error: null,
/**
* Initialize auth state from stored credentials
* Handles migration from legacy single-server storage
*/
initialize: async () => {
try {
set({ isLoading: true, error: null });
// Check for and migrate legacy storage
await storage.migrateFromLegacy();
// Load servers and active selection
const servers = await storage.getServers();
const activeServerId = await storage.getActiveServerId();
const activeServer = activeServerId
? servers.find((s) => s.id === activeServerId) ?? null
: null;
// If we have servers but no active selection, select first one
if (servers.length > 0 && !activeServer) {
const firstServer = servers[0]!;
await storage.setActiveServerId(firstServer.id);
set({
servers,
activeServerId: firstServer.id,
activeServer: firstServer,
isAuthenticated: true,
serverUrl: firstServer.url,
serverName: firstServer.name,
isLoading: false,
});
} else if (activeServer) {
set({
servers,
activeServerId,
activeServer,
isAuthenticated: true,
serverUrl: activeServer.url,
serverName: activeServer.name,
isLoading: false,
});
} else {
set({
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
serverUrl: null,
serverName: null,
isLoading: false,
});
}
} catch (error) {
console.error('Auth initialization failed:', error);
set({
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
isLoading: false,
error: 'Failed to initialize authentication',
});
}
},
/**
* Pair with server using mobile token (legacy method, adds as first/only server)
*/
pair: async (serverUrl: string, token: string) => {
// Delegate to addServer
await get().addServer(serverUrl, token);
},
/**
* Add a new server connection
*/
addServer: async (serverUrl: string, token: string) => {
try {
set({ isLoading: true, error: null });
// Get device info
const deviceName =
Device.deviceName || `${Device.brand || 'Unknown'} ${Device.modelName || 'Device'}`;
const deviceId = Device.osBuildId || `${Platform.OS}-${Date.now()}`;
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
// Normalize URL (remove trailing slash)
const normalizedUrl = serverUrl.replace(/\/$/, '');
// Get device secret for push notification encryption (if available)
let deviceSecret: string | undefined;
if (isEncryptionAvailable()) {
try {
deviceSecret = await getDeviceSecret();
} catch (error) {
console.warn('Failed to get device secret for encryption:', error);
}
}
// Call pair API
const response = await api.pair(
normalizedUrl,
token,
deviceName,
deviceId,
platform,
deviceSecret
);
// Create server info
const serverInfo: ServerInfo = {
id: response.server.id,
url: normalizedUrl,
name: response.server.name,
type: response.server.type,
addedAt: new Date().toISOString(),
};
// Store server and credentials
await storage.addServer(serverInfo, {
accessToken: response.accessToken,
refreshToken: response.refreshToken,
});
// Set as active server
await storage.setActiveServerId(serverInfo.id);
// Reset API client to use new server
resetApiClient();
// Update state
const servers = await storage.getServers();
set({
servers,
activeServerId: serverInfo.id,
activeServer: serverInfo,
isAuthenticated: true,
serverUrl: normalizedUrl,
serverName: serverInfo.name,
isLoading: false,
});
} catch (error) {
console.error('Adding server failed:', error);
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to add server. Check URL and token.',
});
throw error;
}
},
/**
* Remove a server connection
*/
removeServer: async (serverId: string) => {
try {
set({ isLoading: true });
await storage.removeServer(serverId);
// Reload state
const servers = await storage.getServers();
const activeServerId = await storage.getActiveServerId();
const activeServer = activeServerId
? servers.find((s) => s.id === activeServerId) ?? null
: null;
// Reset API client
resetApiClient();
if (servers.length === 0) {
set({
servers: [],
activeServerId: null,
activeServer: null,
isAuthenticated: false,
serverUrl: null,
serverName: null,
isLoading: false,
error: null,
});
} else {
set({
servers,
activeServerId,
activeServer,
isAuthenticated: true,
serverUrl: activeServer?.url ?? null,
serverName: activeServer?.name ?? null,
isLoading: false,
});
}
} catch (error) {
console.error('Removing server failed:', error);
set({
isLoading: false,
error: 'Failed to remove server',
});
}
},
/**
* Switch to a different server
*/
selectServer: async (serverId: string) => {
try {
const { servers } = get();
const server = servers.find((s) => s.id === serverId);
if (!server) {
throw new Error('Server not found');
}
// Set as active
await storage.setActiveServerId(serverId);
// Reset API client to use new server
resetApiClient();
set({
activeServerId: serverId,
activeServer: server,
serverUrl: server.url,
serverName: server.name,
});
} catch (error) {
console.error('Selecting server failed:', error);
set({
error: 'Failed to switch server',
});
}
},
/**
* Remove the currently active server
* @deprecated Use removeServer(serverId) instead for clarity
*/
logout: async () => {
const { activeServerId } = get();
if (activeServerId) {
await get().removeServer(activeServerId);
}
},
/**
* Remove the currently active server (alias for logout with clearer name)
*/
removeActiveServer: async () => {
const { activeServerId } = get();
if (activeServerId) {
await get().removeServer(activeServerId);
}
},
/**
* Clear error message
*/
clearError: () => {
set({ error: null });
},
}));

View File

@@ -0,0 +1,186 @@
/**
* Background Task Handler for Push Notifications
*
* Handles push notifications when the app is in the background or killed.
* Uses expo-task-manager to register background tasks that process
* incoming notifications.
*/
import * as TaskManager from 'expo-task-manager';
import * as Notifications from 'expo-notifications';
import { decryptPushPayload, type DecryptedPayload } from './crypto';
import type { EncryptedPushPayload } from '@tracearr/shared';
// Task identifier for background notification handling
export const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND_NOTIFICATION_TASK';
// Check if notification payload is encrypted
function isEncrypted(data: unknown): data is EncryptedPushPayload {
if (!data || typeof data !== 'object') return false;
const payload = data as Record<string, unknown>;
return (
payload.v === 1 &&
typeof payload.iv === 'string' &&
typeof payload.ct === 'string' &&
typeof payload.tag === 'string'
);
}
/**
* Process notification payload (decrypt if needed)
*/
async function processPayload(
data: Record<string, unknown>
): Promise<DecryptedPayload | null> {
// Check if payload is encrypted
if (isEncrypted(data)) {
try {
return await decryptPushPayload(data);
} catch (error) {
console.error('[BackgroundTask] Failed to decrypt payload:', error);
return null;
}
}
// Not encrypted, return as-is
return data as DecryptedPayload;
}
/**
* Handle different notification types in background
*/
async function handleNotificationType(payload: DecryptedPayload): Promise<void> {
const notificationType = payload.type as string;
// Handle based on notification type
if (notificationType === 'violation_detected') {
// Violation notifications are critical - ensure they're displayed
console.log('[BackgroundTask] Processing violation notification');
} else if (
notificationType === 'stream_started' ||
notificationType === 'stream_stopped'
) {
// Session notifications are informational
console.log('[BackgroundTask] Processing session notification');
} else if (
notificationType === 'server_down' ||
notificationType === 'server_up'
) {
// Server status notifications are important
console.log('[BackgroundTask] Processing server status notification');
} else if (notificationType === 'data_sync') {
// Silent notification for background data refresh
const syncType = payload.syncType as string | undefined;
console.log('[BackgroundTask] Processing data sync request:', syncType);
// Import QueryClient dynamically to avoid circular dependencies
try {
// Invalidate relevant query caches based on sync type
// The actual data will be refetched when the app becomes active
// This is handled by React Query's cache invalidation
if (syncType === 'stats') {
console.log('[BackgroundTask] Marking stats cache for refresh');
// Stats will be refetched on next app focus
} else if (syncType === 'sessions') {
console.log('[BackgroundTask] Marking sessions cache for refresh');
// Sessions will be refetched on next app focus
}
} catch (error) {
console.error('[BackgroundTask] Data sync error:', error);
}
} else {
console.log('[BackgroundTask] Unknown notification type:', notificationType);
}
}
/**
* Define the background notification task
*
* This task is executed by the OS when a background notification arrives.
* It must complete within the OS-defined time limit (usually 30 seconds).
*/
TaskManager.defineTask(
BACKGROUND_NOTIFICATION_TASK,
async ({ data, error }: TaskManager.TaskManagerTaskBody) => {
if (error) {
console.error('[BackgroundTask] Error:', error);
return;
}
if (!data) {
console.log('[BackgroundTask] No data received');
return;
}
try {
// Extract notification from task data
const taskData = data as { notification?: Notifications.Notification };
const notificationData = taskData.notification?.request.content.data;
if (!notificationData) {
console.log('[BackgroundTask] No notification data');
return;
}
// Process the payload (decrypt if encrypted)
const payload = await processPayload(
notificationData as Record<string, unknown>
);
if (!payload) {
console.error('[BackgroundTask] Failed to process payload');
return;
}
// Handle the notification based on type
await handleNotificationType(payload);
} catch (err) {
console.error('[BackgroundTask] Processing error:', err);
}
}
);
/**
* Register the background notification task
* Call this during app initialization
*/
export async function registerBackgroundNotificationTask(): Promise<void> {
try {
const isRegistered = await TaskManager.isTaskRegisteredAsync(
BACKGROUND_NOTIFICATION_TASK
);
if (!isRegistered) {
await Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
console.log('[BackgroundTask] Registered background notification task');
} else {
console.log('[BackgroundTask] Background task already registered');
}
} catch (error) {
console.error('[BackgroundTask] Failed to register task:', error);
}
}
/**
* Unregister the background notification task
* Call this on logout/cleanup
*/
export async function unregisterBackgroundNotificationTask(): Promise<void> {
try {
const isRegistered = await TaskManager.isTaskRegisteredAsync(
BACKGROUND_NOTIFICATION_TASK
);
if (isRegistered) {
await Notifications.unregisterTaskAsync(BACKGROUND_NOTIFICATION_TASK);
console.log('[BackgroundTask] Unregistered background notification task');
}
} catch (error) {
console.error('[BackgroundTask] Failed to unregister task:', error);
}
}
/**
* Check if background notifications are supported
*/
export function isBackgroundNotificationSupported(): boolean {
return TaskManager.isAvailableAsync !== undefined;
}

View File

@@ -0,0 +1,203 @@
/**
* Push Notification Payload Encryption/Decryption
*
* Uses AES-256-GCM (Authenticated Encryption with Associated Data)
* for secure push notification payloads.
*
* Security properties:
* - Confidentiality: Only the intended device can read the payload
* - Integrity: Tampered payloads are detected and rejected
* - Per-device keys: Each device has a unique derived key
*/
import crypto from 'react-native-quick-crypto';
import * as SecureStore from 'expo-secure-store';
import type { EncryptedPushPayload, NotificationEventType } from '@tracearr/shared';
// Storage key for the per-device encryption secret
const DEVICE_SECRET_KEY = 'tracearr_device_secret';
// AES-256-GCM parameters
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
const SALT_LENGTH = 16; // 128 bits (NIST recommended minimum)
const AUTH_TAG_LENGTH = 16; // 128 bits
/**
* Decrypted push payload structure
*/
export interface DecryptedPayload {
type: NotificationEventType | 'data_sync';
title?: string;
body?: string;
data?: Record<string, unknown>;
[key: string]: unknown;
}
/**
* Generate or retrieve the device-specific encryption secret
* This secret is used along with the server's key to derive the encryption key
*/
export async function getDeviceSecret(): Promise<string> {
let secret = await SecureStore.getItemAsync(DEVICE_SECRET_KEY);
if (!secret) {
// Generate a new 32-byte random secret
const randomBytes = crypto.randomBytes(32);
secret = Buffer.from(randomBytes).toString('base64');
await SecureStore.setItemAsync(DEVICE_SECRET_KEY, secret);
console.log('[Crypto] Generated new device secret');
}
return secret;
}
/**
* Derive the encryption key using PBKDF2
*
* The key is derived from:
* - Device secret (stored locally)
* - Server key identifier (sent with encrypted payload)
*
* This ensures each device has a unique key.
*/
export async function deriveKey(
deviceSecret: string,
salt: Buffer
): Promise<Buffer> {
// Use PBKDF2 with 100,000 iterations for key derivation
// Note: react-native-quick-crypto uses uppercase hash names
const key = crypto.pbkdf2Sync(
deviceSecret,
salt,
100000,
KEY_LENGTH,
'SHA-256'
);
return Buffer.from(key);
}
/**
* Decrypt an encrypted push notification payload
*/
export async function decryptPushPayload(
encrypted: EncryptedPushPayload
): Promise<DecryptedPayload> {
// Validate version
if (encrypted.v !== 1) {
throw new Error(`Unsupported encryption version: ${encrypted.v}`);
}
try {
// Get device secret
const deviceSecret = await getDeviceSecret();
// Decode Base64 values
const iv = Buffer.from(encrypted.iv, 'base64');
const salt = Buffer.from(encrypted.salt, 'base64');
const ciphertext = Buffer.from(encrypted.ct, 'base64');
const authTag = Buffer.from(encrypted.tag, 'base64');
// Validate lengths
if (iv.length !== IV_LENGTH) {
throw new Error(`Invalid IV length: ${iv.length}`);
}
if (salt.length !== SALT_LENGTH) {
throw new Error(`Invalid salt length: ${salt.length}`);
}
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error(`Invalid auth tag length: ${authTag.length}`);
}
// Derive key using the separate salt from payload
const key = await deriveKey(deviceSecret, salt);
// Create decipher
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createDecipheriv> & {
setAuthTag: (tag: Buffer) => void;
};
decipher.setAuthTag(authTag);
// Decrypt
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
// Parse JSON
const payload = JSON.parse(decrypted.toString('utf8')) as DecryptedPayload;
return payload;
} catch (error) {
console.error('[Crypto] Decryption failed:', error);
throw new Error('Failed to decrypt push payload');
}
}
/**
* Encrypt data for testing purposes (client-side encryption)
* This is primarily used for development/testing.
* In production, encryption happens on the server.
*/
export async function encryptData(
data: Record<string, unknown>
): Promise<EncryptedPushPayload> {
try {
const deviceSecret = await getDeviceSecret();
// Generate random IV and salt separately (NIST: salt should be at least 128 bits)
const iv = crypto.randomBytes(IV_LENGTH);
const salt = crypto.randomBytes(SALT_LENGTH);
// Derive key using proper random salt (wrap in Buffer for type compatibility)
const key = await deriveKey(deviceSecret, Buffer.from(salt));
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, key, iv) as ReturnType<typeof crypto.createCipheriv> & {
getAuthTag: () => Buffer;
};
// Encrypt
const plaintext = Buffer.from(JSON.stringify(data), 'utf8');
const encrypted = Buffer.concat([
cipher.update(plaintext),
cipher.final(),
]);
// Get auth tag
const authTag = cipher.getAuthTag();
return {
v: 1,
iv: Buffer.from(iv).toString('base64'),
salt: Buffer.from(salt).toString('base64'),
ct: encrypted.toString('base64'),
tag: authTag.toString('base64'),
};
} catch (error) {
console.error('[Crypto] Encryption failed:', error);
throw new Error('Failed to encrypt data');
}
}
/**
* Check if encryption is available on this device
*/
export function isEncryptionAvailable(): boolean {
try {
// Test if crypto functions are available
const testBytes = crypto.randomBytes(16);
return testBytes.length === 16;
} catch {
return false;
}
}
/**
* Clear the device secret (on logout/unpair)
*/
export async function clearDeviceSecret(): Promise<void> {
await SecureStore.deleteItemAsync(DEVICE_SECRET_KEY);
console.log('[Crypto] Cleared device secret');
}

View File

@@ -0,0 +1,366 @@
/**
* Secure storage utilities for mobile app credentials
* Supports multiple server connections with independent credentials
*
* Uses expo-secure-store for all storage (Expo Go compatible)
*/
import * as SecureStore from 'expo-secure-store';
// Keys for secure storage (per-server, uses serverId suffix)
const SECURE_KEYS = {
ACCESS_TOKEN: 'tracearr_access_token',
REFRESH_TOKEN: 'tracearr_refresh_token',
} as const;
// Keys for general storage (JSON-serializable data, stored in SecureStore)
const STORAGE_KEYS = {
SERVERS: 'tracearr_servers',
ACTIVE_SERVER: 'tracearr_active_server',
} as const;
/**
* Server connection info stored in SecureStore
*/
export interface ServerInfo {
id: string; // Unique identifier (from pairing response or generated)
url: string;
name: string;
type: 'plex' | 'jellyfin' | 'emby';
addedAt: string; // ISO date string
}
/**
* Credentials for a specific server (tokens stored in SecureStore)
*/
export interface ServerCredentials {
accessToken: string;
refreshToken: string;
}
/**
* Full server data including credentials
*/
export interface StoredServer extends ServerInfo {
credentials: ServerCredentials;
}
// Helper to get per-server secure key
function getSecureKey(baseKey: string, serverId: string): string {
return `${baseKey}_${serverId}`;
}
export const storage = {
// ============================================================================
// Server List Management
// ============================================================================
/**
* Get all connected servers
*/
async getServers(): Promise<ServerInfo[]> {
const data = await SecureStore.getItemAsync(STORAGE_KEYS.SERVERS);
if (!data) return [];
try {
return JSON.parse(data) as ServerInfo[];
} catch {
return [];
}
},
/**
* Add a new server to the list
*/
async addServer(server: ServerInfo, credentials: ServerCredentials): Promise<void> {
// Store credentials in SecureStore
await Promise.all([
SecureStore.setItemAsync(
getSecureKey(SECURE_KEYS.ACCESS_TOKEN, server.id),
credentials.accessToken
),
SecureStore.setItemAsync(
getSecureKey(SECURE_KEYS.REFRESH_TOKEN, server.id),
credentials.refreshToken
),
]);
// Add server to list
const servers = await this.getServers();
const existingIndex = servers.findIndex((s) => s.id === server.id);
if (existingIndex >= 0) {
servers[existingIndex] = server;
} else {
servers.push(server);
}
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers));
},
/**
* Remove a server and its credentials
*/
async removeServer(serverId: string): Promise<void> {
// Read active server ID BEFORE making any changes to avoid race conditions
const activeId = await this.getActiveServerId();
const servers = await this.getServers();
const filtered = servers.filter((s) => s.id !== serverId);
// Remove credentials from SecureStore
await Promise.all([
SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)),
SecureStore.deleteItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)),
]);
// Remove from server list
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(filtered));
// Update active server if the removed one was active
if (activeId === serverId) {
// Select first remaining server or clear
if (filtered.length > 0) {
await this.setActiveServerId(filtered[0]!.id);
} else {
await SecureStore.deleteItemAsync(STORAGE_KEYS.ACTIVE_SERVER);
}
}
},
/**
* Get a specific server by ID
*/
async getServer(serverId: string): Promise<ServerInfo | null> {
const servers = await this.getServers();
return servers.find((s) => s.id === serverId) ?? null;
},
/**
* Update server info (e.g., name changed)
*/
async updateServer(serverId: string, updates: Partial<Omit<ServerInfo, 'id'>>): Promise<void> {
const servers = await this.getServers();
const index = servers.findIndex((s) => s.id === serverId);
if (index >= 0) {
servers[index] = { ...servers[index]!, ...updates };
await SecureStore.setItemAsync(STORAGE_KEYS.SERVERS, JSON.stringify(servers));
}
},
// ============================================================================
// Active Server Selection
// ============================================================================
/**
* Get the currently active server ID
*/
async getActiveServerId(): Promise<string | null> {
return SecureStore.getItemAsync(STORAGE_KEYS.ACTIVE_SERVER);
},
/**
* Set the active server
*/
async setActiveServerId(serverId: string): Promise<void> {
await SecureStore.setItemAsync(STORAGE_KEYS.ACTIVE_SERVER, serverId);
},
/**
* Get the active server info
*/
async getActiveServer(): Promise<ServerInfo | null> {
const activeId = await this.getActiveServerId();
if (!activeId) return null;
return this.getServer(activeId);
},
// ============================================================================
// Credentials Management (per-server)
// ============================================================================
/**
* Get credentials for a specific server
*/
async getServerCredentials(serverId: string): Promise<ServerCredentials | null> {
const [accessToken, refreshToken] = await Promise.all([
SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId)),
SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId)),
]);
if (!accessToken || !refreshToken) {
return null;
}
return { accessToken, refreshToken };
},
/**
* Update tokens for a specific server (after refresh)
*/
async updateServerTokens(
serverId: string,
accessToken: string,
refreshToken: string
): Promise<void> {
await Promise.all([
SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, serverId), accessToken),
SecureStore.setItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, serverId), refreshToken),
]);
},
/**
* Get access token for active server
*/
async getAccessToken(): Promise<string | null> {
const activeId = await this.getActiveServerId();
if (!activeId) return null;
return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.ACCESS_TOKEN, activeId));
},
/**
* Get refresh token for active server
*/
async getRefreshToken(): Promise<string | null> {
const activeId = await this.getActiveServerId();
if (!activeId) return null;
return SecureStore.getItemAsync(getSecureKey(SECURE_KEYS.REFRESH_TOKEN, activeId));
},
/**
* Get server URL for active server
*/
async getServerUrl(): Promise<string | null> {
const server = await this.getActiveServer();
return server?.url ?? null;
},
/**
* Update tokens for active server
*/
async updateTokens(accessToken: string, refreshToken: string): Promise<void> {
const activeId = await this.getActiveServerId();
if (!activeId) throw new Error('No active server');
await this.updateServerTokens(activeId, accessToken, refreshToken);
},
// ============================================================================
// Migration & Compatibility
// ============================================================================
/**
* Check if using legacy single-server storage and migrate if needed
*/
async migrateFromLegacy(): Promise<boolean> {
// Check for legacy keys
const legacyUrl = await SecureStore.getItemAsync('tracearr_server_url');
const legacyAccess = await SecureStore.getItemAsync('tracearr_access_token');
const legacyRefresh = await SecureStore.getItemAsync('tracearr_refresh_token');
const legacyName = await SecureStore.getItemAsync('tracearr_server_name');
if (legacyUrl && legacyAccess && legacyRefresh) {
// Generate a server ID from the URL
const serverId = Buffer.from(legacyUrl).toString('base64').slice(0, 16);
const serverInfo: ServerInfo = {
id: serverId,
url: legacyUrl,
name: legacyName || 'Tracearr',
type: 'plex', // Assume plex for legacy, will update on next sync
addedAt: new Date().toISOString(),
};
// Add server with credentials
await this.addServer(serverInfo, {
accessToken: legacyAccess,
refreshToken: legacyRefresh,
});
// Set as active
await this.setActiveServerId(serverId);
// Clean up legacy keys
await Promise.all([
SecureStore.deleteItemAsync('tracearr_server_url'),
SecureStore.deleteItemAsync('tracearr_access_token'),
SecureStore.deleteItemAsync('tracearr_refresh_token'),
SecureStore.deleteItemAsync('tracearr_server_name'),
]);
return true;
}
return false;
},
// ============================================================================
// Legacy Compatibility (for existing code during transition)
// ============================================================================
/**
* @deprecated Use getServers() and getServerCredentials() instead
* Get stored credentials for active server (legacy compatibility)
*/
async getCredentials(): Promise<{
serverUrl: string;
accessToken: string;
refreshToken: string;
serverName: string;
} | null> {
const server = await this.getActiveServer();
if (!server) return null;
const credentials = await this.getServerCredentials(server.id);
if (!credentials) return null;
return {
serverUrl: server.url,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
serverName: server.name,
};
},
/**
* @deprecated Use addServer() instead
* Store credentials (legacy compatibility - adds/updates single server)
*/
async storeCredentials(credentials: {
serverUrl: string;
accessToken: string;
refreshToken: string;
serverName: string;
}): Promise<void> {
// Generate ID from URL for consistency
const serverId = Buffer.from(credentials.serverUrl).toString('base64').slice(0, 16);
const serverInfo: ServerInfo = {
id: serverId,
url: credentials.serverUrl,
name: credentials.serverName,
type: 'plex', // Will be updated on server sync
addedAt: new Date().toISOString(),
};
await this.addServer(serverInfo, {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
});
await this.setActiveServerId(serverId);
},
/**
* @deprecated Use removeServer() for specific server
* Clear all credentials (legacy compatibility - removes active server)
*/
async clearCredentials(): Promise<void> {
const activeId = await this.getActiveServerId();
if (activeId) {
await this.removeServer(activeId);
}
},
/**
* Check if user is authenticated (has at least one server)
*/
async isAuthenticated(): Promise<boolean> {
const servers = await this.getServers();
return servers.length > 0;
},
};

View File

@@ -0,0 +1,167 @@
/**
* Design system tokens matching Tracearr web app
* These values match the web's dark mode theme exactly
*/
export const colors = {
// Brand colors (from Tracearr web)
cyan: {
core: '#18D1E7',
deep: '#0EAFC8',
dark: '#0A7C96',
},
blue: {
core: '#0B1A2E',
steel: '#162840',
soft: '#1E3A5C',
},
// Background colors - matching web dark mode
background: {
dark: '#050A12',
light: '#F9FAFB',
},
card: {
dark: '#0B1A2E',
light: '#FFFFFF',
},
surface: {
dark: '#0F2338',
light: '#F3F4F6',
},
// Accent colors
orange: {
core: '#F97316',
},
purple: '#8B5CF6',
// Status colors
success: '#22C55E',
warning: '#F59E0B',
error: '#EF4444',
danger: '#EF4444',
info: '#3B82F6',
// Switch/toggle colors - matching web dark mode border
switch: {
trackOff: '#162840',
trackOn: '#0EAFC8',
thumbOn: '#18D1E7',
thumbOff: '#64748B',
},
// Text colors - matching web dark mode (CSS: --color-*)
text: {
// --color-foreground / --color-card-foreground
primary: {
dark: '#FFFFFF',
light: '#0B1A2E',
},
// --color-muted-foreground (used for secondary/muted text)
secondary: {
dark: '#94A3B8',
light: '#64748B',
},
// Alias for secondary - use this for muted text in inline styles
muted: {
dark: '#94A3B8',
light: '#64748B',
},
},
// Icon colors - matching web dark mode (CSS: --color-icon-*)
icon: {
default: '#8CA3B8',
active: '#18D1E7',
danger: '#FF4C4C',
},
// Border colors - matching web dark mode (blue-steel)
border: {
dark: '#162840',
light: '#E5E7EB',
},
// Chart colors
chart: ['#18D1E7', '#0EAFC8', '#1E3A5C', '#F59E0B', '#EF4444', '#22C55E'],
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
} as const;
export const borderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16,
full: 9999,
} as const;
export const typography = {
fontFamily: {
regular: 'System',
medium: 'System',
bold: 'System',
},
fontSize: {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
'2xl': 24,
'3xl': 30,
'4xl': 36,
},
lineHeight: {
tight: 1.25,
normal: 1.5,
relaxed: 1.75,
},
} as const;
export const shadows = {
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 3,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.15,
shadowRadius: 15,
elevation: 5,
},
} as const;
// Helper function to get theme-aware colors
export function getThemeColor(
colorKey: 'background' | 'card' | 'surface' | 'border',
isDark: boolean
): string {
return colors[colorKey][isDark ? 'dark' : 'light'];
}
export function getTextColor(
variant: 'primary' | 'secondary' | 'muted',
isDark: boolean
): string {
return colors.text[variant][isDark ? 'dark' : 'light'];
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,153 @@
/**
* Media Server selection provider
* Fetches available servers from Tracearr API and manages selection
* Similar to web's useServer hook
*/
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
type ReactNode,
} from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import * as SecureStore from 'expo-secure-store';
import type { Server } from '@tracearr/shared';
import { api } from '../lib/api';
import { useAuthStore } from '../lib/authStore';
const SELECTED_SERVER_KEY = 'tracearr_selected_media_server';
interface MediaServerContextValue {
servers: Server[];
selectedServer: Server | null;
selectedServerId: string | null;
isLoading: boolean;
selectServer: (serverId: string | null) => void;
refetch: () => Promise<unknown>;
}
const MediaServerContext = createContext<MediaServerContextValue | null>(null);
export function MediaServerProvider({ children }: { children: ReactNode }) {
const { isAuthenticated, activeServerId: tracearrBackendId } = useAuthStore();
const queryClient = useQueryClient();
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// Load saved selection on mount
useEffect(() => {
void SecureStore.getItemAsync(SELECTED_SERVER_KEY).then((saved) => {
if (saved) {
setSelectedServerId(saved);
}
setInitialized(true);
});
}, []);
// Fetch available servers from API
const {
data: servers = [],
isLoading,
refetch,
} = useQuery({
queryKey: ['media-servers', tracearrBackendId],
queryFn: () => api.servers.list(),
enabled: isAuthenticated && !!tracearrBackendId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Validate selection when servers load
useEffect(() => {
if (!initialized || isLoading) return;
if (servers.length === 0) {
if (selectedServerId) {
setSelectedServerId(null);
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
}
return;
}
// If selection is invalid (server no longer exists), select first
if (selectedServerId && !servers.some((s) => s.id === selectedServerId)) {
const firstServer = servers[0];
if (firstServer) {
setSelectedServerId(firstServer.id);
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id);
}
}
// If no selection but servers exist, select first
if (!selectedServerId && servers.length > 0) {
const firstServer = servers[0];
if (firstServer) {
setSelectedServerId(firstServer.id);
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, firstServer.id);
}
}
}, [servers, selectedServerId, initialized, isLoading]);
// Clear selection on logout
useEffect(() => {
if (!isAuthenticated) {
setSelectedServerId(null);
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
}
}, [isAuthenticated]);
const selectServer = useCallback(
(serverId: string | null) => {
setSelectedServerId(serverId);
if (serverId) {
void SecureStore.setItemAsync(SELECTED_SERVER_KEY, serverId);
} else {
void SecureStore.deleteItemAsync(SELECTED_SERVER_KEY);
}
// Invalidate all server-dependent queries
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey[0];
return key !== 'media-servers' && key !== 'servers';
},
});
},
[queryClient]
);
const selectedServer = useMemo(() => {
if (!selectedServerId) return null;
return servers.find((s) => s.id === selectedServerId) ?? null;
}, [servers, selectedServerId]);
const value = useMemo<MediaServerContextValue>(
() => ({
servers,
selectedServer,
selectedServerId,
isLoading,
selectServer,
refetch,
}),
[servers, selectedServer, selectedServerId, isLoading, selectServer, refetch]
);
return (
<MediaServerContext.Provider value={value}>{children}</MediaServerContext.Provider>
);
}
export function useMediaServer(): MediaServerContextValue {
const context = useContext(MediaServerContext);
if (!context) {
throw new Error('useMediaServer must be used within a MediaServerProvider');
}
return context;
}
export function useSelectedServerId(): string | null {
const { selectedServerId } = useMediaServer();
return selectedServerId;
}

View File

@@ -0,0 +1,32 @@
/**
* React Query provider for data fetching
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 2,
refetchOnWindowFocus: true,
},
mutations: {
retry: 1,
},
},
});
interface QueryProviderProps {
children: React.ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
export { queryClient };

View File

@@ -0,0 +1,160 @@
/**
* Socket.io provider for real-time updates
* Connects to Tracearr backend and invalidates queries on events
*/
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import { AppState } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { useQueryClient } from '@tanstack/react-query';
import { storage } from '../lib/storage';
import { useAuthStore } from '../lib/authStore';
import type {
ServerToClientEvents,
ClientToServerEvents,
ActiveSession,
ViolationWithDetails,
DashboardStats,
} from '@tracearr/shared';
interface SocketContextValue {
socket: Socket<ServerToClientEvents, ClientToServerEvents> | null;
isConnected: boolean;
}
const SocketContext = createContext<SocketContextValue>({
socket: null,
isConnected: false,
});
export function useSocket() {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
}
export function SocketProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated, activeServerId, serverUrl } = useAuthStore();
const queryClient = useQueryClient();
const socketRef = useRef<Socket<ServerToClientEvents, ClientToServerEvents> | null>(null);
const [isConnected, setIsConnected] = useState(false);
// Track which Tracearr backend we're connected to
const connectedServerIdRef = useRef<string | null>(null);
const connectSocket = useCallback(async () => {
if (!isAuthenticated || !serverUrl || !activeServerId) return;
// If already connected to this backend, skip
if (connectedServerIdRef.current === activeServerId && socketRef.current?.connected) {
return;
}
// Disconnect existing socket if connected to different backend
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
const credentials = await storage.getServerCredentials(activeServerId);
if (!credentials) return;
connectedServerIdRef.current = activeServerId;
const newSocket: Socket<ServerToClientEvents, ClientToServerEvents> = io(serverUrl, {
auth: { token: credentials.accessToken },
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
newSocket.on('connect', () => {
console.log('Socket connected');
setIsConnected(true);
// Subscribe to session updates
newSocket.emit('subscribe:sessions');
});
newSocket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
setIsConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('Socket connection error:', error.message);
setIsConnected(false);
});
// Handle real-time events
// Use partial query keys to invalidate ALL cached data regardless of selected media server
// This matches the web app pattern where socket events invalidate all server-filtered caches
newSocket.on('session:started', (_session: ActiveSession) => {
// Invalidate all active sessions caches (any server filter)
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
newSocket.on('session:stopped', (_sessionId: string) => {
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
newSocket.on('session:updated', (_session: ActiveSession) => {
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
});
newSocket.on('violation:new', (_violation: ViolationWithDetails) => {
void queryClient.invalidateQueries({ queryKey: ['violations'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
newSocket.on('stats:updated', (_stats: DashboardStats) => {
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
});
socketRef.current = newSocket;
}, [isAuthenticated, serverUrl, activeServerId, queryClient]);
// Connect/disconnect based on auth state
useEffect(() => {
if (isAuthenticated && serverUrl && activeServerId) {
void connectSocket();
} else if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
connectedServerIdRef.current = null;
setIsConnected(false);
}
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
connectedServerIdRef.current = null;
}
};
}, [isAuthenticated, serverUrl, activeServerId, connectSocket]);
// Handle app state changes (background/foreground)
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === 'active' && isAuthenticated && !isConnected) {
// Reconnect when app comes to foreground
void connectSocket();
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription.remove();
}, [isAuthenticated, isConnected, connectSocket]);
return (
<SocketContext.Provider value={{ socket: socketRef.current, isConnected }}>
{children}
</SocketContext.Provider>
);
}

32
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@tracearr/shared": [
"../../packages/shared/src"
]
},
"types": [
"expo-router/types",
"node"
],
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": [
"node_modules"
]
}