mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: enhance offline email handling with caching and mailbox seeding
This commit is contained in:
@@ -210,14 +210,20 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 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). */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
{onBack ? (
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.headerLeftSpacer} />
|
||||
)}
|
||||
<Settings size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
|
||||
</View>
|
||||
@@ -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 },
|
||||
|
||||
@@ -150,15 +150,43 @@ export const useEmailStore = create<EmailState>()(
|
||||
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<EmailState>()(
|
||||
},
|
||||
|
||||
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<EmailState>()(
|
||||
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' });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,6 +59,12 @@ interface OfflineCacheState {
|
||||
remove: (ids: string[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// 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<Email[]>;
|
||||
|
||||
totalSize: () => number;
|
||||
totalCount: () => number;
|
||||
|
||||
@@ -154,6 +160,31 @@ export const useOfflineCacheStore = create<OfflineCacheState>((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;
|
||||
|
||||
Reference in New Issue
Block a user