diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 78dd9925..95eb52a2 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, + AppState, Platform, StyleSheet, Text, @@ -22,7 +23,8 @@ import { DEFAULT_RELAY_BASE_URL, disablePushForAccount, getStoredRelayBaseUrl, - isPushEnabledForAccount, + isPushActiveForAccount, + shouldRecheckPushState, isPushSupported, isValidRelayUrl, listPushDevices, @@ -118,11 +120,25 @@ export function NotificationSettings() { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + // Read again when the app comes back to the front: notifications may have + // been turned off in the phone's settings meanwhile (webmail #1148). + const [recheck, setRecheck] = useState(0); + const pushStatusKind = React.useRef(pushStatus.kind); + pushStatusKind.current = pushStatus.kind; + useEffect(() => { + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active' && shouldRecheckPushState(pushStatusKind.current)) setRecheck((n) => n + 1); + }); + return () => sub.remove(); + }, []); + useEffect(() => { let cancelled = false; void (async () => { const stored = await getStoredRelayBaseUrl(); - const enabled = activeAccountId ? await isPushEnabledForAccount(activeAccountId) : false; + const enabled = activeAccountId ? await isPushActiveForAccount(activeAccountId) : false; + // An enable or disable that started meanwhile has the final word. + if (!shouldRecheckPushState(pushStatusKind.current)) return; if (cancelled) return; if (stored) setRelayUrl(stored); setPushEnabled(enabled); @@ -137,7 +153,7 @@ export function NotificationSettings() { })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeAccountId]); + }, [activeAccountId, recheck]); const trimmed = relayUrl.trim().replace(/\/+$/, ''); const relayValid = isValidRelayUrl(trimmed); diff --git a/src/lib/__tests__/push-recheck.test.ts b/src/lib/__tests__/push-recheck.test.ts new file mode 100644 index 00000000..67e5dfe8 --- /dev/null +++ b/src/lib/__tests__/push-recheck.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Notifications from 'expo-notifications'; +import { isPushActiveForAccount, shouldRecheckPushState } from '../push-notifications'; + +// Settings > Notifications read the push state once, when it opened, and +// read only whether a subscription was stored: notifications turned off in +// the phone's settings meanwhile still showed as on (webmail #1148). + +const ACCOUNT = 'ada@mail.example.com'; + +beforeEach(async () => { + await AsyncStorage.clear(); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ granted: true } as never); +}); + +describe('push state', () => { + it('reads as off once notifications are turned off in the system', async () => { + await AsyncStorage.setItem(`push:subscriptionId:v2:${ACCOUNT}`, 'sub-1'); + await expect(isPushActiveForAccount(ACCOUNT)).resolves.toBe(true); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ granted: false } as never); + await expect(isPushActiveForAccount(ACCOUNT)).resolves.toBe(false); + }); + + it('reads a failed check as off, and an account without push as off', async () => { + await expect(isPushActiveForAccount(ACCOUNT)).resolves.toBe(false); + await AsyncStorage.setItem(`push:subscriptionId:v2:${ACCOUNT}`, 'sub-1'); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValue(new Error('no bridge')); + await expect(isPushActiveForAccount(ACCOUNT)).resolves.toBe(false); + }); + + it('is read again on return unless an enable or disable runs or just failed', () => { + expect(shouldRecheckPushState('enabled')).toBe(true); + expect(shouldRecheckPushState('idle')).toBe(true); + expect(shouldRecheckPushState('busy')).toBe(false); + expect(shouldRecheckPushState('error')).toBe(false); + }); +}); diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index defeda52..c9a61d50 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -793,6 +793,31 @@ export async function isPushEnabledForAccount(accountId: string): Promise { + try { + if (!(await isPushEnabledForAccount(accountId))) return false; + const permission = await Notifications.getPermissionsAsync(); + return permission.granted !== false; + } catch { + return false; + } +} + +/** + * Whether Settings > Notifications should read the push state again when + * the app comes back: not while an enable or disable runs, nor right after + * one failed, which would wipe its message (webmail #1148). + */ +export function shouldRecheckPushState(status: 'idle' | 'busy' | 'enabled' | 'error'): boolean { + return status !== 'busy' && status !== 'error'; +} + export async function wasPushPromptDismissed(accountId: string): Promise { return (await AsyncStorage.getItem(promptDismissedKey(accountId))) !== null; }