import React from 'react'; import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useColorScheme } from 'react-native'; import { DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused, } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack'; import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons'; import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream'; import { jmapClient } from './src/api/jmap-client'; import type { StateChange } from './src/api/types'; import { dispatchStateChange } from './src/lib/state-change-bus'; import { startCalendarNotificationSync, startCalendarReminderTapHandling, } from './src/lib/calendar-notifications'; import { startLivenessMonitor } from './src/lib/connection-liveness'; import { startDeviceSyncTriggers } from './src/device-sync/app/triggers'; import { setPendingCalendarOpen, type CalendarReminderTarget, } from './src/navigation/pending-calendar-open'; import { sweepStaleExportFiles } from './src/lib/email-export'; import { useFilterStore } from './src/stores/filter-store'; import { useVacationStore } from './src/stores/vacation-store'; import { addMessageListener, addNotificationTapListener, addTokenRefreshListener, getInitialNotificationTap, getStoredRelayBaseUrl, notificationTapJmapAccountId, resyncPushNotifications, teardownPushNotificationsForAccount, type NotificationTapPayload, } from './src/lib/push-notifications'; import { addUnifiedPushEndpointListener } from './src/lib/unified-push'; // Pause between a mute change and the push-filter update it sends (#78). const MUTES_RESYNC_DELAY_MS = 1_000; import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; import ComposeScreen from './src/screens/ComposeScreen'; import EmailThreadScreen from './src/screens/EmailThreadScreen'; import EmailSourceScreen from './src/screens/EmailSourceScreen'; import LoginScreen from './src/screens/LoginScreen'; import MailTab from './src/screens/MailTab'; import FilesScreen from './src/screens/FilesScreen'; import CalendarScreen from './src/screens/CalendarScreen'; import ContactsTab from './src/screens/ContactsTab'; import ContactDetailScreen from './src/screens/ContactDetailScreen'; import ContactFormScreen from './src/screens/ContactFormScreen'; import GroupDetailScreen from './src/screens/GroupDetailScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import ScheduledScreen from './src/screens/ScheduledScreen'; import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen'; import { useAccountStore } from './src/stores/account-store'; import { useAuthStore } from './src/stores/auth-store'; import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; import { useEmailStore } from './src/stores/email-store'; import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { useLocaleStore } from './src/stores/locale-store'; import { useLayout } from './src/lib/use-layout'; import { toast } from './src/stores/toast-store'; import { useNetworkStore } from './src/stores/network-store'; import { useUpdatesStore } from './src/stores/updates-store'; import { UpdateBanner } from './src/components/UpdateBanner'; import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt'; import { ToastHost } from './src/components/ToastHost'; import { UndoSnackbar } from './src/components/UndoSnackbar'; import { AppIconBadge } from './src/components/AppIconBadge'; import { getEmails } from './src/api/email'; import { signOutWidgets, startWidgetSync } from './src/widgets/sync'; import { draftContextFromEmail } from './src/lib/draft-context'; import { acceptSignInLink, handleDeepLink, parseDeepLink, routeParkedSignInLink, shareToDeepLink, type DeepLink, } from './src/navigation/linking'; import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link'; import { MAX_ACCOUNTS } from './src/lib/account-utils'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; import { useOfflineCacheStore } from './src/stores/offline-cache-store'; import { useOutboxStore } from './src/stores/outbox-store'; import { runOfflineSync } from './src/lib/offline-sync'; import { spacing, typography, type ThemePalette } from './src/theme/tokens'; import { useColors } from './src/theme/colors'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); const navigationRef = createNavigationContainerRef(); // The launch URL is read once per process: Android hands the same intent back // on every getInitialURL(), and a sign-in link must not run twice. let initialSignInLinkRead = false; async function navigateToNotificationTap(payload: NotificationTapPayload): Promise { if (!navigationRef.isReady()) return; // The notification carries the account it was generated for. If the user // has since switched to a different account (or had a different one active // when the notification arrived), opening EmailThread under the active // account would fetch the email from the wrong server and fail. const auth = useAuthStore.getState(); if (payload.accountId && payload.accountId !== auth.activeAccountId) { const account = useAccountStore.getState().getAccountById(payload.accountId); if (!account) return; // account was logged out — nothing safe to open. await auth.switchAccount(payload.accountId); if (useAuthStore.getState().activeAccountId !== payload.accountId) return; } prefetchMessage({ id: payload.emailId, threadId: payload.threadId }); navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, emailIds: [payload.emailId], subject: payload.subject, // A group mailbox's message lives under another JMAP account (#839). jmapAccountId: notificationTapJmapAccountId(payload), ...(payload.action === 'reply' ? { action: 'reply' as const } : {}), }); } // A tapped calendar reminder opens its event (or task) in the Calendar tab, // under the account it was scheduled for. The target is parked for // CalendarScreen, which also picks it up when it mounts after a cold start. async function openCalendarReminder(target: CalendarReminderTarget): Promise { const auth = useAuthStore.getState(); if (target.appAccountId && target.appAccountId !== auth.activeAccountId) { if (!useAccountStore.getState().getAccountById(target.appAccountId)) return; await auth.switchAccount(target.appAccountId); if (useAuthStore.getState().activeAccountId !== target.appAccountId) return; } setPendingCalendarOpen(target); // On a cold start the navigator mounts right after the auth gate flips. for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { await new Promise((resolve) => setTimeout(resolve, 100)); } if (navigationRef.isReady()) { navigationRef.navigate('MainTabs', { screen: 'Calendar' } as never); } } // Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and // Android share-sheet payloads all end up here once the navigator is ready. async function openDeepLink(link: DeepLink): Promise { const opened = await handleDeepLink(link, { navigation: navigationRef, resolveThreadId: async (emailId, jmapAccountId) => { try { const [email] = await getEmails([emailId], jmapAccountId); return email?.threadId ?? null; } catch { return null; } }, openDraft: async (emailId, jmapAccountId) => { try { // Cache first, so a draft read before still opens offline. const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId); if (!navigationRef.isReady()) return false; navigationRef.navigate('Compose', { draft }); return true; } catch { return false; } }, switchAccount: async (accountId) => { const auth = useAuthStore.getState(); if (auth.activeAccountId === accountId) return true; if (!useAccountStore.getState().getAccountById(accountId)) return false; await auth.switchAccount(accountId); return useAuthStore.getState().activeAccountId === accountId; }, }); // A widget or a link elsewhere brought the app up for this; say it failed // rather than leave the user wondering why the app just opened. if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this")); } // Toasts and the undo bar show on whichever stack screen is up, so a failure // that lands after leaving the screen it came from (a viewer action finishing // on the Unified Inbox or a contact) is still seen, and a reply held by the // undo-send delay can be undone from the viewer it was sent from. Every // screen carries the hosts, and only the focused one renders them, so // neither ever shows twice (the screen below the top one is kept live). // The composer gets no undo bar: its Undo would hand the open composer // another draft. A held send's bar shows on the screen below once it closes. function FocusedHosts({ undo }: { undo: boolean }) { if (!useIsFocused()) return null; return ( <> {undo && } ); } function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) { return ( <> {children} ); } function LoadingScreen({ message }: { message: string }) { const c = useColors(); return ( {message} ); } function MainTabsNavigator({ navigation }: NativeStackScreenProps) { const c = useColors(); // Select the count, not the list: every mailbox fetch sets a new array, and // re-rendering here re-renders the mail list with it. const inboxUnreadCount = useEmailStore( (state) => state.mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0, ); const logout = useAuthStore((state) => state.logout); const hasCalendar = useHasCalendar(); const hasContacts = useHasContacts(); const hasFiles = useHasFiles(); const disabledTabStyle = { opacity: 0.4 } as const; const t = useLocaleStore((state) => state.t); // The webmail's navigation names; a tab the server lacks says so. const tabLabels = { mail: t('sidebar.mail', 'Mail'), calendar: t('sidebar.calendar', 'Calendar'), contacts: t('sidebar.contacts', 'Contacts'), files: t('sidebar.files', 'Files'), settings: t('sidebar.settings', 'Settings'), }; const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name }); // Side by side layouts move the tabs into a rail on the left, like the // webmail's navigation rail. const { isSplit } = useLayout(); // A bottom tab bar keeps the screens clear of the gesture bar or taskbar; // with the tabs in a side rail the whole tab area has to. const bottomInset = useSafeAreaInsets().bottom; return ( , tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined, tabBarBadgeStyle: { backgroundColor: c.error, color: c.primaryForeground, fontSize: 10, fontWeight: '700', minWidth: 16, height: 16, lineHeight: 16, borderRadius: 8, top: -2, right: -6, }, }} > {() => } , tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar), }} listeners={{ tabPress: (e) => { if (!hasCalendar) e.preventDefault(); }, }} /> , tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts), }} listeners={{ tabPress: (e) => { if (!hasContacts) e.preventDefault(); }, }} /> , tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files), }} listeners={{ tabPress: (e) => { if (!hasFiles) e.preventDefault(); }, }} /> , }} > {() => } ); } export default function App() { const hasRestoredSession = useAuthStore((state) => state.hasRestoredSession); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const client = useAuthStore((state) => state.client); const restoreSession = useAuthStore((state) => state.restoreSession); const t = useLocaleStore((state) => state.t); // Resolve the user's theme preference to a concrete light/dark style for the // system status bar. The rest of the app's colors are still hard-coded dark // until the StyleSheet migration to a theme-aware `useColors` hook lands. const themePref = useSettingsStore((state) => state.theme); const systemScheme = useColorScheme(); const resolvedScheme: 'light' | 'dark' = themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref; const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light'; // React Navigation's default theme is light: without this its containers // paint white behind and between screens, even in dark mode. const background = useColors().background; const navigationTheme = React.useMemo(() => { const base = resolvedScheme === 'light' ? DefaultTheme : DarkTheme; return { ...base, colors: { ...base.colors, background, card: background } }; }, [resolvedScheme, background]); // Persisted active account is the signal that the user was already signed // in on the previous launch. When present we render the main UI with the // cached mail list instead of the "Restoring session" spinner; the real // JMAP session comes up in the background. const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null); const { isSplit } = useLayout(); React.useEffect(() => { if (!hasRestoredSession) { void restoreSession(); } }, [hasRestoredSession, restoreSession]); React.useEffect(() => { void useSettingsStore.getState().hydrate(); void useLocaleStore.getState().hydrate(); // Calendar reminders are kept scheduled from launch, not only once the // Calendar tab has been opened. startCalendarNotificationSync(); // Device sync (Android, #34): live changes, the app's own edits and the // foreground ask Android to sync; accounts are checked against Android's. startDeviceSyncTriggers(); return useNetworkStore.getState().init(); }, []); // Home-screen widgets follow the signed-in data; once the last account is // gone they are wiped so no mail stays visible on the launcher. React.useEffect(() => { if (isAuthenticated) return startWidgetSync(); if (hasRestoredSession) void signOutWidgets(); return undefined; }, [isAuthenticated, hasRestoredSession]); // When the network flips back on while we're authenticated-but-offline // (no live JMAP session), retry the session so the user lands back on // live data without needing to relaunch. React.useEffect(() => { if (!isAuthenticated) return; return useNetworkStore.subscribe((state, prev) => { if (state.online && !prev.online && !useAuthStore.getState().session) { void useAuthStore.getState().retrySession(); } }); }, [isAuthenticated]); React.useEffect(() => { let cancelled = false; void (async () => { const store = useUpdatesStore.getState(); await store.hydrate(); if (cancelled) return; if (useUpdatesStore.getState().autoCheck) { await useUpdatesStore.getState().checkNow(); } })(); return () => { cancelled = true; }; }, []); // Offline mail cache: hydrate the cache index on launch, and kick off a // background sync once we have a live JMAP session and the user has the // feature enabled. Re-runs whenever the user changes the days window. const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled); const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays); const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB); const haveLiveSession = useAuthStore((s) => s.session != null); React.useEffect(() => { void useOfflineCacheStore.getState().hydrate(); // Attachments shared out of the app linger in the cache dir; drop the // ones older than a day so a granted content URI can't read them forever. void sweepStaleExportFiles(); }, []); React.useEffect(() => { if (!offlineCacheEnabled || !haveLiveSession) return; // Slight delay so cold start doesn't compete with the inbox load. const t = setTimeout(() => { void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB }); }, 2000); return () => clearTimeout(t); }, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]); // Drain the offline action queue (outbox) as soon as we have a live session, // and again whenever the network comes back. The flush itself no-ops when // there's nothing queued or the client isn't ready. React.useEffect(() => { if (!haveLiveSession) return; void useOutboxStore.getState().flush(); return useNetworkStore.subscribe((state, prev) => { if (state.online && !prev.online) void useOutboxStore.getState().flush(); }); }, [haveLiveSession]); // Foreground FCM messages: the Kotlin service skips the headless task while // the app is visible, so feed the relay's StateChange straight into the // stores. SSE normally beats it, but this covers the window where the SSE // socket is down and the poll fallback has not fired yet. React.useEffect(() => { const unsubscribe = addMessageListener((payload) => { const raw = payload?.data?.changed; if (!raw) return; try { const changed = JSON.parse(raw) as Record>; if (!changed || typeof changed !== 'object') return; void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed }); } catch { // malformed payload - ignore } }); return unsubscribe; }, []); // When the user taps a notification the app lands here with an email id // either stashed on the cold-start intent or delivered as a live event. // Wait until auth is restored so the navigation target has credentials to // load the thread. React.useEffect(() => { if (!isAuthenticated) return; let cancelled = false; void (async () => { const initial = await getInitialNotificationTap(); if (cancelled || !initial) return; await navigateToNotificationTap(initial); })(); const unsubscribe = addNotificationTapListener((payload) => { void navigateToNotificationTap(payload); }); return () => { cancelled = true; unsubscribe(); }; }, [isAuthenticated]); // Calendar reminders are local expo notifications; their taps come through // expo-notifications, not the native tap store used for mail push above. React.useEffect(() => { if (!isAuthenticated) return; return startCalendarReminderTapHandling((target) => { void openCalendarReminder(target); }); }, [isAuthenticated]); // Deep links and share-sheet payloads. The cold-start URL / share is read // once auth is restored so the target screen has credentials. React.useEffect(() => { if (!isAuthenticated) return; let cancelled = false; const open = (link: DeepLink | null) => { if (!link || cancelled) return; // Give the navigator a tick to mount after the auth gate flips. setTimeout(() => { void openDeepLink(link); }, 50); }; void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null)); void getInitialShare().then((share) => { if (!share) return; const link = shareToDeepLink(share); if (link.kind === 'compose' && share.uris?.length) { navigationRef.isReady() && navigationRef.navigate('Compose', { prefillTo: link.to, prefillSubject: link.subject, prefillBody: link.body, prefillAttachments: shareAttachments(share), }); return; } open(link); }); const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url))); const shareSub = addShareListener((share) => { const link = shareToDeepLink(share); if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) { navigationRef.navigate('Compose', { prefillTo: link.to, prefillSubject: link.subject, prefillBody: link.body, prefillAttachments: shareAttachments(share), }); return; } open(link); }); return () => { cancelled = true; urlSub.remove(); shareSub(); }; }, [isAuthenticated]); // Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App, // `bulwarkmail://connect?…`) work signed out too, so unlike the links above // they are listened for from launch. They are parked for the login screen // (the signed-out one takes them as it mounts), marked as coming from // outside the app: any page or app can fire one, so the login screen asks // before it redeems or opens anything. React.useEffect(() => { if (!initialSignInLinkRead) { initialSignInLinkRead = true; void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined); } const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); }); return () => subscription.remove(); }, []); // Signed in, a sign-in link adds an account: open Add account, whose login // screen takes the parked link (or says why a refused one won't be used). // On a cold start the navigator mounts right after the auth gate flips, so // wait for it like a reminder tap does. const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null); const addAccountForLink = isAuthenticated && signInLinkPending; React.useEffect(() => { if (!addAccountForLink) return; let cancelled = false; void (async () => { for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { await new Promise((resolve) => setTimeout(resolve, 100)); if (cancelled) return; } if (cancelled || !navigationRef.isReady()) return; const route = routeParkedSignInLink(useAccountStore.getState().accounts.length); if (route === 'account-limit') { // Said like the Add account buttons say it, instead of opening a // screen whose sign-in can only fail. const t = useLocaleStore.getState().t; toast.error( t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }), t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'), ); return; } if (route === 'add-account') navigationRef.navigate('AddAccount'); })(); return () => { cancelled = true; }; }, [addAccountForLink]); // Re-register the device with the configured relay once authenticated, // and whenever the FCM token rotates. Honours the user's notification // preference - flipping it off tears down THIS account's subscription so // notifications stop arriving for it. Other logged-in accounts keep their // setups intact. An account the user turned push off for, or whose device // registration was revoked, stays off (see resyncPushNotifications). const emailNotificationsEnabled = useSettingsStore( (s) => s.emailNotificationsEnabled, ); const activeAccountId = useAuthStore((s) => s.activeAccountId); React.useEffect(() => { if (!isAuthenticated || !client) return; let cancelled = false; const doSetup = async () => { if (!emailNotificationsEnabled) { if (activeAccountId) { await teardownPushNotificationsForAccount(activeAccountId).catch( () => undefined, ); } return; } const relayBaseUrl = await getStoredRelayBaseUrl(); if (!relayBaseUrl) return; try { await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined, }); if (cancelled) return; } catch (error) { console.warn( '[push] relay setup failed:', error instanceof Error ? error.message : error, ); } }; void doSetup(); const unsubscribe = addTokenRefreshListener(() => { void doSetup(); }); // UnifiedPush equivalent of an FCM token rotation: the distributor handed // out a new endpoint, so re-register it with the relay. const unsubscribeUp = addUnifiedPushEndpointListener(() => { void doSetup(); }); return () => { cancelled = true; unsubscribe(); unsubscribeUp(); }; }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); // Muting a folder or account changes the server-side push filter (#78), // which the setup above would only send at the next launch. Send it now, // after a short pause so a few taps in a row make one update. Compared by // content, since hydration hands back an equal but new object; the value // seen first after hydration is the baseline, not a change. const notificationMutes = useSettingsStore((s) => s.notificationMutes); const settingsHydrated = useSettingsStore((s) => s.hydrated); const mutesKey = JSON.stringify(notificationMutes); const lastMutesKey = React.useRef(null); React.useEffect(() => { if (!settingsHydrated) return; if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) { lastMutesKey.current = mutesKey; return; } lastMutesKey.current = mutesKey; if (!isAuthenticated || !client || !emailNotificationsEnabled) return; let cancelled = false; const timer = setTimeout(async () => { const relayBaseUrl = await getStoredRelayBaseUrl(); if (!relayBaseUrl || cancelled) return; try { await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined }); } catch (error) { console.warn('[push] mute update failed:', error instanceof Error ? error.message : error); } }, MUTES_RESYNC_DELAY_MS); return () => { cancelled = true; clearTimeout(timer); }; // Only a change of the mutes should send an update; the rest is read live. // eslint-disable-next-line react-hooks/exhaustive-deps }, [mutesKey, settingsHydrated]); // Live updates (SSE with polling fallback), re-armed on every account // switch and every re-established session — the singleton `client` object // never changes identity, so it cannot be the dependency on its own. // Backgrounding closes the stream (no socket + server pings while the app // is asleep); foregrounding reconnects it and refreshes what the user is // looking at, since events that happened in between are gone for good. React.useEffect(() => { if (!isAuthenticated || !client || !haveLiveSession) { return; } let mounted = true; let handle: LiveUpdatesHandle | null = null; let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive'; const onStateChange = async (change: StateChange) => { // Filters / vacation edited elsewhere (webmail, another device): refetch // so the settings panes don't save stale state over the newer script. const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })(); const own = primary ? change.changed?.[primary] : undefined; const extra: Promise[] = []; if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined)); if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined)); dispatchStateChange(change); await Promise.all([ useEmailStore.getState().handleStateChange(change), useContactsStore.getState().handleStateChange(change), useCalendarStore.getState().handleStateChange(change), ...extra, ]); }; const start = async () => { try { const next = await startLiveUpdates({ onStateChange: (change) => { void onStateChange(change); }, onError: (error) => { console.warn('[push]', error.message); }, onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); }, isActive: () => appActive, }); if (!mounted) { next.close(); return; } handle = next; } catch (error) { console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates'); } }; const refreshAfterResume = () => { const email = useEmailStore.getState(); void email.fetchMailboxes(); if (email.currentMailboxId) void email.refreshEmails(); void useOutboxStore.getState().flush(); }; // Foreground liveness for the per-account connection dot: the open // stream's pings, or a `Core/echo` every 30 s while there is none (see // startLivenessMonitor). NetInfo cannot tell an unreachable server. const liveness = startLivenessMonitor({ ping: () => jmapClient.ping(), streamHealthy: () => handle?.healthy ?? false, isActive: () => appActive, isOnline: () => useNetworkStore.getState().online, onConnected: (connected) => { if (!mounted || !activeAccountId) return; const account = useAccountStore.getState().getAccountById(activeAccountId); if (account && account.isConnected !== connected) { useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected }); } }, }); void start(); const subscription = AppState.addEventListener('change', (state) => { const nowActive = state === 'active'; if (nowActive === appActive) return; appActive = nowActive; if (!nowActive) { handle?.close(); handle = null; return; } // Coming back: reconnect and catch up on what was missed at once; // one echo alongside checks the server for the connection dot. if (!handle) void start(); else handle.reconnect(); refreshAfterResume(); void liveness.check(); }); // Reconnect when the network comes back while foregrounded. const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => { if (state.online && !prev.online && appActive) { if (handle) handle.reconnect(); else void start(); } }); return () => { mounted = false; subscription.remove(); unsubscribeNetwork(); liveness.stop(); handle?.close(); handle = null; }; }, [client, isAuthenticated, haveLiveSession, activeAccountId]); // Skip the "Restoring session" flash for returning users: if we already // have a persisted active account, render the main UI immediately with // whatever the email-store hydrated from cache. restoreSession still runs // in the background and swaps in fresh data once it completes. if (!hasRestoredSession && !hasPersistedAccount) { return ( <> ); } if (hasRestoredSession && !isAuthenticated) { return ( <> ); } return ( {/* On Fabric, native-stack keeps the screen right below the top live and freezes the ones further down. */} {({ navigation }) => ( navigation.goBack()} onLogin={() => navigation.goBack()} /> )} ); } const styles = StyleSheet.create({ loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, }, loadingText: { ...typography.body, }, });