diff --git a/App.tsx b/App.tsx index efd4d0a1..0a55be71 100644 --- a/App.tsx +++ b/App.tsx @@ -193,6 +193,18 @@ export default function App() { 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 () => { diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index befa8e05..4befe5c2 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -117,18 +117,38 @@ export class JMAPClient { const stored = await SecureStore.getItemAsync(credentialsKey(accountId)); if (!stored) return false; + let creds: StoredCredentials; + try { + creds = JSON.parse(stored); + } catch { + // Corrupt entry — credentials can't be used. Caller should evict. + return false; + } + + // Errors past this point propagate so callers can distinguish + // unrecoverable (AuthenticationError) from transient (NetworkError) and + // avoid clearing credentials when the server is just unreachable. + this.credentials = creds; try { - const creds: StoredCredentials = JSON.parse(stored); - this.credentials = creds; this.session = this.rewriteSessionUrls( await this.fetchSession(creds.serverUrl), creds.serverUrl, ); - this._accountId = this.resolveAccountId(this.session); - return true; - } catch { - return false; + } catch (err) { + // Don't leave a half-populated client behind; the caller needs to know + // the session is unavailable. Credentials stay in memory so a retry + // after the network comes back doesn't need a re-login. + this.session = null; + this._accountId = null; + if (err instanceof AuthenticationError) throw err; + // Anything else is treated as transport-level. Wrapping rather than + // rethrowing the raw fetch error gives callers a single check. + throw new NetworkError( + err instanceof Error ? err.message : 'Server unreachable', + ); } + this._accountId = this.resolveAccountId(this.session); + return true; } async clearAccountCredentials(accountId: string): Promise { @@ -332,6 +352,15 @@ export class AuthenticationError extends Error { } } +// Server unreachable / transient transport failure. Callers should keep +// stored credentials and surface an offline state rather than logging out. +export class NetworkError extends Error { + constructor(message: string) { + super(message); + this.name = 'NetworkError'; + } +} + export class RateLimitError extends Error { retryAfterMs: number; constructor(retryAfterMs: number) { diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index a87b3f09..5daded3d 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -487,7 +487,9 @@ function makeStyles(c: ThemePalette) { top: 0, bottom: 0, left: 0, width: '85%', maxWidth: 340, - backgroundColor: '#262626', // matches webmail bg-secondary (dark mode) + // Mirror webmail's bg-secondary in both palettes - was hardcoded to the + // dark-mode value, which left light mode unreadable (dark bg + dark text). + backgroundColor: c.secondary, borderRightWidth: 1, borderRightColor: c.border, }, @@ -537,7 +539,7 @@ function makeStyles(c: ThemePalette) { marginHorizontal: spacing.sm, marginTop: spacing.xs, marginBottom: spacing.sm, - backgroundColor: '#1f1f1f', + backgroundColor: c.popover, borderRadius: radius.md, borderWidth: 1, borderColor: c.border, diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index aaa20c78..bb552899 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -191,7 +191,7 @@ function makeStyles(c: ThemePalette) { left: 0, width: '85%', maxWidth: 340, - backgroundColor: '#262626', + backgroundColor: c.secondary, borderRightWidth: 1, borderRightColor: c.border, }, diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 01bf5dd4..56339ab5 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) { top: 0, bottom: 0, left: 0, width: '85%', maxWidth: 340, - backgroundColor: '#262626', + backgroundColor: c.secondary, borderRightWidth: 1, borderRightColor: c.border, }, diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 7a81eff8..3becd6e0 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { jmapClient, AuthenticationError } from '../api/jmap-client'; +import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client'; import type { JMAPSession } from '../api/types'; import { useAccountStore } from './account-store'; import { useEmailStore } from './email-store'; @@ -39,6 +39,7 @@ export interface AuthState { logoutAll: () => Promise; switchAccount: (accountId: string) => Promise; restoreSession: () => Promise; + retrySession: () => Promise; clearError: () => void; } @@ -286,15 +287,56 @@ export const useAuthStore = create((set, get) => ({ return false; } - const ok = await jmapClient.loadAccount(target.id); - if (!ok) { - accountStore.removeAccount(target.id); - set({ isLoading: false, hasRestoredSession: true }); - return false; + try { + const ok = await jmapClient.loadAccount(target.id); + if (!ok) { + // No stored credentials (or corrupt) — genuine logout. + accountStore.removeAccount(target.id); + set({ isLoading: false, hasRestoredSession: true }); + return false; + } + } catch (err) { + if (err instanceof NetworkError) { + // Server unreachable. Keep credentials, mark account offline, and + // surface the cached UI so the user can still browse persisted + // mail / contacts / calendar. The login screen would lose their + // settings without recourse, which is the bug we're fixing here. + accountStore.setActiveAccount(target.id); + accountStore.updateAccount(target.id, { + isConnected: false, + hasError: true, + errorMessage: err.message, + }); + set({ + isAuthenticated: true, + isLoading: false, + hasRestoredSession: true, + error: null, + serverUrl: target.serverUrl, + username: target.username, + session: null, + accountId: null, + activeAccountId: target.id, + client: jmapClient, + }); + return true; + } + if (err instanceof AuthenticationError) { + // Server reachable but credentials rejected — drop them. + await jmapClient.clearAccountCredentials(target.id).catch(() => undefined); + accountStore.removeAccount(target.id); + set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' }); + return false; + } + throw err; } accountStore.setActiveAccount(target.id); - accountStore.updateAccount(target.id, { isConnected: true, hasError: false }); + accountStore.updateAccount(target.id, { + isConnected: true, + hasError: false, + errorMessage: undefined, + }); const session = jmapClient.currentSession!; applyConnectedState(set, session, target.serverUrl, target.username, target.id); @@ -309,5 +351,51 @@ export const useAuthStore = create((set, get) => ({ } }, + // Re-attempt session establishment for the currently active account + // without disturbing UI state on failure. Used by the network-recovery + // watcher and any explicit "retry" button. Idempotent: returns true if + // a session is already live. + retrySession: async () => { + const { activeAccountId, session } = get(); + if (!activeAccountId) return false; + if (session) return true; + const accountStore = useAccountStore.getState(); + const target = accountStore.getAccountById(activeAccountId); + if (!target) return false; + try { + const ok = await jmapClient.loadAccount(activeAccountId); + if (!ok) return false; + accountStore.updateAccount(activeAccountId, { + isConnected: true, + hasError: false, + errorMessage: undefined, + }); + const fresh = jmapClient.currentSession!; + applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId); + refetchFeatureStores(); + return true; + } catch (err) { + if (err instanceof AuthenticationError) { + // Now we know the credentials are bad — fall back to logout flow. + await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined); + accountStore.removeAccount(activeAccountId); + set({ + isAuthenticated: false, + isLoading: false, + hasRestoredSession: true, + error: 'Session expired', + serverUrl: null, + username: null, + session: null, + accountId: null, + activeAccountId: null, + client: null, + }); + } + // NetworkError or anything else: stay where we are. + return false; + } + }, + clearError: () => set({ error: null }), }));