import React from 'react'; import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; import { useColorScheme } from 'react-native'; import { NavigationContainer, createNavigationContainerRef } 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 'lucide-react-native'; 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 { 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, setupPushNotifications, teardownPushNotificationsForAccount, type NotificationTapPayload, } from './src/lib/push-notifications'; 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 EmailListScreen from './src/screens/EmailListScreen'; import FilesScreen from './src/screens/FilesScreen'; import CalendarScreen from './src/screens/CalendarScreen'; import ContactsScreen from './src/screens/ContactsScreen'; 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 { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { useLocaleStore } from './src/stores/locale-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 { getEmails } from './src/api/email'; import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; 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(); 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; } navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, subject: payload.subject, }); } // 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 { await handleDeepLink(link, { navigation: navigationRef, resolveThreadId: async (emailId) => { try { const [email] = await getEmails([emailId]); return email?.threadId ?? null; } catch { return null; } }, 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; }, }); } function LoadingScreen({ message }: { message: string }) { const c = useColors(); return ( {message} ); } function MainTabsNavigator({ navigation }: NativeStackScreenProps) { const c = useColors(); const mailboxes = useEmailStore((state) => state.mailboxes); const logout = useAuthStore((state) => state.logout); const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0; const hasCalendar = useHasCalendar(); const hasContacts = useHasContacts(); const hasFiles = useHasFiles(); const disabledTabStyle = { opacity: 0.4 } as const; 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, }, }} > {() => ( navigation.navigate('Compose')} onEmailPress={(email) => { navigation.navigate('EmailThread', { emailId: email.id, threadId: email.threadId, subject: email.subject, }); }} /> )} , tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)', }} listeners={{ tabPress: (e) => { if (!hasCalendar) e.preventDefault(); }, }} /> , tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)', }} listeners={{ tabPress: (e) => { if (!hasContacts) e.preventDefault(); }, }} /> , tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)', }} 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); // 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'; // 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); React.useEffect(() => { if (!hasRestoredSession) { void restoreSession(); } }, [hasRestoredSession, restoreSession]); React.useEffect(() => { void useSettingsStore.getState().hydrate(); void useLocaleStore.getState().hydrate(); return useNetworkStore.getState().init(); }, []); // 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]); // 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]); // 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. 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 setupPushNotifications({ 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(); }); return () => { cancelled = true; unsubscribe(); }; }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); // 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(); }; 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: verify the session is still alive before trusting the // stream, then reconnect and catch up on what was missed. void (async () => { const alive = await jmapClient.ping().catch(() => false); if (!mounted) return; if (!alive && useNetworkStore.getState().online) { const ok = await useAuthStore.getState().retrySession().catch(() => false); if (!ok || !mounted) return; } if (!handle) await start(); else handle.reconnect(); refreshAfterResume(); })(); }); // 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(); handle?.close(); handle = null; }; }, [client, isAuthenticated, haveLiveSession, activeAccountId]); // Foreground keep-alive: a `Core/echo` every 30 s tells us when the server // is unreachable even though the device is online (NetInfo cannot), and // drives the per-account connection dot. React.useEffect(() => { if (!isAuthenticated || !haveLiveSession || !activeAccountId) return; let cancelled = false; let failures = 0; const timer = setInterval(() => { if (AppState.currentState !== 'active') return; if (!useNetworkStore.getState().online) return; void jmapClient.ping().then((ok) => { if (cancelled) return; failures = ok ? 0 : failures + 1; const account = useAccountStore.getState().getAccountById(activeAccountId); if (!account) return; // One missed echo can be a blip; two in a row is a lost connection. const connected = failures < 2; if (account.isConnected !== connected) { useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected }); } }).catch(() => undefined); }, 30_000); return () => { cancelled = true; clearInterval(timer); }; }, [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 ( {({ navigation }) => ( navigation.goBack()} onLogin={() => navigation.goBack()} /> )} ); } const styles = StyleSheet.create({ loadingContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, }, loadingText: { ...typography.body, }, });