mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81749dd89c | ||
|
|
beef086d4c | ||
|
|
cfd67398de | ||
|
|
2b15d2dc50 | ||
|
|
9bf2ecdf27 | ||
|
|
67db8a2ac3 | ||
|
|
6846d859fd |
@@ -15,7 +15,7 @@ import {
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
@@ -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
|
||||
@@ -289,18 +315,24 @@ export default function App() {
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down the active subscription so the
|
||||
// FirebaseMessagingService stops receiving pings for this device.
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||
// setups intact.
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
let cancelled = false;
|
||||
const doSetup = async () => {
|
||||
if (!emailNotificationsEnabled) {
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
@@ -328,7 +360,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.33",
|
||||
"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.29",
|
||||
"version": "0.1.33",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,9 +17,10 @@ import { useSettingsStore, type NotificationSound } from '../../stores/settings-
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
getStoredRelayBaseUrl,
|
||||
readPushAccountIds,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../../lib/push-notifications';
|
||||
|
||||
const SOUNDS: { value: NotificationSound; label: string }[] = [
|
||||
@@ -40,6 +41,7 @@ export function NotificationSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
@@ -106,10 +108,20 @@ export function NotificationSettings() {
|
||||
setConfirmDisable(false);
|
||||
setPushStatus({ kind: 'busy', message: 'Disabling…' });
|
||||
try {
|
||||
await teardownPushNotifications();
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId);
|
||||
}
|
||||
// The relay base URL is a device-wide setting shared with any other
|
||||
// accounts that are still using push. Only clear it (and reset the UI
|
||||
// to "not configured") when no accounts are using push any more —
|
||||
// otherwise the remaining accounts would lose their relay URL and
|
||||
// their next setup attempt would no-op.
|
||||
const remaining = await readPushAccountIds();
|
||||
if (remaining.length === 0) {
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
}
|
||||
setPushStatus({ kind: 'idle' });
|
||||
} catch (error) {
|
||||
setPushStatus({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+131
-49
@@ -8,13 +8,22 @@ import {
|
||||
getFaviconDomain,
|
||||
getFaviconUrl,
|
||||
} from './avatar-utils';
|
||||
import {
|
||||
deviceClientIdKey,
|
||||
lastNotifiedKey,
|
||||
migrateLegacyPushKeys,
|
||||
readPushAccountIds,
|
||||
} from './push-notifications';
|
||||
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
||||
// import the Zustand store (would pull in React) so we read AsyncStorage
|
||||
// directly. Keep this in sync if the store key ever changes.
|
||||
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
|
||||
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
|
||||
// the user's active account at the end of the headless task so we can leave
|
||||
// the jmapClient singleton in a state consistent with what the UI expects
|
||||
// when the app resumes.
|
||||
const ACCOUNT_REGISTRY_KEY = 'account-registry';
|
||||
|
||||
interface PushPersistedSettings {
|
||||
emailNotificationsEnabled?: boolean;
|
||||
@@ -48,71 +57,144 @@ interface BulwarkFcmNative {
|
||||
showNotification(opts: ShowNotificationOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
|
||||
// received the push on (the URL slot from createPushSubscription). The exact
|
||||
// key the relay uses isn't part of any client-controlled contract, so rather
|
||||
// than guessing a field name we scan all string values in the data payload
|
||||
// and look for one that matches a deviceClientId we've registered locally.
|
||||
// Returns null if nothing matches (e.g. legacy relay payload format) so the
|
||||
// caller falls back to iterating every known account.
|
||||
async function identifyAccountFromFcmData(
|
||||
data: unknown,
|
||||
accountIds: string[],
|
||||
): Promise<string | null> {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const reverseMap = new Map<string, string>();
|
||||
for (const accountId of accountIds) {
|
||||
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||
if (dcid) reverseMap.set(dcid, accountId);
|
||||
}
|
||||
if (reverseMap.size === 0) return null;
|
||||
|
||||
for (const value of Object.values(data as Record<string, unknown>)) {
|
||||
if (typeof value === 'string') {
|
||||
const match = reverseMap.get(value);
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the persisted active account directly from AsyncStorage (Zustand's
|
||||
// persist middleware stores the JSON-serialised state under its `name`).
|
||||
async function readActiveAccountId(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
|
||||
return parsed.state?.activeAccountId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
|
||||
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
export async function pushBackgroundTask(data: unknown): Promise<void> {
|
||||
let activeAccountId: string | null = null;
|
||||
try {
|
||||
if (!(await emailNotificationsAllowed())) return;
|
||||
|
||||
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
|
||||
if (!accountId) return;
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const loaded = await jmapClient.loadAccount(accountId);
|
||||
if (!loaded) return;
|
||||
const accountIds = await readPushAccountIds();
|
||||
if (accountIds.length === 0) return;
|
||||
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
||||
if (!inbox) return;
|
||||
activeAccountId = await readActiveAccountId();
|
||||
|
||||
const { ids } = await queryEmails(inbox.id, {
|
||||
filter: { notKeyword: '$seen' },
|
||||
limit: 1,
|
||||
});
|
||||
if (ids.length === 0) return;
|
||||
const matched = await identifyAccountFromFcmData(data, accountIds);
|
||||
const accountsToCheck = matched ? [matched] : accountIds;
|
||||
|
||||
const emailId = ids[0];
|
||||
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
if (previouslyNotified === emailId) return;
|
||||
|
||||
const [email] = await getEmails([emailId]);
|
||||
if (!email) return;
|
||||
|
||||
const from = email.from?.[0];
|
||||
const name = from?.name ?? '';
|
||||
const address = from?.email ?? '';
|
||||
const title = name || address || 'New mail';
|
||||
const body = email.subject || '(no subject)';
|
||||
const initials = getEmailInitials(name, address);
|
||||
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
|
||||
const faviconDomain = getFaviconDomain(address);
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||
if (!native?.showNotification) return;
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${emailId}`,
|
||||
title,
|
||||
body,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
accountId,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
|
||||
for (const accountId of accountsToCheck) {
|
||||
try {
|
||||
await processAccountForPush(accountId);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[push] background check failed for account',
|
||||
accountId,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[push] background task failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
} finally {
|
||||
// Leave the singleton bound to the user's active account so the UI sees
|
||||
// a consistent jmapClient state when the app resumes. Without this, the
|
||||
// last account processed above would remain loaded and the UI's cached
|
||||
// email-store data would not match what the next JMAP request returns.
|
||||
if (activeAccountId) {
|
||||
await jmapClient.loadAccount(activeAccountId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processAccountForPush(accountId: string): Promise<void> {
|
||||
const loaded = await jmapClient.loadAccount(accountId);
|
||||
if (!loaded) return;
|
||||
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
||||
if (!inbox) return;
|
||||
|
||||
const { ids } = await queryEmails(inbox.id, {
|
||||
filter: { notKeyword: '$seen' },
|
||||
limit: 1,
|
||||
});
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const emailId = ids[0];
|
||||
const lastKey = lastNotifiedKey(accountId);
|
||||
const previouslyNotified = await AsyncStorage.getItem(lastKey);
|
||||
if (previouslyNotified === emailId) return;
|
||||
|
||||
const [email] = await getEmails([emailId]);
|
||||
if (!email) return;
|
||||
|
||||
const from = email.from?.[0];
|
||||
const name = from?.name ?? '';
|
||||
const address = from?.email ?? '';
|
||||
const title = name || address || 'New mail';
|
||||
const body = email.subject || '(no subject)';
|
||||
const initials = getEmailInitials(name, address);
|
||||
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
|
||||
const faviconDomain = getFaviconDomain(address);
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||
if (!native?.showNotification) return;
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${emailId}`,
|
||||
title,
|
||||
body,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
accountId,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(lastKey, emailId);
|
||||
}
|
||||
|
||||
function hslToHex(hsl: string): string {
|
||||
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
|
||||
if (!match) return '#2563eb';
|
||||
|
||||
+201
-43
@@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client';
|
||||
import { generateAccountId } from './account-utils';
|
||||
|
||||
// Persist identifiers across launches so we reuse the same JMAP subscription
|
||||
// after app restarts. deviceClientId also routes to the relay slot.
|
||||
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
|
||||
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
|
||||
// after app restarts. Each account gets its own deviceClientId so the hosted
|
||||
// relay can distinguish per-account pushes via the URL slot it forwards.
|
||||
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
|
||||
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
|
||||
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
|
||||
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
|
||||
|
||||
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
||||
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
||||
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
|
||||
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
|
||||
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
|
||||
export function deviceClientIdKey(accountId: string): string {
|
||||
return DEVICE_CLIENT_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function subscriptionIdKey(accountId: string): string {
|
||||
return SUBSCRIPTION_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export function lastNotifiedKey(accountId: string): string {
|
||||
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export async function readPushAccountIds(): Promise<string[]> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writePushAccountIds(ids: string[]): Promise<void> {
|
||||
const deduped = Array.from(new Set(ids));
|
||||
if (deduped.length === 0) {
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
} else {
|
||||
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot migration from the pre-multi-account schema. If the legacy
|
||||
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
|
||||
// setup: reuse the legacy deviceClientId and JMAP subscription id under
|
||||
// the new per-account keys so the user doesn't lose push on upgrade.
|
||||
export async function migrateLegacyPushKeys(): Promise<void> {
|
||||
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
|
||||
if (!legacyAccountId) return;
|
||||
|
||||
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
|
||||
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
|
||||
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
|
||||
if (legacyDcid) {
|
||||
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
|
||||
}
|
||||
if (legacySubId) {
|
||||
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
|
||||
}
|
||||
if (legacyLastId) {
|
||||
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
|
||||
}
|
||||
|
||||
const ids = await readPushAccountIds();
|
||||
if (!ids.includes(legacyAccountId)) {
|
||||
await writePushAccountIds([...ids, legacyAccountId]);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiRemove([
|
||||
LEGACY_PUSH_ACCOUNT_ID_KEY,
|
||||
LEGACY_DEVICE_CLIENT_ID_KEY,
|
||||
LEGACY_SUBSCRIPTION_ID_KEY,
|
||||
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
|
||||
]);
|
||||
}
|
||||
|
||||
// Hosted relay so users don't need to run their own Firebase project. The
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
|
||||
@@ -72,11 +148,12 @@ function randomClientId(): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function getOrCreateDeviceClientId(): Promise<string> {
|
||||
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
|
||||
const key = deviceClientIdKey(accountId);
|
||||
const existing = await AsyncStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const next = randomClientId();
|
||||
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
|
||||
await AsyncStorage.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -184,20 +261,24 @@ export async function setupPushNotifications(
|
||||
const fcmToken = await native.getToken();
|
||||
if (!fcmToken) throw new Error('FCM token unavailable');
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId();
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Persist the account id so the background push task can reload credentials
|
||||
// from SecureStore without the main app being running.
|
||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||
// need its username/serverUrl up-front so we can key per-account state.
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (username && serverUrl) {
|
||||
await AsyncStorage.setItem(
|
||||
PUSH_ACCOUNT_ID_KEY,
|
||||
generateAccountId(username, serverUrl),
|
||||
);
|
||||
if (!username || !serverUrl) {
|
||||
throw new Error('No account loaded - cannot set up push');
|
||||
}
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId(accountId);
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Register this account's device-client-id with the relay. Multiple
|
||||
// accounts on the same device end up as separate registrations sharing
|
||||
// one fcmToken - the relay forwards each push individually so the headless
|
||||
// task can identify the source account via the FCM data payload.
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
@@ -208,26 +289,31 @@ export async function setupPushNotifications(
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const storedServerId = await AsyncStorage.getItem(subKey);
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(match);
|
||||
if (refreshed) {
|
||||
await addPushAccountId(accountId);
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
// Server rejected the refresh (likely the subscription was already
|
||||
// deleted server-side) - drop the stale id and recreate below.
|
||||
await destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this device.
|
||||
// These pile up when a previous enable attempt failed mid-flow (verify
|
||||
// race, network blip, app killed). Stalwart per-account rate-limits
|
||||
// PushVerification posts, so leaving stragglers around blocks the new
|
||||
// one's verification - the symptom is a confusing "code does not match".
|
||||
// Reap any leftover Stalwart subscriptions still bound to this account's
|
||||
// deviceClientId. These pile up when a previous enable attempt failed
|
||||
// mid-flow (verify race, network blip, app killed). Stalwart per-account
|
||||
// rate-limits PushVerification posts, so leaving stragglers around blocks
|
||||
// the new one's verification - the symptom is a confusing "code does not
|
||||
// match". Only reap matches for THIS account's deviceClientId; other
|
||||
// accounts' subscriptions on this server (rare but possible if the same
|
||||
// JMAP server backs multiple accounts) must be left alone.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
@@ -245,11 +331,19 @@ export async function setupPushNotifications(
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
|
||||
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
|
||||
await AsyncStorage.setItem(subKey, serverAssignedId);
|
||||
await addPushAccountId(accountId);
|
||||
|
||||
return { subscriptionId: serverAssignedId, verified: true };
|
||||
}
|
||||
|
||||
async function addPushAccountId(accountId: string): Promise<void> {
|
||||
const ids = await readPushAccountIds();
|
||||
if (!ids.includes(accountId)) {
|
||||
await writePushAccountIds([...ids, accountId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Push the subscription's expires forward when it's getting close to the
|
||||
// server's ceiling. Returns false if the server rejects the update, which the
|
||||
// caller treats as "recreate".
|
||||
@@ -273,28 +367,92 @@ async function refreshSubscriptionExpires(
|
||||
}
|
||||
}
|
||||
|
||||
async function deregisterFromRelay(
|
||||
relayBaseUrl: string,
|
||||
deviceClientId: string,
|
||||
): Promise<void> {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
* FCM token. Never throws - callers treat teardown as best effort.
|
||||
* Tear down push for a single account. Destroys that account's JMAP
|
||||
* subscription (assumes the jmapClient is currently authenticated to that
|
||||
* account; the active-account logout flow guarantees this) and tells the
|
||||
* relay to drop its mapping. Other accounts' push setups are untouched.
|
||||
*
|
||||
* If no accounts have push left after removal, also deletes the FCM token
|
||||
* so the device stops receiving FCM messages entirely. Never throws -
|
||||
* callers treat teardown as best effort.
|
||||
*/
|
||||
export async function teardownPushNotificationsForAccount(
|
||||
accountId: string,
|
||||
): Promise<void> {
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
||||
const dcidKey = deviceClientIdKey(accountId);
|
||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
|
||||
if (storedSubId) {
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
||||
|
||||
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
|
||||
await writePushAccountIds(remaining);
|
||||
|
||||
// If this was the last account with push, kill the FCM token so the device
|
||||
// truly goes silent. Otherwise the token stays alive so the remaining
|
||||
// accounts keep receiving pushes.
|
||||
if (remaining.length === 0) {
|
||||
const native = getNative();
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push for ALL accounts on this device. Used by the logout-all
|
||||
* flow; best-effort because we typically aren't authenticated to every
|
||||
* account's JMAP server at the moment we need to call destroy on it. The
|
||||
* FCM token is always deleted so no push gets through regardless.
|
||||
*/
|
||||
export async function teardownPushNotifications(): Promise<void> {
|
||||
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
if (storedId) {
|
||||
await destroyPushSubscription(storedId).catch(() => undefined);
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
const accountIds = await readPushAccountIds();
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const dcidKey = deviceClientIdKey(accountId);
|
||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
||||
|
||||
if (storedSubId) {
|
||||
// Will only succeed if the jmapClient happens to be authenticated to
|
||||
// this account right now. We don't switch the client to attempt each
|
||||
// one; the subscription will expire server-side instead (90-day TTL).
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||
}
|
||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
||||
}
|
||||
if (relayBaseUrl && deviceClientId) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
|
||||
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
|
||||
const native = getNative();
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,7 +17,18 @@ vi.mock('../../api/email', () => ({
|
||||
// trip on react-native's Flow-typed entrypoint under vitest. The store only
|
||||
// reads archiveMode in archiveEmail, so a minimal stub is enough.
|
||||
vi.mock('../settings-store', () => ({
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
|
||||
}));
|
||||
|
||||
// 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.
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'acc-1',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
},
|
||||
}));
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
|
||||
+45
-12
@@ -6,7 +6,10 @@ import { useEmailStore } from './email-store';
|
||||
import { useContactsStore } from './contacts-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { teardownPushNotifications } from '../lib/push-notifications';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../lib/push-notifications';
|
||||
|
||||
// Persist middleware hydrates asynchronously on cold start. Without this
|
||||
// guard, restoreSession() can read the account-store before AsyncStorage has
|
||||
@@ -143,9 +146,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
|
||||
// Best-effort: revoke the JMAP PushSubscription and drop the relay
|
||||
// mapping before we lose credentials. Do not abort logout on failure.
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
// Best-effort: revoke this account's JMAP PushSubscription and drop its
|
||||
// relay mapping before we lose credentials. Other logged-in accounts'
|
||||
// push setups remain untouched. Do not abort logout on failure.
|
||||
if (currentId) {
|
||||
await teardownPushNotificationsForAccount(currentId).catch(() => undefined);
|
||||
} else {
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Clear credentials for this account first
|
||||
if (currentId) {
|
||||
@@ -217,17 +225,42 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
// 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.
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||
accountStore.removeAccount(accountId);
|
||||
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.
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to switch account',
|
||||
});
|
||||
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,
|
||||
|
||||
@@ -89,6 +89,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
fetchCalendars: async () => {
|
||||
// CalendarScreen fires this on mount; on cold start that happens before
|
||||
// restoreSession has connected jmapClient. Bail rather than surfacing
|
||||
// a "Not authenticated" error - the refetch driven by the auth-store
|
||||
// will run this again once the session is live.
|
||||
if (!jmapClient.isConnected) return;
|
||||
try {
|
||||
const calendars = (await fetchCalendars()) ?? [];
|
||||
set({ calendars });
|
||||
@@ -98,6 +103,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
fetchEvents: async (calendarIds, after, before) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
|
||||
@@ -142,6 +148,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
@@ -87,6 +87,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
fetchAddressBooks: async () => {
|
||||
// No-op until a JMAP session exists. ContactsScreen fires this from a
|
||||
// mount-time useEffect, which on cold start runs before restoreSession
|
||||
// has set up the client.
|
||||
if (!jmapClient.isConnected) return;
|
||||
try {
|
||||
const addressBooks = (await fetchAddressBooks()) ?? [];
|
||||
set({ addressBooks });
|
||||
@@ -96,6 +100,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
fetchContacts: async (filter) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const ids = (await queryContacts(filter)) ?? [];
|
||||
@@ -111,6 +116,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
@@ -129,6 +129,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
pendingUndo: 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;
|
||||
try {
|
||||
const mailboxes = await fetchMailboxes();
|
||||
set({ mailboxes });
|
||||
@@ -171,6 +178,15 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
set({ loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = buildJmapFilter(mailboxId, '', {});
|
||||
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
|
||||
@@ -195,6 +211,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
loadMoreEmails: async () => {
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
|
||||
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
|
||||
if (!jmapClient.isConnected) return;
|
||||
|
||||
set({ loading: true });
|
||||
try {
|
||||
@@ -215,6 +232,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user