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
53 changes: 40 additions & 13 deletions apps/mobile/app/settings/notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,48 @@ import {
type NotificationPreferences,
setPreferences,
} from '@services/notifications/notificationPreferences';
import { useWalletStore } from '@store/useStore';
import { cancelAllHuntExpiryNotifications } from '@utils/huntNotifications';
import React, { useCallback, useEffect, useState } from 'react';
import { Linking, Platform,ScrollView, StyleSheet, Text, View } from 'react-native';
import { Linking, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';

export default function NotificationsScreen() {
const { colors } = useTheme();
const { enabled, permissionStatus, loading, toggle } = useNotifications();
const walletAddress = useWalletStore((state) => state.walletAddress);
const [prefs, setPrefs] = useState<NotificationPreferences>(DEFAULT_PREFERENCES);

// Hydrate preferences from storage on mount
// Hydrate the local copy, then replace it with the wallet-scoped server
// copy. This makes the mobile screen reflect changes made on web or another
// phone while retaining offline support.
useEffect(() => {
getPreferences().then(setPrefs);
}, []);
let cancelled = false;
getPreferences(walletAddress || undefined).then((nextPrefs) => {
if (!cancelled) setPrefs(nextPrefs);
});
return () => {
cancelled = true;
};
}, [walletAddress]);

/** Persist a partial preference update. */
const updatePref = useCallback(
async (key: keyof NotificationPreferences, value: boolean) => {
const updated = { ...prefs, [key]: value };
setPrefs(updated);
await setPreferences(updated);

if (!value && (key === 'enabled' || key === 'huntEvents')) {
try {
await cancelAllHuntExpiryNotifications();
} catch {
// Preference changes should still persist if a scheduled reminder
// cannot be inspected on this device.
}
}

await setPreferences(updated, walletAddress || undefined);
},
[prefs],
[prefs, walletAddress],
);

/** Handle the master toggle — requests OS permission when enabling. */
Expand Down Expand Up @@ -67,17 +88,23 @@ export default function NotificationsScreen() {
<SettingsSection title="General">
<SettingsRow
icon="notifications-outline"
label="Push Notifications"
label="All Notifications"
description={
permissionStatus === 'denied'
? 'Permission denied — tap to open Settings'
: 'Receive push notifications from Hunty'
prefs.enabled ? 'Receive notifications from Hunty' : 'All notifications are muted'
}
type={permissionStatus === 'denied' ? 'navigate' : 'toggle'}
value={enabled && prefs.enabled}
type="toggle"
value={prefs.enabled}
onToggle={handleMasterToggle}
onPress={permissionStatus === 'denied' ? openDeviceSettings : undefined}
/>
{permissionStatus === 'denied' && (
<SettingsRow
icon="settings-outline"
label="Device Permission"
description="Permission denied — tap to open Settings"
type="navigate"
onPress={openDeviceSettings}
/>
)}
</SettingsSection>

{/* Hunt events */}
Expand Down
15 changes: 14 additions & 1 deletion apps/mobile/providers/NotificationsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
*/

import { incrementBadge, resetBadge } from '@services/notifications/badgeService';
import { shouldShowNotification } from '@services/notifications/notificationPreferences';
import {
getPreferences,
shouldShowNotification,
} from '@services/notifications/notificationPreferences';
import {
configureNotificationHandler,
ensureAndroidChannel,
Expand All @@ -30,6 +33,8 @@ import { useRouter } from 'expo-router';
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { AppState, type AppStateStatus } from 'react-native';

import { useWalletStore } from '@store/useStore';

// ─── Context (exposed for convenience hooks) ──────────────────────────────────

interface NotificationsContextValue {
Expand All @@ -48,6 +53,14 @@ export function useNotificationsContext(): NotificationsContextValue {
export const NotificationsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const router = useRouter();
const isListeningRef = useRef(false);
const walletAddress = useWalletStore((state) => state.walletAddress);

// Hydrate the device cache when a wallet is restored or connected. The
// foreground handler can then apply the same preferences before the user
// opens the settings screen.
useEffect(() => {
void getPreferences(walletAddress || undefined);
}, [walletAddress]);

useEffect(() => {
// Configure handler as early as possible so foreground notifications show
Expand Down
130 changes: 94 additions & 36 deletions apps/mobile/services/notifications/notificationPreferences.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* Notification preferences for Hunty Mobile.
*
* Stores per-category notification preferences in AsyncStorage. Each category
* maps to one or more NotificationEventType values. The preferences are checked
* by the NotificationsProvider before displaying a foreground notification.
* The local copy keeps the app usable offline. When a wallet address is
* supplied, reads and writes also use the wallet-scoped v1 API so the same
* category choices are available on every device.
*/

import AsyncStorage from '@react-native-async-storage/async-storage';

import env from '@config/env';

import type { NotificationEventType } from './types';

const PREFS_KEY = 'hunty_notification_prefs';
Expand Down Expand Up @@ -36,56 +38,112 @@ export const DEFAULT_PREFERENCES: NotificationPreferences = {
achievements: true,
};

// ─── Mapping from event type to preference category ───────────────────────────

const EVENT_TO_CATEGORY: Record<
NotificationEventType,
keyof Omit<NotificationPreferences, 'enabled'>
> = {
hunt_start: 'huntEvents',
hunt_ending_soon: 'huntEvents',
reward: 'rewards',
correct_answer: 'rewards',
leaderboard_outranked: 'social',
achievement: 'achievements',
};

// ─── Public API ───────────────────────────────────────────────────────────────
const PREFERENCES_ENDPOINT = `${env.apiUrl}/v1/notifications/preferences`;

function normalizePreferences(
value: Partial<NotificationPreferences> | null | undefined,
): NotificationPreferences {
const input = value ?? {};
return {
enabled: typeof input.enabled === 'boolean' ? input.enabled : DEFAULT_PREFERENCES.enabled,
huntEvents:
typeof input.huntEvents === 'boolean' ? input.huntEvents : DEFAULT_PREFERENCES.huntEvents,
rewards: typeof input.rewards === 'boolean' ? input.rewards : DEFAULT_PREFERENCES.rewards,
social: typeof input.social === 'boolean' ? input.social : DEFAULT_PREFERENCES.social,
achievements:
typeof input.achievements === 'boolean'
? input.achievements
: DEFAULT_PREFERENCES.achievements,
};
}

/**
* Retrieve the saved notification preferences, falling back to defaults.
*/
export async function getPreferences(): Promise<NotificationPreferences> {
async function getLocalPreferences(): Promise<NotificationPreferences> {
try {
const raw = await AsyncStorage.getItem(PREFS_KEY);
if (!raw) return { ...DEFAULT_PREFERENCES };

const parsed = JSON.parse(raw) as Partial<NotificationPreferences>;
return { ...DEFAULT_PREFERENCES, ...parsed };
return normalizePreferences(JSON.parse(raw) as Partial<NotificationPreferences>);
} catch {
return { ...DEFAULT_PREFERENCES };
}
}

/**
* Persist notification preferences.
*/
export async function setPreferences(prefs: NotificationPreferences): Promise<void> {
async function persistLocalPreferences(prefs: NotificationPreferences): Promise<void> {
try {
await AsyncStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch {
if (__DEV__) console.warn('[NotificationPreferences] Failed to save preferences');
}
}

/** Retrieve the saved preferences, optionally hydrating from the wallet API. */
export async function getPreferences(walletAddress?: string): Promise<NotificationPreferences> {
const local = await getLocalPreferences();
if (!walletAddress) return local;

try {
const response = await fetch(
`${PREFERENCES_ENDPOINT}?walletAddress=${encodeURIComponent(walletAddress)}`,
{ headers: { Accept: 'application/json' } },
);
if (!response.ok) return local;

const body = (await response.json()) as {
preferences?: Partial<NotificationPreferences>;
};
if (!body.preferences) return local;

const serverPreferences = normalizePreferences(body.preferences);
await persistLocalPreferences(serverPreferences);
return serverPreferences;
} catch {
// Offline or an unavailable API should not prevent local notifications.
return local;
}
}

/**
* Check whether a notification of the given type should be shown, based on the
* user's saved preferences.
*
* Returns false if:
* - The master toggle is off.
* - The category for the given type is disabled.
* - The event type is unknown (defensive fallback).
* Persist notification preferences locally and, when a wallet is supplied,
* sync the complete category document to the server.
*/
export async function setPreferences(
prefs: NotificationPreferences,
walletAddress?: string,
): Promise<void> {
const normalized = normalizePreferences(prefs);
await persistLocalPreferences(normalized);

if (!walletAddress) return;

try {
await fetch(PREFERENCES_ENDPOINT, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ walletAddress, preferences: normalized }),
});
} catch {
// The local value remains available and will be retried on the next edit.
}
}

// ─── Mapping from event type to preference category ───────────────────────────

const EVENT_TO_CATEGORY: Record<
NotificationEventType,
keyof Omit<NotificationPreferences, 'enabled'>
> = {
hunt_start: 'huntEvents',
hunt_ending_soon: 'huntEvents',
reward: 'rewards',
correct_answer: 'rewards',
leaderboard_outranked: 'social',
achievement: 'achievements',
};

// ─── Public filtering API ─────────────────────────────────────────────────────

/**
* Check whether a notification of the given type should be shown.
* Unknown event types are rejected defensively.
*/
export async function shouldShowNotification(type: string): Promise<boolean> {
const prefs = await getPreferences();
Expand Down
32 changes: 22 additions & 10 deletions apps/mobile/services/notifications/notificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';

import { incrementBadge } from './badgeService';
import { shouldShowNotification } from './notificationPreferences';
import type { NotificationPayload } from './types';

// ─── Background task ─────────────────────────────────────────────────────────
Expand All @@ -31,8 +32,14 @@ TaskManager.defineTask(BACKGROUND_NOTIFICATION_TASK, async ({ data, error }) =>
}

if (data) {
// Increment the badge count for every background notification
await incrementBadge();
const notificationType =
typeof data === 'object' && data !== null && 'type' in data
? String((data as { type: unknown }).type)
: null;
const shouldShow = notificationType ? await shouldShowNotification(notificationType) : true;

// A muted notification should not increment the app badge either.
if (shouldShow) await incrementBadge();
}
});

Expand All @@ -59,13 +66,18 @@ export async function registerBackgroundNotificationTask(): Promise<void> {
*/
export function configureNotificationHandler(): void {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
handleNotification: async (notification) => {
const payload = extractPayload(notification);
const shouldShow = payload ? await shouldShowNotification(payload.type) : true;

return {
shouldShowAlert: shouldShow,
shouldPlaySound: shouldShow,
shouldSetBadge: shouldShow,
shouldShowBanner: shouldShow,
shouldShowList: shouldShow,
};
},
});
}

Expand Down Expand Up @@ -121,7 +133,7 @@ export async function requestPermission(): Promise<PermissionStatus> {
export function extractPayload(
notification: Notifications.Notification,
): NotificationPayload | null {
const data = notification.request.content.data as Record<string, unknown> | null;
const data = notification?.request?.content?.data as Record<string, unknown> | null | undefined;
if (!data || typeof data.type !== 'string') return null;

return data as unknown as NotificationPayload;
Expand Down
19 changes: 19 additions & 0 deletions apps/mobile/utils/huntNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as Notifications from 'expo-notifications';

import { shouldShowNotification } from '@services/notifications/notificationPreferences';

const NOTIF_ID_PREFIX = 'hunt_expiry_';

/**
Expand All @@ -12,6 +14,12 @@ export async function scheduleHuntExpiryNotification(
huntTitle: string,
endTimeSeconds: number,
): Promise<void> {
// Cancel an already scheduled reminder when the player mutes hunt events.
if (!(await shouldShowNotification('hunt_ending_soon'))) {
await cancelHuntExpiryNotification(huntId);
return;
}

const triggerAt = (endTimeSeconds - 3600) * 1000; // 1 hour before, in ms
if (triggerAt <= Date.now()) return;

Expand All @@ -32,3 +40,14 @@ export async function scheduleHuntExpiryNotification(
export async function cancelHuntExpiryNotification(huntId: number): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(`${NOTIF_ID_PREFIX}${huntId}`);
}

/** Cancel every locally scheduled hunt reminder when the category is muted. */
export async function cancelAllHuntExpiryNotifications(): Promise<void> {
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
await Promise.all(
scheduled
.map((notification) => notification.identifier)
.filter((identifier) => identifier.startsWith(NOTIF_ID_PREFIX))
.map((identifier) => Notifications.cancelScheduledNotificationAsync(identifier)),
);
}
1 change: 1 addition & 0 deletions apps/web/app/api/hunts/schedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function POST() {
return sendHuntStartReminder({
hunt,
recipientEmail,
recipientWalletAddress: hunt.creator,
startTime: hunt.startAt ?? hunt.startTime ?? Math.floor(Date.now() / 1000),
})
})
Expand Down
Loading