fix: check push again when the app comes back to settings (bulwarkmail/webmail#1148)

This commit is contained in:
Linus Rath
2026-10-31 04:47:14 +01:00
parent 1821fda728
commit 327b2cae1b
3 changed files with 82 additions and 3 deletions
@@ -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);
+38
View File
@@ -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);
});
});
+25
View File
@@ -793,6 +793,31 @@ export async function isPushEnabledForAccount(accountId: string): Promise<boolea
return (await AsyncStorage.getItem(subscriptionIdKey(accountId))) !== null;
}
/**
* Whether push works for the account right now: it is set up, and the
* system still lets the app show notifications (they can be turned off in
* the phone's settings while the app is in the background). A failed check
* reads as off (webmail #1148).
*/
export async function isPushActiveForAccount(accountId: string): Promise<boolean> {
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<boolean> {
return (await AsyncStorage.getItem(promptDismissedKey(accountId))) !== null;
}