Files
Tracearr/apps/mobile/app/settings/notifications.tsx
Rephl3x 3015f48118
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
Initial Upload
2025-12-17 12:32:50 +13:00

539 lines
17 KiB
TypeScript

/**
* 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>
);
}