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
539 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|