From 3d4779a689c9d06d09aa0ab85ff3e6ea8998f5bd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 3 May 2026 19:05:30 +0200 Subject: [PATCH] feat: enhance offline email handling with caching and mailbox seeding --- src/screens/SettingsScreen.tsx | 21 ++++++++---- src/stores/email-store.ts | 56 +++++++++++++++++++++++++++++-- src/stores/offline-cache-store.ts | 31 +++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 41b0952d..3d45777b 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -210,14 +210,20 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin return ( - {/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */} + {/* Header - matches webmail mobile: h-14, border-b, back button + icon + title. + Settings is a tab root, so the back button is only rendered when a + parent passes onBack (it'd otherwise lead nowhere). */} - [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} - > - - + {onBack ? ( + [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} + > + + + ) : ( + + )} {t('settings.title', 'Settings')} @@ -310,6 +316,7 @@ function makeStyles(c: ThemePalette) { borderRadius: radius.md, }, headerBackBtnPressed: { backgroundColor: c.accent }, + headerLeftSpacer: { width: spacing.sm }, headerTitle: { ...typography.h3, color: c.text }, scrollArea: { flex: 1 }, diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 6c89c82b..a2762e4a 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -150,15 +150,43 @@ export const useEmailStore = create()( filters: {}, pendingUndo: null, }); + + // Seed from the offline cache immediately so changing folders shows + // something even before the network responds — and is the fallback + // when offline. The network result will replace this once it arrives. + const cacheStore = useOfflineCacheStore.getState(); + if (!cacheStore.hydrated) await cacheStore.hydrate(); + const limit = useSettingsStore.getState().emailsPerPage; + let cached: Email[] = []; + if (cacheStore.totalCount() > 0) { + try { + cached = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50)); + if (cached.length > 0 && get().currentMailboxId === mailboxId) { + set({ emails: cached, totalEmails: cached.length }); + } + } catch (err) { + console.warn('[email-store] cache seed failed:', err); + } + } + try { const filter = buildJmapFilter(mailboxId, '', {}); - const limit = useSettingsStore.getState().emailsPerPage; const { ids, total } = await queryEmails(mailboxId, { limit, filter }); const emails = ids.length > 0 ? await fetchEmails(ids) : []; + // Race guard: don't overwrite if the user already moved to a different + // mailbox while this request was in flight. + if (get().currentMailboxId !== mailboxId) return; set({ emails, totalEmails: total, loading: false }); } catch (err) { console.warn('[email-store] selectMailbox failed:', err); - set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' }); + if (get().currentMailboxId !== mailboxId) return; + // Keep whatever the cache seeded with; only surface the error when the + // cache is empty. With cached emails visible the OfflineBanner already + // tells the user the data is stale. + set({ + loading: false, + error: cached.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'), + }); } }, @@ -183,7 +211,7 @@ export const useEmailStore = create()( }, refreshEmails: async () => { - const { currentMailboxId, searchQuery, filters } = get(); + const { currentMailboxId, searchQuery, filters, emails: existing } = get(); if (!currentMailboxId) return; // Keep existing emails visible while refreshing - swap atomically once // the new list is ready so pull-to-refresh doesn't flash an empty list. @@ -196,6 +224,28 @@ export const useEmailStore = create()( set({ emails, totalEmails: total, loading: false }); } catch (err) { console.warn('[email-store] refreshEmails failed:', err); + // If the list was empty before the refresh attempt, fall back to the + // offline cache so the user still sees something. Otherwise keep what + // they had visible and let the OfflineBanner explain. + if (existing.length === 0) { + try { + const cacheStore = useOfflineCacheStore.getState(); + if (!cacheStore.hydrated) await cacheStore.hydrate(); + if (cacheStore.totalCount() > 0) { + const limit = useSettingsStore.getState().emailsPerPage; + const cached = await cacheStore.getEmailsInMailbox( + currentMailboxId, + Math.max(limit, 50), + ); + if (get().currentMailboxId === currentMailboxId && cached.length > 0) { + set({ emails: cached, totalEmails: cached.length, loading: false, error: null }); + return; + } + } + } catch (cacheErr) { + console.warn('[email-store] refresh cache fallback failed:', cacheErr); + } + } set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' }); } }, diff --git a/src/stores/offline-cache-store.ts b/src/stores/offline-cache-store.ts index 252a57ac..3093dd9e 100644 --- a/src/stores/offline-cache-store.ts +++ b/src/stores/offline-cache-store.ts @@ -59,6 +59,12 @@ interface OfflineCacheState { remove: (ids: string[]) => Promise; clearAll: () => Promise; + // Returns cached emails whose `mailboxIds` include the given mailbox, + // sorted by `receivedAt` descending. Used by selectMailbox to seed the + // list when the network is unavailable. Loads each entry from + // AsyncStorage; the index doesn't carry the mailbox set. + getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise; + totalSize: () => number; totalCount: () => number; @@ -154,6 +160,31 @@ export const useOfflineCacheStore = create((set, get) => ({ persistIndex(index); }, + getEmailsInMailbox: async (mailboxId, limit = 200) => { + // Sort the index by receivedAt descending first so we don't have to read + // entries we won't return — the index already carries receivedAt. + const sorted = Object.values(get().index.entries).sort((a, b) => { + const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0; + const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0; + return bt - at; + }); + const matches: Email[] = []; + for (const entry of sorted) { + if (matches.length >= limit) break; + try { + const raw = await AsyncStorage.getItem(entryKey(entry.id)); + if (!raw) continue; + const email = JSON.parse(raw) as Email; + if (email.mailboxIds?.[mailboxId]) { + matches.push(email); + } + } catch { + // skip corrupt entries — they'll be replaced on next sync + } + } + return matches; + }, + totalSize: () => { let n = 0; for (const e of Object.values(get().index.entries)) n += e.size;