Compare commits

...
5 Commits
10 changed files with 855 additions and 111 deletions
+26
View File
@@ -36,6 +36,7 @@ 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';
@@ -88,6 +89,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
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 (
<View style={{ flex: 1, backgroundColor: c.background }}>
@@ -149,6 +154,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={CalendarScreen}
options={{
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasCalendar) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -156,6 +168,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={ContactsScreen}
options={{
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasContacts) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -163,6 +182,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={FilesScreen}
options={{
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasFiles) e.preventDefault();
},
}}
/>
<Tab.Screen
+1 -1
View File
@@ -1 +1 @@
0.1.31
0.1.34
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.31",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.31",
"version": "0.1.34",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.31",
"version": "0.1.34",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+169 -15
View File
@@ -22,6 +22,55 @@ export async function getMailboxes(): Promise<Mailbox[]> {
return res.methodResponses[0][1].list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export interface MailboxChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function createMailbox(
data: { name: string; parentId?: string | null },
): Promise<string> {
@@ -83,6 +132,22 @@ export async function deleteMailbox(id: string): Promise<void> {
}
}
function buildMailboxQueryFilter(
mailboxId: string,
userFilter: Record<string, unknown> | undefined,
): Record<string, unknown> {
const inMailbox = { inMailbox: mailboxId };
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
if ('operator' in userFilter) {
return { operator: 'AND', conditions: [inMailbox, userFilter] };
}
return { ...inMailbox, ...userFilter };
}
export async function queryEmails(
mailboxId: string,
options?: {
@@ -91,20 +156,9 @@ export async function queryEmails(
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
},
): Promise<{ ids: string[]; total: number }> {
): Promise<{ ids: string[]; total: number; queryState?: string }> {
const accountId = jmapClient.accountId;
const inMailbox = { inMailbox: mailboxId };
const userFilter = options?.filter;
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
const filter =
!userFilter || Object.keys(userFilter).length === 0
? inMailbox
: 'operator' in userFilter
? { operator: 'AND', conditions: [inMailbox, userFilter] }
: { ...inMailbox, ...userFilter };
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const res = await jmapClient.request([
['Email/query', {
accountId,
@@ -115,9 +169,56 @@ export async function queryEmails(
calculateTotal: true,
}, '0'],
]);
const body = res.methodResponses[0][1];
return {
ids: res.methodResponses[0][1].ids,
total: res.methodResponses[0][1].total,
ids: body.ids,
total: body.total,
queryState: body.queryState as string | undefined,
};
}
export interface EmailQueryChangesResult {
oldQueryState: string;
newQueryState: string;
total: number;
removed: string[];
added: Array<{ id: string; index: number }>;
}
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
// query. Returns null when the server replies with `cannotCalculateChanges`
// (or any other error) — caller should fall back to a fresh Email/query.
export async function getEmailQueryChanges(
mailboxId: string,
sinceQueryState: string,
options?: {
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
upToId?: string;
maxChanges?: number;
},
): Promise<EmailQueryChangesResult | null> {
const accountId = jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
sinceQueryState,
calculateTotal: true,
};
if (options?.upToId) args.upToId = options.upToId;
if (options?.maxChanges) args.maxChanges = options.maxChanges;
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldQueryState: body.oldQueryState as string,
newQueryState: body.newQueryState as string,
total: (body.total as number) ?? 0,
removed: (body.removed as string[]) ?? [],
added: (body.added as Array<{ id: string; index: number }>) ?? [],
};
}
@@ -129,6 +230,59 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
return res.methodResponses[0][1].list;
}
// Returns the Email/get response with the JMAP `state` token. Used by the
// store so we can later issue Email/changes(sinceState=…) for incremental
// updates instead of re-fetching the full list.
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
if (ids.length === 0) {
// Email/get with an empty id list still returns a state token; useful for
// priming the store after an empty mailbox query.
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: [], state: body.state as string };
}
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: body.list as Email[], state: body.state as string };
}
export interface EmailChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server replies with `cannotCalculateChanges` or any
// other error response; caller should treat that as "rebuild from scratch".
export async function getEmailChanges(
sinceState: string,
maxChanges?: number,
): Promise<EmailChangesResult | null> {
const accountId = jmapClient.accountId;
const args: Record<string, unknown> = { accountId, sinceState };
if (maxChanges) args.maxChanges = maxChanges;
const res = await jmapClient.request([['Email/changes', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function getFullEmail(id: string): Promise<Email> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
+35
View File
@@ -0,0 +1,35 @@
import { CAPABILITIES } from '../api/types';
import { useAuthStore } from '../stores/auth-store';
// When the session is null (cold start, offline restore) we assume features
// are available so they don't flicker off mid-restore. Once the live session
// arrives, the real capability set takes over.
function sessionHasCapability(capability: string): boolean {
const session = useAuthStore.getState().session;
if (!session) return true;
return capability in session.capabilities;
}
export function hasCalendarCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CALENDARS);
}
export function hasContactsCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CONTACTS);
}
export function hasFilesCapability(): boolean {
return sessionHasCapability(CAPABILITIES.FILES);
}
export function useHasCalendar(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
}
export function useHasContacts(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
}
export function useHasFiles(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
}
+21 -3
View File
@@ -36,6 +36,7 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -155,6 +156,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
// Subscribe to locale so labels re-render when the user picks a different language.
const locale = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t);
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const unavailableTabs = React.useMemo(() => {
const set = new Set<Tab>();
if (!hasCalendar) set.add('calendar');
if (!hasContacts) set.add('contacts');
if (!hasFiles) set.add('files');
return set;
}, [hasCalendar, hasContacts, hasFiles]);
const groupedTabs = React.useMemo(() => {
void locale; // dependency: re-translate on locale change
return groupTabs().map((g) => ({
@@ -178,6 +189,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
if (unavailableTabs.has(tab.id)) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
@@ -241,7 +253,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
{group.items.map((tab) => {
const Icon = tab.icon;
const disabled = !tab.implemented;
const unavailable = unavailableTabs.has(tab.id);
const disabled = !tab.implemented || unavailable;
const badgeLabel = !tab.implemented
? 'Not implemented'
: unavailable
? 'Unavailable'
: null;
return (
<Pressable
key={tab.id}
@@ -269,9 +287,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
</View>
)}
</View>
{disabled ? (
{badgeLabel ? (
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not implemented</Text>
<Text style={styles.notWorkingText}>{badgeLabel}</Text>
</View>
) : (
<ChevronRight size={16} color={c.mutedForeground} />
+37 -6
View File
@@ -2,8 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/email', () => ({
getMailboxes: vi.fn(),
// New helpers used by the incremental-sync path. Default behavior: behave
// like a first-ever load — no prior state, full re-query expected.
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getMailboxChanges: vi.fn(async () => null),
queryEmails: vi.fn(),
getEmailQueryChanges: vi.fn(async () => null),
getEmails: vi.fn(),
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
getEmailChanges: vi.fn(async () => null),
getFullEmail: vi.fn(),
setEmailKeywords: vi.fn(),
moveEmail: vi.fn(),
@@ -20,6 +28,23 @@ 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.
vi.mock('../offline-cache-store', () => ({
useOfflineCacheStore: {
getState: () => ({
hydrated: true,
hydrate: vi.fn(),
totalCount: () => 0,
getEmailsInMailbox: vi.fn(async () => []),
has: () => false,
get: vi.fn(async () => null),
put: vi.fn(async () => undefined),
}),
},
}));
// 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.
@@ -28,15 +53,18 @@ vi.mock('../../api/jmap-client', () => ({
isConnected: true,
accountId: 'acc-1',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
getMaxObjectsInGet: () => 500,
},
}));
import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
const mockGetMailboxes = emailApi.getMailboxes as ReturnType<typeof vi.fn>;
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
const mockSetKeywords = emailApi.setEmailKeywords as ReturnType<typeof vi.fn>;
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
@@ -55,7 +83,7 @@ describe('email-store', () => {
{ id: 'mb-1', name: 'Inbox', role: 'inbox' },
{ id: 'mb-2', name: 'Sent', role: 'sent' },
];
mockGetMailboxes.mockResolvedValue(mailboxes);
mockGetMailboxesWithState.mockResolvedValue({ list: mailboxes, state: 'mb-state-1' });
await useEmailStore.getState().fetchMailboxes();
@@ -63,7 +91,7 @@ describe('email-store', () => {
});
it('should set error on failure', async () => {
mockGetMailboxes.mockRejectedValue(new Error('Network error'));
mockGetMailboxesWithState.mockRejectedValue(new Error('Network error'));
await useEmailStore.getState().fetchMailboxes();
@@ -73,12 +101,12 @@ describe('email-store', () => {
describe('selectMailbox', () => {
it('should query and fetch emails for mailbox', async () => {
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2 });
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2, queryState: 'q-1' });
const emails = [
{ id: 'e1', subject: 'Email 1' },
{ id: 'e2', subject: 'Email 2' },
];
mockGetEmails.mockResolvedValue(emails);
mockGetEmailsWithState.mockResolvedValue({ list: emails, state: 'em-state-1' });
await useEmailStore.getState().selectMailbox('mb-1');
@@ -90,11 +118,14 @@ describe('email-store', () => {
});
it('should handle empty mailbox', async () => {
mockQueryEmails.mockResolvedValue({ ids: [], total: 0 });
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q-empty' });
await useEmailStore.getState().selectMailbox('mb-empty');
expect(useEmailStore.getState().emails).toEqual([]);
// First-time load with no ids hits getEmailsWithState only to prime
// emailState (with an empty ids array). It should NOT call legacy
// getEmails — that path is reserved for pagination/search.
expect(mockGetEmails).not.toHaveBeenCalled();
});
});
+63 -19
View File
@@ -46,8 +46,28 @@ export interface AuthState {
clearError: () => void;
}
function resetFeatureStores(): void {
useEmailStore.getState().reset();
// Wipe ALL cached feature data for ALL accounts. Used for logoutAll where
// the user is signing out of everything — we don't want stale snapshots
// lingering on disk for accounts that no longer exist.
function clearAllFeatureStores(): void {
useEmailStore.getState().clearAllAccounts();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
// Drop the named account from the email cache, then reset the (per-session,
// not yet per-account) contacts and calendar stores. Used by logout when
// signing one account out while others remain.
function clearAccountFeatureStores(accountId: string | null): void {
if (accountId) {
useEmailStore.getState().removeAccount(accountId);
} else {
useEmailStore.getState().clearAllAccounts();
}
// Contacts and calendar stores aren't yet keyed by account — the safe
// thing on logout is still to wipe them so the next account doesn't see
// the previous user's data. Per-account caching for those stores is a
// follow-up.
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
@@ -108,11 +128,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (serverUrl, username, password, opts) => {
set({ isLoading: true, error: null });
try {
// Adding an additional account - snapshot/reset feature stores so the
// new account starts with a clean slate.
// Adding an additional account - snapshot the current account away so
// its cache survives, then reset the JMAP client for the new login.
// Contacts/calendar are still single-bucket, so wipe those.
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
resetFeatureStores();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const session = await jmapClient.connect(serverUrl, username, password);
@@ -129,6 +151,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
hasError: false,
});
accountStore.setActiveAccount(accountId);
// Swap the email store's active view to the new account so the rest of
// this function (and refetchFeatureStores) writes to the right bucket.
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
} catch (err) {
@@ -164,7 +189,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
jmapClient.reset();
resetFeatureStores();
clearAccountFeatureStores(currentId);
// Switch to next remaining account, if any
const remaining = accountStore.accounts;
@@ -198,7 +223,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await teardownPushNotifications().catch(() => undefined);
await jmapClient.clearAllCredentials(ids);
jmapClient.reset();
resetFeatureStores();
clearAllFeatureStores();
for (const id of ids) accountStore.removeAccount(id);
@@ -225,19 +250,31 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: true, error: null });
// Load the new account's session BEFORE clearing jmapClient or feature
// stores. The previous order (reset → resetFeatureStores → loadAccount)
// left jmapClient with no accountId during the loadAccount await, and
// any fetch fired by a screen effect reacting to the emptied stores
// (e.g. EmailListScreen retrying fetchMailboxes when mailboxes goes to
// []) threw "Not authenticated - call connect() first". loadAccount
// overwrites credentials/session/_accountId itself, so the pre-reset
// is unnecessary.
// Swap the email-store view to the new account *before* the network
// round-trip. The previous account's data is tucked into its snapshot;
// the new account's data (if previously cached) is restored to the
// top-level fields so the EmailListScreen immediately shows the new
// account's last-known mail instead of flashing empty. The network
// refresh below applies incremental updates on top.
useEmailStore.getState().setActiveAccount(accountId);
// Contacts and calendar stores aren't yet per-account, so they still
// need a reset to avoid showing the previous account's data.
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
// Load the new account's session. loadAccount overwrites
// credentials/session/_accountId itself, so we don't need to reset
// jmapClient first. If it fails, restore the previous active account
// so we don't leave the user stranded on a half-switched state.
const previousActive = get().activeAccountId;
try {
const ok = await jmapClient.loadAccount(accountId);
if (!ok) {
// Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId);
useEmailStore.getState().removeAccount(accountId);
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
set({ isLoading: false, error: 'Session expired for this account' });
return;
}
@@ -245,11 +282,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (err instanceof AuthenticationError) {
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
accountStore.removeAccount(accountId);
useEmailStore.getState().removeAccount(accountId);
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
set({ isLoading: false, error: 'Session expired for this account' });
return;
}
// NetworkError or anything else - keep the previous active account
// intact instead of stranding the user on a half-switched state.
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to switch account',
@@ -257,10 +297,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return;
}
// Session is in place under the new account. Safe now to wipe the
// previous account's cached feature data; the refetch below repopulates.
resetFeatureStores();
accountStore.setActiveAccount(accountId);
accountStore.updateAccount(accountId, {
isConnected: true,
@@ -320,11 +356,18 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return false;
}
// Point the email store at the target account before any await — so
// the EmailListScreen, which re-renders the moment the persisted state
// hydrates, sees the right account's cached emails instead of stale
// data from a previous session.
useEmailStore.getState().setActiveAccount(target.id);
try {
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
// No stored credentials (or corrupt) — genuine logout.
accountStore.removeAccount(target.id);
useEmailStore.getState().removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
return false;
}
@@ -358,6 +401,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Server reachable but credentials rejected — drop them.
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
accountStore.removeAccount(target.id);
useEmailStore.getState().removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
return false;
}
+500 -64
View File
@@ -5,8 +5,14 @@ import type { Email, Mailbox, StateChange } from '../api/types';
import { jmapClient } from '../api/jmap-client';
import {
getMailboxes as fetchMailboxes,
getMailboxesWithState,
getMailboxesByIds,
getMailboxChanges,
queryEmails,
getEmailQueryChanges,
getEmails as fetchEmails,
getEmailsWithState,
getEmailChanges,
getFullEmail,
setEmailKeywords,
moveEmail,
@@ -43,17 +49,56 @@ export interface UndoEntry {
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
}
export interface EmailState {
// Cached emails for one mailbox (the base view: no search query, no filters).
// `queryState` is the JMAP queryState for the matching Email/query, used to
// drive Email/queryChanges on the next refresh.
interface MailboxSnapshot {
emails: Email[];
total: number;
queryState?: string;
}
// Everything we cache for one account so switching accounts can restore the
// previous view instantly instead of going through a network round-trip.
interface AccountSnapshot {
mailboxes: Mailbox[];
mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes)
emailState?: string; // JMAP Email state (drives Email/changes)
currentMailboxId: string | null;
mailboxSnapshots: Record<string, MailboxSnapshot>;
}
export interface EmailState {
// ── Per-account persisted caches ──────────────────────────────
// accountSnapshots is the source of truth for accounts the user is *not*
// currently viewing. The active account's data lives in the top-level
// fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailState`,
// `currentMailboxId`, `emails`, `totalEmails`, `queryState`) so consumers
// keep reading the same shape they always have.
accountSnapshots: Record<string, AccountSnapshot>;
activeAccountId: string | null;
// ── Active view (the currently-shown account/mailbox) ─────────
mailboxes: Mailbox[];
mailboxState?: string;
emailState?: string;
currentMailboxId: string | null;
mailboxSnapshots: Record<string, MailboxSnapshot>;
emails: Email[];
totalEmails: number;
queryState?: string; // queryState for the currently-shown mailbox
// ── UI state (not persisted, not per-account) ─────────────────
loading: boolean;
error: string | null;
searchQuery: string;
filters: EmailFilters;
pendingUndo: UndoEntry | null;
// ── Actions ────────────────────────────────────────────────────
setActiveAccount: (accountId: string | null) => void;
removeAccount: (accountId: string) => void;
clearAllAccounts: () => void;
fetchMailboxes: () => Promise<void>;
selectMailbox: (mailboxId: string) => Promise<void>;
loadMoreEmails: () => Promise<void>;
@@ -115,19 +160,207 @@ function buildJmapFilter(
return { operator: 'AND', conditions };
}
// True when the user has no search/filters active. Only in this case do we
// touch the per-mailbox snapshot cache or use Email/queryChanges — once a
// filter is in play, the queryState belongs to a different query and the
// cached list no longer represents what's on screen.
function isBaseView(searchQuery: string, filters: EmailFilters): boolean {
return !searchQuery.trim() && Object.keys(filters).length === 0;
}
function snapshotFromActive(state: EmailState): AccountSnapshot {
// Persist the currently-visible mailbox into its snapshot before tucking
// the whole account away.
let mailboxSnapshots = state.mailboxSnapshots;
if (state.currentMailboxId && isBaseView(state.searchQuery, state.filters)) {
mailboxSnapshots = {
...mailboxSnapshots,
[state.currentMailboxId]: {
emails: state.emails,
total: state.totalEmails,
queryState: state.queryState,
},
};
}
return {
mailboxes: state.mailboxes,
mailboxState: state.mailboxState,
emailState: state.emailState,
currentMailboxId: state.currentMailboxId,
mailboxSnapshots,
};
}
function viewFromSnapshot(snap: AccountSnapshot | null): {
mailboxes: Mailbox[];
mailboxState?: string;
emailState?: string;
currentMailboxId: string | null;
mailboxSnapshots: Record<string, MailboxSnapshot>;
emails: Email[];
totalEmails: number;
queryState?: string;
} {
if (!snap) {
return {
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
totalEmails: 0,
queryState: undefined,
};
}
const mailboxSnap = snap.currentMailboxId
? snap.mailboxSnapshots[snap.currentMailboxId]
: undefined;
return {
mailboxes: snap.mailboxes,
mailboxState: snap.mailboxState,
emailState: snap.emailState,
currentMailboxId: snap.currentMailboxId,
mailboxSnapshots: snap.mailboxSnapshots,
emails: mailboxSnap?.emails ?? [],
totalEmails: mailboxSnap?.total ?? 0,
queryState: mailboxSnap?.queryState,
};
}
// JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get.
// Chunk to that ceiling (with a small safety fallback) so large change sets
// don't trip 429/413 responses.
async function fetchEmailsChunked(ids: string[]): Promise<Email[]> {
if (ids.length === 0) return [];
const cap = Math.max(1, jmapClient.getMaxObjectsInGet());
const chunk = Math.min(cap, 200);
if (ids.length <= chunk) return fetchEmails(ids);
const out: Email[] = [];
for (let i = 0; i < ids.length; i += chunk) {
const slice = await fetchEmails(ids.slice(i, i + chunk));
out.push(...slice);
}
return out;
}
// Merge a fresh batch of emails into an existing list keyed by id. New entries
// replace stale ones (keywords/mailboxIds may have changed); destroyed ids are
// dropped. Order is preserved according to the supplied id order — pass the
// authoritative id list from Email/query when re-syncing.
function applyEmailDiff(
current: Email[],
orderedIds: string[],
fetched: Email[],
destroyed: Set<string>,
): Email[] {
const byId = new Map<string, Email>();
for (const e of current) byId.set(e.id, e);
for (const e of fetched) byId.set(e.id, e);
const out: Email[] = [];
for (const id of orderedIds) {
if (destroyed.has(id)) continue;
const e = byId.get(id);
if (e) out.push(e);
}
return out;
}
export const useEmailStore = create<EmailState>()(
persist(
(set, get) => ({
accountSnapshots: {},
activeAccountId: null,
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
totalEmails: 0,
queryState: undefined,
loading: false,
error: null,
searchQuery: '',
filters: {},
pendingUndo: null,
// Swap which account's data is currently visible. The previous account's
// view is tucked into accountSnapshots so a return-trip can restore it
// without a network call; the new account's view is pulled from its
// snapshot (or empty defaults if we've never seen it). Callers (auth-store)
// run the network refresh afterwards.
setActiveAccount: (accountId) => {
const state = get();
if (state.activeAccountId === accountId) return;
const nextSnapshots = { ...state.accountSnapshots };
if (state.activeAccountId) {
nextSnapshots[state.activeAccountId] = snapshotFromActive(state);
}
const incoming = accountId ? nextSnapshots[accountId] ?? null : null;
const view = viewFromSnapshot(incoming);
set({
accountSnapshots: nextSnapshots,
activeAccountId: accountId,
...view,
// UI state is reset on switch — search/filters and pending undo belong
// to the previous account's intent.
searchQuery: '',
filters: {},
pendingUndo: null,
error: null,
loading: false,
});
},
removeAccount: (accountId) => {
const state = get();
const { [accountId]: _drop, ...rest } = state.accountSnapshots;
if (state.activeAccountId === accountId) {
set({
accountSnapshots: rest,
activeAccountId: null,
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
totalEmails: 0,
queryState: undefined,
searchQuery: '',
filters: {},
pendingUndo: null,
});
} else {
set({ accountSnapshots: rest });
}
},
clearAllAccounts: () => {
set({
accountSnapshots: {},
activeAccountId: null,
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
totalEmails: 0,
queryState: undefined,
searchQuery: '',
filters: {},
pendingUndo: null,
error: null,
loading: false,
});
},
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
@@ -136,48 +369,118 @@ export const useEmailStore = create<EmailState>()(
// 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;
const prevState = get().mailboxState;
try {
const mailboxes = await fetchMailboxes();
set({ mailboxes });
// Incremental path: ask for just what changed since last time. Fall
// through to a full refetch when the server can't compute the diff or
// we have no previous state to compare against.
if (prevState) {
const changes = await getMailboxChanges(prevState);
if (changes) {
// No changes at all — keep the cached list, just bump the state.
if (
changes.created.length === 0 &&
changes.updated.length === 0 &&
changes.destroyed.length === 0
) {
set({ mailboxState: changes.newState });
return;
}
const toFetch = [...changes.created, ...changes.updated];
const fetched = toFetch.length > 0
? (await getMailboxesByIds(toFetch)).list
: [];
const destroyed = new Set(changes.destroyed);
const byId = new Map<string, Mailbox>();
for (const m of get().mailboxes) byId.set(m.id, m);
for (const m of fetched) byId.set(m.id, m);
for (const id of destroyed) byId.delete(id);
set({
mailboxes: Array.from(byId.values()),
mailboxState: changes.hasMoreChanges ? prevState : changes.newState,
});
// hasMoreChanges = there are still pending changes past the
// server's response cap. Run the same path again to drain.
if (changes.hasMoreChanges) {
void get().fetchMailboxes();
}
return;
}
// changes === null → cannotCalculateChanges. Fall through to full.
}
const { list, state } = await getMailboxesWithState();
set({ mailboxes: list, mailboxState: state });
} catch (err) {
console.warn('[email-store] fetchMailboxes failed:', err);
set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' });
// 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.
if (get().mailboxes.length === 0) {
set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' });
}
}
},
selectMailbox: async (mailboxId) => {
// Reset search/filters when switching mailbox - matches webmail behavior.
// Switching mailboxes invalidates any pending undo: the snackbar would be
// stale and the restored email would re-appear in a different view.
const state = get();
// Tuck the previously-visible mailbox into its snapshot so a return-trip
// can restore it without a network call. Only do this for the base view —
// a filter or search makes the visible list unrepresentative of the
// cached "no-filter" snapshot.
let mailboxSnapshots = state.mailboxSnapshots;
if (
state.currentMailboxId &&
state.currentMailboxId !== mailboxId &&
isBaseView(state.searchQuery, state.filters)
) {
mailboxSnapshots = {
...mailboxSnapshots,
[state.currentMailboxId]: {
emails: state.emails,
total: state.totalEmails,
queryState: state.queryState,
},
};
}
const incoming = mailboxSnapshots[mailboxId];
// Swap to the new mailbox's cached view immediately. If there's no
// snapshot, fall through to the offline cache as a second-best seed;
// if that's also empty we render the empty-state, not a spinner over
// a blank list — better than the previous flash to "Loading…".
let seededEmails: Email[] = incoming?.emails ?? [];
let seededTotal = incoming?.total ?? 0;
let seededQueryState = incoming?.queryState;
if (seededEmails.length === 0) {
const cacheStore = useOfflineCacheStore.getState();
if (!cacheStore.hydrated) await cacheStore.hydrate();
if (cacheStore.totalCount() > 0) {
try {
const limit = useSettingsStore.getState().emailsPerPage;
seededEmails = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
seededTotal = seededEmails.length;
} catch (err) {
console.warn('[email-store] cache seed failed:', err);
}
}
}
set({
currentMailboxId: mailboxId,
emails: seededEmails,
totalEmails: seededTotal,
queryState: seededQueryState,
mailboxSnapshots,
loading: true,
error: null,
emails: [],
totalEmails: 0,
searchQuery: '',
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);
}
}
// 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
@@ -187,25 +490,7 @@ export const useEmailStore = create<EmailState>()(
return;
}
try {
const filter = buildJmapFilter(mailboxId, '', {});
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);
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'),
});
}
await get().refreshEmails();
},
loadMoreEmails: async () => {
@@ -222,37 +507,174 @@ export const useEmailStore = create<EmailState>()(
limit,
filter,
});
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails: [...emails, ...newEmails], loading: false });
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : [];
const merged = [...emails, ...newEmails];
const updates: Partial<EmailState> = { emails: merged, loading: false };
if (isBaseView(searchQuery, filters)) {
updates.mailboxSnapshots = {
...get().mailboxSnapshots,
[currentMailboxId]: {
emails: merged,
total: get().totalEmails,
queryState: get().queryState,
},
};
}
set(updates);
} catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
}
},
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
const state = get();
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState } = state;
if (!currentMailboxId) return;
if (!jmapClient.isConnected) 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.
set({ loading: true, error: null });
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const baseView = isBaseView(searchQuery, filters);
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const { ids, total } = await queryEmails(currentMailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails, totalEmails: total, loading: false });
// Incremental sync path: requires the base unfiltered view AND a known
// queryState (so Email/queryChanges has something to diff against).
// Anything else — search, filter active, first-ever load — falls
// through to a full re-query.
if (baseView && queryState) {
const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, {
filter: undefined,
});
if (queryChanges) {
// What's in the visible window now: drop removed ids, then apply
// added (id, index) entries. Newly added ids need bodies fetched.
const removed = new Set(queryChanges.removed);
const addedIds = queryChanges.added.map((a) => a.id);
// Email/changes catches updates to messages already in our list
// (e.g. another device toggled $seen) that queryChanges wouldn't
// report. Skipped when we have no emailState yet — first refresh
// after a cold start primes it from the Email/get below.
let updatedIds: string[] = [];
let destroyedExtra: string[] = [];
let nextEmailState: string | undefined = emailState;
if (emailState) {
const ec = await getEmailChanges(emailState);
if (ec) {
updatedIds = ec.updated;
destroyedExtra = ec.destroyed;
nextEmailState = ec.newState;
} else {
// cannotCalculateChanges → forget the state and rely on the
// next full re-sync to repopulate it.
nextEmailState = undefined;
}
}
// Fetch only what we don't already have. `addedIds` are new to the
// window; `updatedIds` may already be in `existing` but their
// keywords/mailboxIds need refreshing.
const existingById = new Map(existing.map((e) => [e.id, e]));
const idsToFetch = [
...addedIds.filter((id) => !existingById.has(id)),
...updatedIds.filter((id) => existingById.has(id)),
];
let fetchState: string | undefined;
let fetched: Email[] = [];
if (idsToFetch.length > 0) {
const res = await getEmailsWithState(idsToFetch);
fetched = res.list;
fetchState = res.state;
}
// Rebuild the visible window order: start with existing emails,
// drop removed/destroyed, then splice added at their indices.
const allDestroyed = new Set([...destroyedExtra, ...removed]);
const kept = existing.filter((e) => !allDestroyed.has(e.id));
// Map updated entries onto kept array
const fetchedById = new Map(fetched.map((e) => [e.id, e]));
const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e);
// Insert added entries at the indices the server gave us. Sort
// ascending by index so each splice lands at the right offset.
const sortedAdded = [...queryChanges.added].sort((a, b) => a.index - b.index);
const out = [...updatedKept];
for (const entry of sortedAdded) {
const email = fetchedById.get(entry.id);
if (!email) continue;
const idx = Math.min(entry.index, out.length);
out.splice(idx, 0, email);
}
// Cap the visible list to the user's page size — Email/queryChanges
// can push entries past the original window if many were added.
const trimmed = out.slice(0, Math.max(limit, out.length));
const nextQueryState = queryChanges.newQueryState;
const nextTotal = queryChanges.total;
if (get().currentMailboxId !== currentMailboxId) return;
set({
emails: trimmed,
totalEmails: nextTotal,
queryState: nextQueryState,
emailState: nextEmailState ?? fetchState ?? emailState,
loading: false,
mailboxSnapshots: {
...get().mailboxSnapshots,
[currentMailboxId]: {
emails: trimmed,
total: nextTotal,
queryState: nextQueryState,
},
},
});
return;
}
// queryChanges === null → cannotCalculateChanges. Drop our queryState
// and fall through to a full re-query, which will repopulate it.
}
// Full re-query path. Used when there's no prior queryState, when the
// user has search/filters active (queryState only tracks the base
// query), or when the server returned cannotCalculateChanges above.
const queryRes = await queryEmails(currentMailboxId, { limit, filter });
const fetched = queryRes.ids.length > 0
? await getEmailsWithState(queryRes.ids)
: { list: [], state: undefined as string | undefined };
if (get().currentMailboxId !== currentMailboxId) return;
const updates: Partial<EmailState> = {
emails: fetched.list,
totalEmails: queryRes.total,
loading: false,
};
if (baseView) {
updates.queryState = queryRes.queryState;
updates.emailState = fetched.state;
updates.mailboxSnapshots = {
...get().mailboxSnapshots,
[currentMailboxId]: {
emails: fetched.list,
total: queryRes.total,
queryState: queryRes.queryState,
},
};
}
set(updates);
} 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 (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.
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),
@@ -266,7 +688,10 @@ export const useEmailStore = create<EmailState>()(
console.warn('[email-store] refresh cache fallback failed:', cacheErr);
}
}
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
set({
loading: false,
error: existing.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'),
});
}
},
@@ -527,9 +952,13 @@ export const useEmailStore = create<EmailState>()(
reset: () => set({
mailboxes: [],
mailboxState: undefined,
emailState: undefined,
currentMailboxId: null,
mailboxSnapshots: {},
emails: [],
totalEmails: 0,
queryState: undefined,
loading: false,
error: null,
searchQuery: '',
@@ -537,16 +966,23 @@ export const useEmailStore = create<EmailState>()(
}),
}),
{
// Persist the mail list so the UI can render instantly on re-open,
// before the JMAP session has finished restoring. auth-store triggers
// a background refresh once the session is ready.
// Persist the per-account caches and the active view so the UI can
// render instantly on re-open / account switch, before the JMAP
// session has finished restoring. auth-store triggers a background
// refresh once the session is ready.
name: 'email-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
accountSnapshots: state.accountSnapshots,
activeAccountId: state.activeAccountId,
mailboxes: state.mailboxes,
mailboxState: state.mailboxState,
emailState: state.emailState,
currentMailboxId: state.currentMailboxId,
mailboxSnapshots: state.mailboxSnapshots,
emails: state.emails,
totalEmails: state.totalEmails,
queryState: state.queryState,
}),
},
),