mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b7be03d8 | ||
|
|
9dd9ce8105 | ||
|
|
dd64931be8 | ||
|
|
ac234f288e | ||
|
|
81749dd89c | ||
|
|
beef086d4c | ||
|
|
cfd67398de | ||
|
|
2b15d2dc50 | ||
|
|
9bf2ecdf27 | ||
|
|
67db8a2ac3 | ||
|
|
6846d859fd | ||
|
|
3bdca0133e | ||
|
|
1858d08dd0 | ||
|
|
427d163dc0 | ||
|
|
7e34ba42ef | ||
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 | ||
|
|
455f26e90a | ||
|
|
e9b93bc6e5 | ||
|
|
c21661ac7a |
@@ -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';
|
||||
@@ -51,8 +52,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||
if (!navigationRef.isReady()) return;
|
||||
|
||||
// The notification carries the account it was generated for. If the user
|
||||
// has since switched to a different account (or had a different one active
|
||||
// when the notification arrived), opening EmailThread under the active
|
||||
// account would fetch the email from the wrong server and fail.
|
||||
const auth = useAuthStore.getState();
|
||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
||||
if (!account) return; // account was logged out — nothing safe to open.
|
||||
await auth.switchAccount(payload.accountId);
|
||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
||||
}
|
||||
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
@@ -75,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 }}>
|
||||
@@ -136,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
|
||||
@@ -143,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
|
||||
@@ -150,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
|
||||
@@ -261,11 +300,11 @@ export default function App() {
|
||||
void (async () => {
|
||||
const initial = await getInitialNotificationTap();
|
||||
if (cancelled || !initial) return;
|
||||
navigateToNotificationTap(initial);
|
||||
await navigateToNotificationTap(initial);
|
||||
})();
|
||||
|
||||
const unsubscribe = addNotificationTapListener((payload) => {
|
||||
navigateToNotificationTap(payload);
|
||||
void navigateToNotificationTap(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -276,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();
|
||||
@@ -315,7 +360,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
@@ -64,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val emailId = options.getString("emailId")
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -72,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject,
|
||||
emailId, threadId, subject, accountId,
|
||||
)
|
||||
promise.resolve(null)
|
||||
}
|
||||
@@ -93,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
emailId: String?,
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
@@ -100,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
|
||||
@@ -23,6 +23,11 @@ object NotificationTapStore {
|
||||
// our notification builder writes — preventing other apps from
|
||||
// navigating us to a forged email/thread.
|
||||
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
|
||||
// accountId is `username@host` (see generateAccountId), so it needs `.`,
|
||||
// `@`, `+` etc. — cap length and restrict charset to what that helper can
|
||||
// emit. accountId is optional: notifications created before this field
|
||||
// existed still navigate (using the active account as a fallback).
|
||||
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
|
||||
|
||||
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||
val extras = intent?.extras ?: return null
|
||||
@@ -34,12 +39,14 @@ object NotificationTapStore {
|
||||
// cap its length so a hostile launcher can't ship a 1MB string into
|
||||
// the navigation payload.
|
||||
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
|
||||
val payload = TapPayload(emailId, threadId, subject)
|
||||
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -47,15 +54,18 @@ object NotificationTapStore {
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
}
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.35",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.35",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -5216,6 +5217,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.35",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
|
||||
@@ -61,6 +61,57 @@ export async function uploadBlob(
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
|
||||
// `new File(uri).bytes()`, which is overkill when we already have the bytes
|
||||
// (e.g. the empty-blob trick used to create directories on Stalwart).
|
||||
export async function uploadBytes(
|
||||
bytes: Uint8Array,
|
||||
type: string,
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
export function getDownloadUrl(
|
||||
blobId: string,
|
||||
name?: string,
|
||||
|
||||
+169
-15
@@ -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([
|
||||
|
||||
+157
-8
@@ -1,19 +1,68 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
const t = node.type;
|
||||
if (!t) return false;
|
||||
return DIRECTORY_TYPES.has(t) || t.includes('directory');
|
||||
}
|
||||
|
||||
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
|
||||
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
|
||||
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
|
||||
// e.g. a file inside the `Documents` folder is stored with
|
||||
// `name = "Documents∕report.pdf"`. The display name shown to the user
|
||||
// strips the prefix.
|
||||
export const PATH_SEP = '∕';
|
||||
|
||||
// Path is a list of folder display names from root. [] is root,
|
||||
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
|
||||
// `sub` folder under Documents.
|
||||
export function pathToPrefix(path: readonly string[]): string {
|
||||
if (path.length === 0) return '';
|
||||
return path.join(PATH_SEP) + PATH_SEP;
|
||||
}
|
||||
|
||||
// True when `node` is a direct child (not a descendant) of `prefix`.
|
||||
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
|
||||
if (!node.name) return false;
|
||||
if (prefix === '') return !node.name.includes(PATH_SEP);
|
||||
if (!node.name.startsWith(prefix)) return false;
|
||||
const rest = node.name.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes(PATH_SEP);
|
||||
}
|
||||
|
||||
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
|
||||
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter and some builds
|
||||
// omit `type` from the default property set. We fetch the whole tree and
|
||||
// filter client-side; properties are listed explicitly so `isDirectory()`
|
||||
// always has a value to look at.
|
||||
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
|
||||
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (parentId !== undefined) filter.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, filter }, '0']],
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
@@ -27,13 +76,113 @@ export async function createFileNode(
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
displayName: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
// Stalwart's FileNode/set rejects directory creates without a blobId
|
||||
// ("Missing blob id"), so we upload an empty blob first and reference it.
|
||||
// Mirrors the webmail's createFileDirectory.
|
||||
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
// Rename a node and, if it's a folder, cascade the rename to every descendant
|
||||
// so the encoded path stays consistent. The webmail does this same dance.
|
||||
export async function renameFileNode(
|
||||
node: FileNode,
|
||||
newDisplayName: string,
|
||||
currentPath: readonly string[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const prefix = pathToPrefix(currentPath);
|
||||
const oldServerName = node.name;
|
||||
const newServerName = prefix + newDisplayName;
|
||||
if (oldServerName === newServerName) return;
|
||||
|
||||
await updateFileNode(node.id, { name: newServerName });
|
||||
|
||||
if (isDirectory(node)) {
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id) continue;
|
||||
if (n.name.startsWith(oldFolderPrefix)) {
|
||||
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
|
||||
// references, so we collect descendants by name-prefix and destroy them all
|
||||
// in one call. Without this, deleting a folder leaves its files orphaned and
|
||||
// still discoverable at the old encoded path.
|
||||
export async function deleteWithDescendants(
|
||||
targets: readonly FileNode[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targets) {
|
||||
ids.add(node.id);
|
||||
if (isDirectory(node)) {
|
||||
const folderPrefix = node.name + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteFileNodes(Array.from(ids));
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
displayName: string,
|
||||
mimeType: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({
|
||||
name: fullName,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
}
|
||||
|
||||
+2
-1
@@ -429,7 +429,7 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
|
||||
CALENDARS: 'urn:ietf:params:jmap:calendars',
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
} as const;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { formatBytes } from '../lib/offline-sync';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -12,9 +14,18 @@ import { useColors } from '../theme/colors';
|
||||
export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
// When UpdateBanner is stacked above us it already absorbs the status-bar
|
||||
// inset, so we only add it when we're the topmost banner.
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
const updateBannerVisible =
|
||||
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
|
||||
const topInset = updateBannerVisible ? 0 : insets.top;
|
||||
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
|
||||
@@ -71,7 +82,7 @@ export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const isError = sync.phase === 'error';
|
||||
|
||||
return (
|
||||
<View style={[styles.banner, isError && styles.bannerError]}>
|
||||
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
|
||||
<CloudDownload size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+132
-48
@@ -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;
|
||||
@@ -41,76 +50,151 @@ interface ShowNotificationOptions {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
+205
-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);
|
||||
@@ -327,6 +485,10 @@ export interface NotificationTapPayload {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
// Identifies which logged-in account the notification was generated for.
|
||||
// Optional for back-compat: older notifications already on the system tray
|
||||
// won't carry this and will fall back to the active account on tap.
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
|
||||
+897
-142
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -17,15 +25,57 @@ 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 }) },
|
||||
}));
|
||||
|
||||
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
|
||||
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
|
||||
// switch). Stub it as an empty cache so tests don't need to set up
|
||||
// AsyncStorage.
|
||||
vi.mock('../offline-cache-store', () => ({
|
||||
useOfflineCacheStore: {
|
||||
getState: () => ({
|
||||
activeAccountId: null,
|
||||
hydrated: true,
|
||||
hydrate: vi.fn(),
|
||||
setAccount: vi.fn(async () => undefined),
|
||||
totalCount: () => 0,
|
||||
getEmailsInMailbox: vi.fn(async () => []),
|
||||
has: () => false,
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// email-store now early-returns from fetch actions unless jmapClient is
|
||||
// connected AND serving the same logical account the store has active
|
||||
// (`generateAccountId(username, serverUrl) === activeAccountId`). The
|
||||
// tests exercise those actions, so present a fully connected stub plus
|
||||
// the matching username/serverUrl pair. beforeEach() syncs the store's
|
||||
// activeAccountId to the id these credentials produce.
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'acc-1',
|
||||
username: 'test@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateAccountId } from '../../lib/account-utils';
|
||||
const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.example.com');
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import { useEmailStore } from '../email-store';
|
||||
|
||||
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>;
|
||||
@@ -35,6 +85,10 @@ const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useEmailStore.getState().reset();
|
||||
// Wire the store's active account to the one the mocked jmapClient is
|
||||
// serving, so the guard inside fetchMailboxes / refreshEmails / etc.
|
||||
// doesn't short-circuit the tests.
|
||||
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
|
||||
});
|
||||
|
||||
describe('email-store', () => {
|
||||
@@ -44,7 +98,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();
|
||||
|
||||
@@ -52,7 +106,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();
|
||||
|
||||
@@ -62,12 +116,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');
|
||||
|
||||
@@ -79,11 +133,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();
|
||||
});
|
||||
});
|
||||
|
||||
+95
-18
@@ -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
|
||||
@@ -43,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();
|
||||
}
|
||||
@@ -105,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);
|
||||
@@ -126,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) {
|
||||
@@ -143,9 +171,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) {
|
||||
@@ -156,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;
|
||||
@@ -190,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);
|
||||
|
||||
@@ -217,14 +250,50 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
// 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);
|
||||
|
||||
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' });
|
||||
// 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;
|
||||
}
|
||||
} catch (err) {
|
||||
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',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -287,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;
|
||||
}
|
||||
@@ -325,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+564
-64
@@ -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,
|
||||
@@ -16,9 +22,27 @@ import {
|
||||
searchEmails as apiSearchEmails,
|
||||
} from '../api/email';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { useSettingsStore } from './settings-store';
|
||||
import { useOfflineCacheStore } from './offline-cache-store';
|
||||
|
||||
// True only when the JMAP client is actually serving the email-store's active
|
||||
// account. During an account switch there's a window between
|
||||
// setActiveAccount() (which swaps the email-store view immediately) and
|
||||
// jmapClient.loadAccount() resolving, when the client is still on the
|
||||
// *previous* account. Without this guard, any fetchMailboxes/refreshEmails
|
||||
// fired in that window (e.g. by an EmailListScreen useEffect reacting to
|
||||
// the empty new-account view) would return the previous account's data and
|
||||
// stamp it into the new account's snapshot.
|
||||
function jmapClientServesActiveAccount(activeAccountId: string | null): boolean {
|
||||
if (!activeAccountId) return false;
|
||||
if (!jmapClient.isConnected) return false;
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (!username || !serverUrl) return false;
|
||||
return generateAccountId(username, serverUrl) === activeAccountId;
|
||||
}
|
||||
|
||||
export interface EmailFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -43,17 +67,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,86 +178,360 @@ 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,
|
||||
});
|
||||
|
||||
// Point the offline body cache at the same account so getEmailDetail's
|
||||
// fallback and selectMailbox's seed read from the right bucket. Fire-
|
||||
// and-forget — the cache returns empty until hydration completes,
|
||||
// which is the correct degraded behaviour.
|
||||
void useOfflineCacheStore.getState().setAccount(accountId);
|
||||
},
|
||||
|
||||
removeAccount: (accountId) => {
|
||||
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,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(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,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(null);
|
||||
},
|
||||
|
||||
fetchMailboxes: async () => {
|
||||
// Skip silently when there's no live session, or when jmapClient is
|
||||
// mid-transition to a different account (see jmapClientServesActiveAccount).
|
||||
// Screens fire this from mount-time useEffects, and on cold start
|
||||
// App.tsx renders MainTabs before restoreSession() finishes; without
|
||||
// this guard the underlying API call would either throw "Not
|
||||
// authenticated - call connect() first" or — worse, during an account
|
||||
// switch — return the *previous* account's mailboxes and stamp them
|
||||
// into the new account's snapshot.
|
||||
const activeAccountId = get().activeAccountId;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
const prevState = get().mailboxState;
|
||||
try {
|
||||
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);
|
||||
// Bail if the user switched accounts during the await — anything we
|
||||
// set() now would land in the wrong account's bucket.
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
if (changes) {
|
||||
// No changes at all — keep the cached list, just bump the state.
|
||||
if (
|
||||
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
|
||||
: [];
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
const destroyed = new Set(changes.destroyed);
|
||||
const byId = new Map<string, Mailbox>();
|
||||
for (const m of get().mailboxes) byId.set(m.id, m);
|
||||
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();
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
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' });
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
// Don't overwrite the cached list on a transient failure — the user
|
||||
// can still navigate folders. Only surface the error when we have no
|
||||
// mailboxes at all to show.
|
||||
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 OR jmapClient is mid-transition
|
||||
// to a different account. The cached seed already gave the user
|
||||
// something to look at, and the refetch driven by restoreSession() /
|
||||
// switchAccount will run the network half once the client catches up.
|
||||
if (!jmapClientServesActiveAccount(get().activeAccountId)) {
|
||||
set({ loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters, activeAccountId } = get();
|
||||
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
set({ loading: true });
|
||||
try {
|
||||
@@ -205,41 +542,186 @@ export const useEmailStore = create<EmailState>()(
|
||||
limit,
|
||||
filter,
|
||||
});
|
||||
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails: [...emails, ...newEmails], loading: false });
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : [];
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const merged = [...emails, ...newEmails];
|
||||
const updates: Partial<EmailState> = { emails: merged, loading: false };
|
||||
if (isBaseView(searchQuery, filters)) {
|
||||
updates.mailboxSnapshots = {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: merged,
|
||||
total: get().totalEmails,
|
||||
queryState: get().queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
set(updates);
|
||||
} catch (err) {
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
|
||||
}
|
||||
},
|
||||
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
|
||||
const state = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
|
||||
if (!currentMailboxId) return;
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
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().activeAccountId !== activeAccountId || 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().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
// Keep whatever's visible; only surface the error when the list is
|
||||
// empty. With cached emails on screen the OfflineBanner already
|
||||
// tells the user the data is stale.
|
||||
if (existing.length === 0) {
|
||||
try {
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const cached = await cacheStore.getEmailsInMailbox(
|
||||
currentMailboxId,
|
||||
Math.max(limit, 50),
|
||||
);
|
||||
if (get().currentMailboxId === currentMailboxId && cached.length > 0) {
|
||||
if (
|
||||
get().activeAccountId === activeAccountId &&
|
||||
get().currentMailboxId === currentMailboxId &&
|
||||
cached.length > 0
|
||||
) {
|
||||
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
@@ -248,12 +730,19 @@ 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'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.currentSession) return;
|
||||
// Drop changes that arrived for a different account than the one we're
|
||||
// currently showing (e.g. push notifications received during/just after
|
||||
// an account switch).
|
||||
if (!jmapClientServesActiveAccount(get().activeAccountId)) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
@@ -509,9 +998,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: '',
|
||||
@@ -519,16 +1012,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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
// AsyncStorage; attachment blobs go to expo-file-system under
|
||||
// `Paths.document/offline-attachments/`. The store also exposes the live
|
||||
// progress state the OfflineCacheBanner reads from.
|
||||
//
|
||||
// Storage is keyed by the email-store's `activeAccountId` so logged-in
|
||||
// accounts don't leak cached bodies into each other. The store loads one
|
||||
// account's bucket at a time (the active one); switching accounts via
|
||||
// setAccount() persists the current bucket then hydrates the new one.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
const INDEX_KEY = 'webmail:offline-cache:index:v1';
|
||||
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v1:';
|
||||
const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:';
|
||||
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
|
||||
|
||||
function entryKey(id: string): string {
|
||||
return `${ENTRY_KEY_PREFIX}${id}`;
|
||||
function indexKey(accountId: string): string {
|
||||
return `${INDEX_KEY_PREFIX}${accountId}`;
|
||||
}
|
||||
|
||||
function entryKey(accountId: string, emailId: string): string {
|
||||
return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`;
|
||||
}
|
||||
|
||||
export interface OfflineCacheIndexEntry {
|
||||
@@ -23,8 +32,6 @@ export interface OfflineCacheIndexEntry {
|
||||
}
|
||||
|
||||
interface CacheIndex {
|
||||
// Per-account is keyed at the storage layer (one app session = one account
|
||||
// active at a time). Cross-account separation is left to a future change.
|
||||
entries: Record<string, OfflineCacheIndexEntry>;
|
||||
}
|
||||
|
||||
@@ -42,8 +49,11 @@ export interface SyncState {
|
||||
}
|
||||
|
||||
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
|
||||
const EMPTY_INDEX: CacheIndex = { entries: {} };
|
||||
|
||||
interface OfflineCacheState {
|
||||
// Currently-loaded bucket. null = no account active (no reads/writes).
|
||||
activeAccountId: string | null;
|
||||
index: CacheIndex;
|
||||
hydrated: boolean;
|
||||
sync: SyncState;
|
||||
@@ -52,6 +62,9 @@ interface OfflineCacheState {
|
||||
// AbortControllers need to survive across the function boundary.
|
||||
abortRequested: boolean;
|
||||
|
||||
// Point the store at the given account, hydrating its bucket from
|
||||
// AsyncStorage. Pass null to detach (no reads/writes will hit storage).
|
||||
setAccount: (accountId: string | null) => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
has: (id: string) => boolean;
|
||||
get: (id: string) => Promise<Email | null>;
|
||||
@@ -74,41 +87,83 @@ interface OfflineCacheState {
|
||||
consumeAbort: () => boolean;
|
||||
}
|
||||
|
||||
function persistIndex(index: CacheIndex): void {
|
||||
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
|
||||
function persistIndex(accountId: string, index: CacheIndex): void {
|
||||
void AsyncStorage.setItem(indexKey(accountId), JSON.stringify(index)).catch((err) => {
|
||||
console.warn('[offline-cache] persist index failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadIndex(accountId: string): Promise<CacheIndex> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(indexKey(accountId));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheIndex;
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] hydrate failed', err);
|
||||
}
|
||||
return { entries: {} };
|
||||
}
|
||||
|
||||
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
activeAccountId: null,
|
||||
index: { entries: {} },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(INDEX_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheIndex;
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
set({ index: parsed, hydrated: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] hydrate failed', err);
|
||||
setAccount: async (accountId) => {
|
||||
const state = get();
|
||||
if (state.activeAccountId === accountId) {
|
||||
// Same account — make sure we've hydrated.
|
||||
if (!state.hydrated) await get().hydrate();
|
||||
return;
|
||||
}
|
||||
set({ hydrated: true });
|
||||
// Drop any in-flight sync state from the previous account; the abort flag
|
||||
// makes runOfflineSync stop on its next chunk boundary.
|
||||
set({
|
||||
activeAccountId: accountId,
|
||||
index: { ...EMPTY_INDEX },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: true,
|
||||
});
|
||||
if (accountId) {
|
||||
const index = await loadIndex(accountId);
|
||||
// Re-check in case another setAccount call raced past us.
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
set({ index, hydrated: true, abortRequested: false });
|
||||
} else {
|
||||
set({ hydrated: true, abortRequested: false });
|
||||
}
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
const state = get();
|
||||
if (state.hydrated) return;
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) {
|
||||
// No active account yet — mark hydrated so callers that block on this
|
||||
// (selectMailbox cache-seed path) don't spin. Reads will return empty.
|
||||
set({ hydrated: true });
|
||||
return;
|
||||
}
|
||||
const index = await loadIndex(accountId);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
set({ index, hydrated: true });
|
||||
},
|
||||
|
||||
has: (id) => Boolean(get().index.entries[id]),
|
||||
|
||||
get: async (id) => {
|
||||
if (!get().index.entries[id]) return null;
|
||||
const state = get();
|
||||
if (!state.activeAccountId) return null;
|
||||
if (!state.index.entries[id]) return null;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(id));
|
||||
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as Email;
|
||||
} catch (err) {
|
||||
@@ -118,12 +173,19 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
},
|
||||
|
||||
put: async (email, approxBytes) => {
|
||||
const state = get();
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
|
||||
await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email));
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] put failed', email.id, err);
|
||||
return;
|
||||
}
|
||||
// Re-read the active account in case it changed while we were awaiting
|
||||
// AsyncStorage — don't stamp the new account's index with the old
|
||||
// account's entry.
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const nextEntries = {
|
||||
...get().index.entries,
|
||||
[email.id]: {
|
||||
@@ -135,35 +197,44 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
};
|
||||
const index = { entries: nextEntries };
|
||||
set({ index });
|
||||
persistIndex(index);
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
remove: async (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
|
||||
);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const next = { ...get().index.entries };
|
||||
for (const id of ids) delete next[id];
|
||||
const index = { entries: next };
|
||||
set({ index });
|
||||
persistIndex(index);
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
clearAll: async () => {
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
const ids = Object.keys(get().index.entries);
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
|
||||
);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const index = { entries: {} };
|
||||
set({ index, sync: { ...IDLE } });
|
||||
persistIndex(index);
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
getEmailsInMailbox: async (mailboxId, limit = 200) => {
|
||||
const state = get();
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) return [];
|
||||
// Sort the index by receivedAt descending first so we don't have to read
|
||||
// entries we won't return — the index already carries receivedAt.
|
||||
const sorted = Object.values(get().index.entries).sort((a, b) => {
|
||||
const sorted = Object.values(state.index.entries).sort((a, b) => {
|
||||
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
|
||||
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
|
||||
return bt - at;
|
||||
@@ -172,7 +243,7 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
for (const entry of sorted) {
|
||||
if (matches.length >= limit) break;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(entry.id));
|
||||
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id));
|
||||
if (!raw) continue;
|
||||
const email = JSON.parse(raw) as Email;
|
||||
if (email.mailboxIds?.[mailboxId]) {
|
||||
|
||||
Reference in New Issue
Block a user