Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 50 additions & 66 deletions frontend/app/settings/notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,62 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView, ScrollView, Switch } from 'react-native';
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Switch } from 'react-native';
import { router } from 'expo-router';
import { ArrowLeft, Bell, MessageCircle, Users, Calendar } from 'lucide-react-native';
import { ArrowLeft, Bell, MessageCircle, Users, Calendar, UserPlus } from 'lucide-react-native';
import { NotificationSettings } from '@/types/notifications';
import { useNotificationSettings } from '@/hooks/use-notification-settings';
import { SafeAreaView } from 'react-native-safe-area-context';

interface NotificationSetting {
id: string;
id: NotificationSettings;
title: string;
description: string;
icon: any;
enabled: boolean;
color: string;
}

const DEFAULT_SETTINGS: NotificationSetting[] = [
{
id: NotificationSettings.PUSH_NOTIFICATIONS,
title: 'Push Notifications',
description: 'Receive notifications on your device',
icon: Bell,
enabled: true,
color: '#8B5CF6',
},
{
id: NotificationSettings.FRIEND_ACTIVITY,
title: 'Friend Activity',
description: 'When friends share moments with you',
icon: Users,
enabled: true,
color: '#059669',
},
{
id: NotificationSettings.ENTRY_REMINDER,
title: 'Memory Reminders',
description: 'Daily prompts to capture moments',
icon: Calendar,
enabled: false,
color: '#F59E0B',
},
{
id: NotificationSettings.FRIEND_REQUESTS,
title: 'Friend Requests',
description: 'When someone sends you a friend request or accepts your request',
icon: UserPlus,
enabled: true,
color: '#EF4444',
},
];

export default function NotificationsScreen() {
const [settings, setSettings] = useState<NotificationSetting[]>([
{
id: 'push',
title: 'Push Notifications',
description: 'Receive notifications on your device',
icon: Bell,
enabled: true,
color: '#8B5CF6',
},
{
id: 'friends',
title: 'Friend Activity',
description: 'When friends share moments with you',
icon: Users,
enabled: true,
color: '#059669',
},
{
id: 'memories',
title: 'Memory Reminders',
description: 'Daily prompts to capture moments',
icon: Calendar,
enabled: false,
color: '#F59E0B',
},
{
id: 'comments',
title: 'Comments & Reactions',
description: 'When someone reacts to your moments',
icon: MessageCircle,
enabled: true,
color: '#EF4444',
},
]);
const { settings: settingsMap, toggleSetting } = useNotificationSettings();

const toggleSetting = (id: string) => {
setSettings(prev => {
if (id === 'push') {
// If turning off push notifications, turn off all others
const pushEnabled = !prev.find(s => s.id === 'push')?.enabled;
if (!pushEnabled) {
return prev.map(setting => ({ ...setting, enabled: false }));
} else {
return prev.map(setting =>
setting.id === 'push' ? { ...setting, enabled: true } : setting
);
}
} else {
// For other settings, only allow toggle if push is enabled
const pushEnabled = prev.find(s => s.id === 'push')?.enabled;
if (!pushEnabled) {
return prev; // Don't allow changes if push is disabled
}

return prev.map(setting =>
setting.id === id
? { ...setting, enabled: !setting.enabled }
: setting
);
}
});
};
const settings: NotificationSetting[] = DEFAULT_SETTINGS.map((setting) => ({
...setting,
enabled: settingsMap[setting.id] ?? setting.enabled,
}));

return (
<SafeAreaView style={styles.container}>
Expand All @@ -100,6 +82,8 @@ export default function NotificationsScreen() {
<View style={styles.settingsContainer}>
{settings.map((setting) => {
const IconComponent = setting.icon;
const pushEnabled = settingsMap[NotificationSettings.PUSH_NOTIFICATIONS];

return (
<View key={setting.id} style={styles.settingItem}>
<View style={[styles.iconContainer, { backgroundColor: `${setting.color}15` }]}>
Expand All @@ -114,7 +98,7 @@ export default function NotificationsScreen() {
<Switch
value={setting.enabled}
onValueChange={() => toggleSetting(setting.id)}
disabled={setting.id !== 'push' && !settings.find(s => s.id === 'push')?.enabled}
disabled={setting.id !== NotificationSettings.PUSH_NOTIFICATIONS && !pushEnabled}
trackColor={{ false: '#E5E7EB', true: '#C7D2FE' }}
thumbColor={setting.enabled ? '#8B5CF6' : '#F3F4F6'}
/>
Expand Down
13 changes: 12 additions & 1 deletion frontend/constants/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const TABLES = {
INVITES: 'invites',
ENTRY_REACTIONS: 'entry_reactions',
ENTRY_COMMENTS: 'entry_comments',
PUSH_TOKENS: 'push_tokens'
PUSH_TOKENS: 'push_tokens',
NOTIFICATION_SETTINGS: 'notification_settings',
} as const;

// Storage Bucket Names
Expand Down Expand Up @@ -99,5 +100,15 @@ export const SCHEMA = {
device_id: 'text',
created_at: 'timestamptz DEFAULT now()',
updated_at: 'timestamptz DEFAULT now()',
},
NOTIFICATION_SETTINGS: {
id: 'bigserial PRIMARY KEY',
user_id: 'uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE',
friend_requests: 'boolean NOT NULL DEFAULT true',
push_notifications: 'boolean NOT NULL DEFAULT true',
entry_reminder: 'boolean NOT NULL DEFAULT false',
friend_activity: 'boolean NOT NULL DEFAULT true',
created_at: 'timestamptz DEFAULT now()',
updated_at: 'timestamptz DEFAULT now()',
}
} as const;
125 changes: 125 additions & 0 deletions frontend/hooks/use-notification-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthContext } from '@/providers/auth-provider';
import {
PushNotificationService,
NotificationSettingsMap,
} from '@/services/push-notification-service';
import { NotificationSettings } from '@/types/notifications';

const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettingsMap = {
[NotificationSettings.PUSH_NOTIFICATIONS]: true,
[NotificationSettings.FRIEND_ACTIVITY]: true,
[NotificationSettings.ENTRY_REMINDER]: false,
[NotificationSettings.FRIEND_REQUESTS]: true,
};

interface UseNotificationSettingsResult {
settings: NotificationSettingsMap;
isLoading: boolean;
isSaving: boolean;
error: Error | null;
toggleSetting: (id: NotificationSettings) => void;
}

export function useNotificationSettings(): UseNotificationSettingsResult {
const { user } = useAuthContext();
const queryClient = useQueryClient();

const queryKey = useMemo(
() => ['notification-settings', user?.id],
[user?.id],
);

const {
data,
isLoading,
error,
} = useQuery<NotificationSettingsMap>({
queryKey,
enabled: !!user?.id,
queryFn: async () => {
if (!user?.id) return DEFAULT_NOTIFICATION_SETTINGS;

const stored = await PushNotificationService.getNotificationSettings(user.id);
return stored ?? DEFAULT_NOTIFICATION_SETTINGS;
},
});

const { mutate: saveSettings, isPending: isSaving, error: mutationError } = useMutation({
mutationFn: async (next: NotificationSettingsMap) => {
if (!user?.id) return;
await PushNotificationService.saveNotificationSettings(user.id, next);
return next;
},
onMutate: async (next: NotificationSettingsMap) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<NotificationSettingsMap>(queryKey);

queryClient.setQueryData<NotificationSettingsMap>(queryKey, next);

return { previous };
},
onError: (_err, _next, context) => {
if (context?.previous) {
queryClient.setQueryData<NotificationSettingsMap>(queryKey, context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey });
},
});
Comment on lines +49 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Optimistic rollback won’t happen unless saveNotificationSettings throws on failure.

onError rollback only runs if the mutation rejects; with the current service implementation, Supabase failures are logged but not thrown. Fixing the service to throw (or returning a failure result and throwing here) will make this hook behave as intended.

Also applies to: 120-121

🤖 Prompt for AI Agents
In frontend/hooks/use-notification-settings.ts around lines 49-71 (also apply
similar fix at lines ~120-121), the optimistic rollback in onError will never
run if PushNotificationService.saveNotificationSettings does not throw on
failure; update the code so failures produce a rejected mutation: either modify
PushNotificationService.saveNotificationSettings to throw when the Supabase call
fails, or (if you prefer not to change the service) change the mutationFn to
inspect the service result and throw an Error when it indicates failure so the
mutation rejects and onError can restore the previous state.


const currentSettings: NotificationSettingsMap = data ?? DEFAULT_NOTIFICATION_SETTINGS;

const toggleSetting = (id: NotificationSettings) => {
if (!user?.id) return;

const isPushToggle = id === NotificationSettings.PUSH_NOTIFICATIONS;
const pushCurrentlyEnabled = currentSettings[NotificationSettings.PUSH_NOTIFICATIONS];

let next: NotificationSettingsMap = { ...currentSettings };

if (isPushToggle) {
const nextPushEnabled = !pushCurrentlyEnabled;

if (!nextPushEnabled) {
// Turning off push disables all notifications
next = {
[NotificationSettings.PUSH_NOTIFICATIONS]: false,
[NotificationSettings.FRIEND_ACTIVITY]: false,
[NotificationSettings.ENTRY_REMINDER]: false,
[NotificationSettings.FRIEND_REQUESTS]: false,
};
} else {
// Turning on push only enables the push toggle, keep others as-is
next = {
...currentSettings,
[NotificationSettings.PUSH_NOTIFICATIONS]: true,
};
}
} else {
// For other settings, only allow toggle if push is enabled
if (!pushCurrentlyEnabled) {
return;
}

next = {
...currentSettings,
[id]: !currentSettings[id],
};
}

saveSettings(next);
};

return {
settings: currentSettings,
isLoading,
isSaving,
error: (error as Error | null) ?? (mutationError as Error | null) ?? null,
toggleSetting,
};
}


Loading