mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b7be03d8 | ||
|
|
9dd9ce8105 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.35",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.35",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.34",
|
||||
"version": "0.1.35",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -28,14 +28,17 @@ vi.mock('../settings-store', () => ({
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
|
||||
}));
|
||||
|
||||
// offline-cache-store is touched by selectMailbox (cache-seed fallback) and
|
||||
// getEmailDetail (best-effort body refresh). Stub it as an empty cache so
|
||||
// tests don't need to set up AsyncStorage.
|
||||
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
|
||||
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
|
||||
// switch). Stub it as an empty cache so tests don't need to set up
|
||||
// AsyncStorage.
|
||||
vi.mock('../offline-cache-store', () => ({
|
||||
useOfflineCacheStore: {
|
||||
getState: () => ({
|
||||
activeAccountId: null,
|
||||
hydrated: true,
|
||||
hydrate: vi.fn(),
|
||||
setAccount: vi.fn(async () => undefined),
|
||||
totalCount: () => 0,
|
||||
getEmailsInMailbox: vi.fn(async () => []),
|
||||
has: () => false,
|
||||
@@ -45,19 +48,27 @@ vi.mock('../offline-cache-store', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// email-store now early-returns from fetch actions when jmapClient.isConnected
|
||||
// is false (cold-start guard). The tests exercise those actions, so present
|
||||
// a connected stub.
|
||||
// email-store now early-returns from fetch actions unless jmapClient is
|
||||
// connected AND serving the same logical account the store has active
|
||||
// (`generateAccountId(username, serverUrl) === activeAccountId`). The
|
||||
// tests exercise those actions, so present a fully connected stub plus
|
||||
// the matching username/serverUrl pair. beforeEach() syncs the store's
|
||||
// activeAccountId to the id these credentials produce.
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'acc-1',
|
||||
username: 'test@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateAccountId } from '../../lib/account-utils';
|
||||
const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.example.com');
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import { useEmailStore } from '../email-store';
|
||||
|
||||
@@ -74,6 +85,10 @@ const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useEmailStore.getState().reset();
|
||||
// Wire the store's active account to the one the mocked jmapClient is
|
||||
// serving, so the guard inside fetchMailboxes / refreshEmails / etc.
|
||||
// doesn't short-circuit the tests.
|
||||
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
|
||||
});
|
||||
|
||||
describe('email-store', () => {
|
||||
|
||||
+65
-19
@@ -22,9 +22,27 @@ import {
|
||||
searchEmails as apiSearchEmails,
|
||||
} from '../api/email';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { useSettingsStore } from './settings-store';
|
||||
import { useOfflineCacheStore } from './offline-cache-store';
|
||||
|
||||
// True only when the JMAP client is actually serving the email-store's active
|
||||
// account. During an account switch there's a window between
|
||||
// setActiveAccount() (which swaps the email-store view immediately) and
|
||||
// jmapClient.loadAccount() resolving, when the client is still on the
|
||||
// *previous* account. Without this guard, any fetchMailboxes/refreshEmails
|
||||
// fired in that window (e.g. by an EmailListScreen useEffect reacting to
|
||||
// the empty new-account view) would return the previous account's data and
|
||||
// stamp it into the new account's snapshot.
|
||||
function jmapClientServesActiveAccount(activeAccountId: string | null): boolean {
|
||||
if (!activeAccountId) return false;
|
||||
if (!jmapClient.isConnected) return false;
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (!username || !serverUrl) return false;
|
||||
return generateAccountId(username, serverUrl) === activeAccountId;
|
||||
}
|
||||
|
||||
export interface EmailFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -315,6 +333,12 @@ export const useEmailStore = create<EmailState>()(
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Point the offline body cache at the same account so getEmailDetail's
|
||||
// fallback and selectMailbox's seed read from the right bucket. Fire-
|
||||
// and-forget — the cache returns empty until hydration completes,
|
||||
// which is the correct degraded behaviour.
|
||||
void useOfflineCacheStore.getState().setAccount(accountId);
|
||||
},
|
||||
|
||||
removeAccount: (accountId) => {
|
||||
@@ -336,6 +360,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(null);
|
||||
} else {
|
||||
set({ accountSnapshots: rest });
|
||||
}
|
||||
@@ -359,16 +384,20 @@ export const useEmailStore = create<EmailState>()(
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(null);
|
||||
},
|
||||
|
||||
fetchMailboxes: async () => {
|
||||
// Skip silently when there's no live session. Screens fire this from
|
||||
// mount-time useEffects, and on cold start App.tsx renders MainTabs
|
||||
// before restoreSession() finishes; without this guard the underlying
|
||||
// API call would throw "Not authenticated - call connect() first" and
|
||||
// surface as a user-visible error before the real fetch (kicked off by
|
||||
// refetchFeatureStores() once the session is live) replaces it.
|
||||
if (!jmapClient.isConnected) return;
|
||||
// Skip silently when there's no live session, or when jmapClient is
|
||||
// mid-transition to a different account (see jmapClientServesActiveAccount).
|
||||
// Screens fire this from mount-time useEffects, and on cold start
|
||||
// App.tsx renders MainTabs before restoreSession() finishes; without
|
||||
// this guard the underlying API call would either throw "Not
|
||||
// authenticated - call connect() first" or — worse, during an account
|
||||
// switch — return the *previous* account's mailboxes and stamp them
|
||||
// into the new account's snapshot.
|
||||
const activeAccountId = get().activeAccountId;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
const prevState = get().mailboxState;
|
||||
try {
|
||||
@@ -377,6 +406,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
// we have no previous state to compare against.
|
||||
if (prevState) {
|
||||
const changes = await getMailboxChanges(prevState);
|
||||
// Bail if the user switched accounts during the await — anything we
|
||||
// set() now would land in the wrong account's bucket.
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
if (changes) {
|
||||
// No changes at all — keep the cached list, just bump the state.
|
||||
if (
|
||||
@@ -391,6 +423,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
const fetched = toFetch.length > 0
|
||||
? (await getMailboxesByIds(toFetch)).list
|
||||
: [];
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
const destroyed = new Set(changes.destroyed);
|
||||
const byId = new Map<string, Mailbox>();
|
||||
for (const m of get().mailboxes) byId.set(m.id, m);
|
||||
@@ -411,9 +444,11 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
|
||||
const { list, state } = await getMailboxesWithState();
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ mailboxes: list, mailboxState: state });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] fetchMailboxes failed:', err);
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
// Don't overwrite the cached list on a transient failure — the user
|
||||
// can still navigate folders. Only surface the error when we have no
|
||||
// mailboxes at all to show.
|
||||
@@ -481,11 +516,11 @@ export const useEmailStore = create<EmailState>()(
|
||||
pendingUndo: null,
|
||||
});
|
||||
|
||||
// Stop here if there's no live session yet — the cached seed already
|
||||
// gave the user something to look at, and the refetch driven by
|
||||
// restoreSession() / switchAccount will run the network half once the
|
||||
// client is connected.
|
||||
if (!jmapClient.isConnected) {
|
||||
// Stop here if there's no live session OR jmapClient is mid-transition
|
||||
// to a different account. The cached seed already gave the user
|
||||
// something to look at, and the refetch driven by restoreSession() /
|
||||
// switchAccount will run the network half once the client catches up.
|
||||
if (!jmapClientServesActiveAccount(get().activeAccountId)) {
|
||||
set({ loading: false });
|
||||
return;
|
||||
}
|
||||
@@ -494,9 +529,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
loadMoreEmails: async () => {
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters, activeAccountId } = get();
|
||||
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
|
||||
if (!jmapClient.isConnected) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
set({ loading: true });
|
||||
try {
|
||||
@@ -507,7 +542,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
limit,
|
||||
filter,
|
||||
});
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : [];
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const merged = [...emails, ...newEmails];
|
||||
const updates: Partial<EmailState> = { emails: merged, loading: false };
|
||||
if (isBaseView(searchQuery, filters)) {
|
||||
@@ -522,15 +559,16 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
set(updates);
|
||||
} catch (err) {
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
|
||||
}
|
||||
},
|
||||
|
||||
refreshEmails: async () => {
|
||||
const state = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState } = state;
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
|
||||
if (!currentMailboxId) return;
|
||||
if (!jmapClient.isConnected) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
set({ loading: true, error: null });
|
||||
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
@@ -613,7 +651,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
const nextQueryState = queryChanges.newQueryState;
|
||||
const nextTotal = queryChanges.total;
|
||||
|
||||
if (get().currentMailboxId !== currentMailboxId) return;
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
|
||||
set({
|
||||
emails: trimmed,
|
||||
@@ -666,7 +704,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
set(updates);
|
||||
} catch (err) {
|
||||
console.warn('[email-store] refreshEmails failed:', err);
|
||||
if (get().currentMailboxId !== currentMailboxId) return;
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
// Keep whatever's visible; only surface the error when the list is
|
||||
// empty. With cached emails on screen the OfflineBanner already
|
||||
// tells the user the data is stale.
|
||||
@@ -679,7 +717,11 @@ export const useEmailStore = create<EmailState>()(
|
||||
currentMailboxId,
|
||||
Math.max(limit, 50),
|
||||
);
|
||||
if (get().currentMailboxId === currentMailboxId && cached.length > 0) {
|
||||
if (
|
||||
get().activeAccountId === activeAccountId &&
|
||||
get().currentMailboxId === currentMailboxId &&
|
||||
cached.length > 0
|
||||
) {
|
||||
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
@@ -697,6 +739,10 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.currentSession) return;
|
||||
// Drop changes that arrived for a different account than the one we're
|
||||
// currently showing (e.g. push notifications received during/just after
|
||||
// an account switch).
|
||||
if (!jmapClientServesActiveAccount(get().activeAccountId)) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
// AsyncStorage; attachment blobs go to expo-file-system under
|
||||
// `Paths.document/offline-attachments/`. The store also exposes the live
|
||||
// progress state the OfflineCacheBanner reads from.
|
||||
//
|
||||
// Storage is keyed by the email-store's `activeAccountId` so logged-in
|
||||
// accounts don't leak cached bodies into each other. The store loads one
|
||||
// account's bucket at a time (the active one); switching accounts via
|
||||
// setAccount() persists the current bucket then hydrates the new one.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
const INDEX_KEY = 'webmail:offline-cache:index:v1';
|
||||
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v1:';
|
||||
const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:';
|
||||
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
|
||||
|
||||
function entryKey(id: string): string {
|
||||
return `${ENTRY_KEY_PREFIX}${id}`;
|
||||
function indexKey(accountId: string): string {
|
||||
return `${INDEX_KEY_PREFIX}${accountId}`;
|
||||
}
|
||||
|
||||
function entryKey(accountId: string, emailId: string): string {
|
||||
return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`;
|
||||
}
|
||||
|
||||
export interface OfflineCacheIndexEntry {
|
||||
@@ -23,8 +32,6 @@ export interface OfflineCacheIndexEntry {
|
||||
}
|
||||
|
||||
interface CacheIndex {
|
||||
// Per-account is keyed at the storage layer (one app session = one account
|
||||
// active at a time). Cross-account separation is left to a future change.
|
||||
entries: Record<string, OfflineCacheIndexEntry>;
|
||||
}
|
||||
|
||||
@@ -42,8 +49,11 @@ export interface SyncState {
|
||||
}
|
||||
|
||||
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
|
||||
const EMPTY_INDEX: CacheIndex = { entries: {} };
|
||||
|
||||
interface OfflineCacheState {
|
||||
// Currently-loaded bucket. null = no account active (no reads/writes).
|
||||
activeAccountId: string | null;
|
||||
index: CacheIndex;
|
||||
hydrated: boolean;
|
||||
sync: SyncState;
|
||||
@@ -52,6 +62,9 @@ interface OfflineCacheState {
|
||||
// AbortControllers need to survive across the function boundary.
|
||||
abortRequested: boolean;
|
||||
|
||||
// Point the store at the given account, hydrating its bucket from
|
||||
// AsyncStorage. Pass null to detach (no reads/writes will hit storage).
|
||||
setAccount: (accountId: string | null) => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
has: (id: string) => boolean;
|
||||
get: (id: string) => Promise<Email | null>;
|
||||
@@ -74,41 +87,83 @@ interface OfflineCacheState {
|
||||
consumeAbort: () => boolean;
|
||||
}
|
||||
|
||||
function persistIndex(index: CacheIndex): void {
|
||||
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
|
||||
function persistIndex(accountId: string, index: CacheIndex): void {
|
||||
void AsyncStorage.setItem(indexKey(accountId), JSON.stringify(index)).catch((err) => {
|
||||
console.warn('[offline-cache] persist index failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadIndex(accountId: string): Promise<CacheIndex> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(indexKey(accountId));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheIndex;
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] hydrate failed', err);
|
||||
}
|
||||
return { entries: {} };
|
||||
}
|
||||
|
||||
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
activeAccountId: null,
|
||||
index: { entries: {} },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(INDEX_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheIndex;
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
set({ index: parsed, hydrated: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] hydrate failed', err);
|
||||
setAccount: async (accountId) => {
|
||||
const state = get();
|
||||
if (state.activeAccountId === accountId) {
|
||||
// Same account — make sure we've hydrated.
|
||||
if (!state.hydrated) await get().hydrate();
|
||||
return;
|
||||
}
|
||||
set({ hydrated: true });
|
||||
// Drop any in-flight sync state from the previous account; the abort flag
|
||||
// makes runOfflineSync stop on its next chunk boundary.
|
||||
set({
|
||||
activeAccountId: accountId,
|
||||
index: { ...EMPTY_INDEX },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: true,
|
||||
});
|
||||
if (accountId) {
|
||||
const index = await loadIndex(accountId);
|
||||
// Re-check in case another setAccount call raced past us.
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
set({ index, hydrated: true, abortRequested: false });
|
||||
} else {
|
||||
set({ hydrated: true, abortRequested: false });
|
||||
}
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
const state = get();
|
||||
if (state.hydrated) return;
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) {
|
||||
// No active account yet — mark hydrated so callers that block on this
|
||||
// (selectMailbox cache-seed path) don't spin. Reads will return empty.
|
||||
set({ hydrated: true });
|
||||
return;
|
||||
}
|
||||
const index = await loadIndex(accountId);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
set({ index, hydrated: true });
|
||||
},
|
||||
|
||||
has: (id) => Boolean(get().index.entries[id]),
|
||||
|
||||
get: async (id) => {
|
||||
if (!get().index.entries[id]) return null;
|
||||
const state = get();
|
||||
if (!state.activeAccountId) return null;
|
||||
if (!state.index.entries[id]) return null;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(id));
|
||||
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as Email;
|
||||
} catch (err) {
|
||||
@@ -118,12 +173,19 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
},
|
||||
|
||||
put: async (email, approxBytes) => {
|
||||
const state = get();
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
|
||||
await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email));
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] put failed', email.id, err);
|
||||
return;
|
||||
}
|
||||
// Re-read the active account in case it changed while we were awaiting
|
||||
// AsyncStorage — don't stamp the new account's index with the old
|
||||
// account's entry.
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const nextEntries = {
|
||||
...get().index.entries,
|
||||
[email.id]: {
|
||||
@@ -135,35 +197,44 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
};
|
||||
const index = { entries: nextEntries };
|
||||
set({ index });
|
||||
persistIndex(index);
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
remove: async (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
|
||||
);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const next = { ...get().index.entries };
|
||||
for (const id of ids) delete next[id];
|
||||
const index = { entries: next };
|
||||
set({ index });
|
||||
persistIndex(index);
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
clearAll: async () => {
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
const ids = Object.keys(get().index.entries);
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
|
||||
);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const index = { entries: {} };
|
||||
set({ index, sync: { ...IDLE } });
|
||||
persistIndex(index);
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
getEmailsInMailbox: async (mailboxId, limit = 200) => {
|
||||
const state = get();
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) return [];
|
||||
// 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 sorted = Object.values(state.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;
|
||||
@@ -172,7 +243,7 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
for (const entry of sorted) {
|
||||
if (matches.length >= limit) break;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(entry.id));
|
||||
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id));
|
||||
if (!raw) continue;
|
||||
const email = JSON.parse(raw) as Email;
|
||||
if (email.mailboxIds?.[mailboxId]) {
|
||||
|
||||
Reference in New Issue
Block a user