Compare commits

..
42 Commits
Author SHA1 Message Date
Linus Rath d16f04e818 chore: update version number to 0.1.39 2026-05-29 15:34:50 +02:00
Linus Rath d616943c25 feat: add unified inbox, scheduled send, iCal import/subscriptions, tasks, and contact tagging/import 2026-05-29 15:34:39 +02:00
Linus Rath cf4e3955bf chore: update version number to 0.1.38 2026-05-19 00:45:52 +02:00
Linus Rath bf20c0af58 feat: implement OAuth token handling and webmail-mediated login flow 2026-05-19 00:45:42 +02:00
Linus Rath 67f7f83621 chore: update version number to 0.1.37 2026-05-19 00:08:27 +02:00
Linus Rath 086d398058 feat: implement webmail-mediated login flow and remove OAuth dependencies 2026-05-19 00:05:51 +02:00
Linus Rath 373a1a60d8 chore: update version number to 0.1.36 2026-05-18 23:32:59 +02:00
Linus Rath f71f219187 feat: implement OAuth login flow and enhance authentication handling 2026-05-18 23:32:50 +02:00
Linus Rath 77b7be03d8 chore: update version number to 0.1.35 2026-05-18 21:52:49 +02:00
Linus Rath 9dd9ce8105 feat: enhance email store and offline cache to support account switching and data isolation 2026-05-18 21:52:35 +02:00
Linus Rath dd64931be8 chore: update version number to 0.1.34 2026-05-18 20:11:20 +02:00
Linus Rath ac234f288e Refactor email store to support incremental sync 2026-05-18 20:05:03 +02:00
Linus Rath 81749dd89c chore: update version number to 0.1.33 2026-05-15 17:26:10 +02:00
Linus Rath beef086d4c chore: update version number to 0.1.32 2026-05-15 17:26:00 +02:00
Linus Rath cfd67398de feat: add capability hooks for calendar, contacts, and files 2026-05-15 17:25:55 +02:00
Linus Rath 2b15d2dc50 chore: update version number to 0.1.31 2026-05-15 02:10:55 +02:00
Linus Rath 9bf2ecdf27 fix: resolve "Not authenticated" race on cold start and account switch 2026-05-15 00:31:57 +02:00
Linus Rath 67db8a2ac3 chore: update version number to 0.1.30 2026-05-14 13:51:00 +02:00
Linus Rath 6846d859fd feat: implement multi-account support for push notifications and enhance teardown logic 2026-05-14 13:50:52 +02:00
Linus Rath 3bdca0133e chore: update version number to 0.1.29 2026-05-14 12:57:18 +02:00
Linus Rath 1858d08dd0 feat: enhance notification handling with accountId support and improve file management functions 2026-05-14 12:57:03 +02:00
Linus Rath 427d163dc0 chore: update version number to 0.1.28 2026-05-08 20:04:32 +02:00
Linus Rath 7e34ba42ef feat: implement in-memory upload with uploadBytes function and integrate into createFolder 2026-05-08 20:04:25 +02:00
Linus Rath 886a64171d chore: update version number to 0.1.27 2026-05-08 13:18:24 +02:00
Linus Rath 760bf01f4d fix: defer expo-document-picker require to avoid native-module crash 2026-05-08 13:18:17 +02:00
Linus Rath b6d87093b9 chore: update version number to 0.1.26 2026-05-08 10:24:43 +02:00
Linus Rath 2a3b1f90dc feat: add upload, preview, download, selection, folder creation 2026-05-08 10:24:36 +02:00
Linus Rath 2ed9c29bd5 fix: clear status bar inset on OfflineCacheBanner 2026-05-08 01:36:49 +02:00
Linus Rath 455f26e90a chore: update version number to 0.1.25 2026-05-08 01:03:20 +02:00
Linus Rath e9b93bc6e5 fix: import ByteArrayOutputStream in BulwarkFcmModule 2026-05-08 01:03:11 +02:00
Linus Rath c21661ac7a feat: finish Files tab with folder nav, grid view, and refresh 2026-05-08 00:57:51 +02:00
Linus Rath 00be851611 chore: update version number to 0.1.24 2026-05-08 00:51:23 +02:00
Linus Rath 71ff8930a2 fix: apply unread/starred filters in mailbox queries 2026-05-08 00:50:27 +02:00
Linus Rath 29b06a3a4f security: fix push SSE breakage, harden APK updates and email rendering 2026-05-05 22:06:07 +02:00
Linus Rath 2911b8e7ac chore: update version number to 0.1.23 2026-05-03 23:23:15 +02:00
Linus Rath 3d4779a689 feat: enhance offline email handling with caching and mailbox seeding 2026-05-03 19:05:30 +02:00
Linus Rath 641fde0505 chore: update version number to 0.1.22 2026-05-03 18:40:08 +02:00
Linus Rath 6d5195d9fa feat: implement offline mail caching and sync functionality 2026-05-03 18:40:01 +02:00
Linus Rath 40f9da244f chore: update version number to 0.1.21 2026-05-03 17:38:59 +02:00
Linus Rath c7ea4d757c feat: implement client certificate support with secure fetch and alias management 2026-05-03 17:33:54 +02:00
Linus Rath 1d89cb38cf chore: update version number to 0.1.20 2026-05-03 17:15:31 +02:00
Linus Rath abbbff2afa feat: implement network recovery for authenticated users and enhance error handling in auth store 2026-05-03 17:15:17 +02:00
88 changed files with 12210 additions and 729 deletions
+91 -8
View File
@@ -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';
@@ -31,16 +31,22 @@ import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
import { useAccountStore } from './src/stores/account-store';
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';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
@@ -48,8 +54,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,
@@ -72,10 +91,15 @@ 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 }}>
<UpdateBanner />
<OfflineCacheBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -132,6 +156,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
@@ -139,6 +170,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
@@ -146,6 +184,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
@@ -193,6 +238,18 @@ export default function App() {
return useNetworkStore.getState().init();
}, []);
// When the network flips back on while we're authenticated-but-offline
// (no live JMAP session), retry the session so the user lands back on
// live data without needing to relaunch.
React.useEffect(() => {
if (!isAuthenticated) return;
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && !useAuthStore.getState().session) {
void useAuthStore.getState().retrySession();
}
});
}, [isAuthenticated]);
React.useEffect(() => {
let cancelled = false;
void (async () => {
@@ -206,6 +263,24 @@ export default function App() {
return () => { cancelled = true; };
}, []);
// Offline mail cache: hydrate the cache index on launch, and kick off a
// background sync once we have a live JMAP session and the user has the
// feature enabled. Re-runs whenever the user changes the days window.
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
}, []);
React.useEffect(() => {
if (!offlineCacheEnabled || !haveLiveSession) return;
// Slight delay so cold start doesn't compete with the inbox load.
const t = setTimeout(() => {
void runOfflineSync({ days: offlineCacheDays });
}, 2000);
return () => clearTimeout(t);
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
@@ -227,11 +302,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 () => {
@@ -242,18 +317,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();
@@ -281,7 +362,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated, emailNotificationsEnabled]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
@@ -370,6 +451,8 @@ export default function App() {
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen
name="AddAccount"
options={{
+1 -1
View File
@@ -1 +1 @@
0.1.19
0.1.39
+2 -4
View File
@@ -2,11 +2,9 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -0,0 +1,286 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import android.security.KeyChainAliasCallback
import android.util.Base64
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
import kotlin.concurrent.thread
/**
* Bridges Android's KeyChain client-certificate machinery into JS so the
* mobile app can authenticate to a reverse proxy that asks for mTLS.
*
* Two surfaces:
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
* pick a cert installed in Android's system credential store. The
* selected alias is persisted in SharedPreferences so the choice
* survives app restarts.
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
* injects the picked cert into the TLS handshake. JS-side `fetch`
* can't present a client cert because RN's bundled OkHttp client
* doesn't expose its KeyManager hook to userland; this method is
* the workaround.
*/
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
private val prefs: SharedPreferences =
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
@Volatile private var cachedFactory: SSLSocketFactory? = null
@Volatile private var cachedFactoryForAlias: String? = null
override fun getName(): String = MODULE_NAME
// ── alias management ────────────────────────────────────────
@ReactMethod
fun getAlias(promise: Promise) {
promise.resolve(cachedAlias)
}
@ReactMethod
fun clearAlias(promise: Promise) {
prefs.edit().remove(PREF_ALIAS).apply()
cachedAlias = null
invalidateFactory()
promise.resolve(null)
}
/**
* Show the system "Choose a certificate" picker. Resolves with the
* selected alias, or `null` when the user dismisses the picker.
*/
@ReactMethod
fun pickAlias(host: String?, promise: Promise) {
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
// reference). The explicit Java-style getter on the application
// context is the supported call site.
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
if (activity == null) {
promise.reject("no_activity", "No current activity to host the cert picker")
return
}
val callback = KeyChainAliasCallback { alias ->
// Callback runs off the main thread; persist + resolve directly.
if (alias.isNullOrBlank()) {
promise.resolve(null)
return@KeyChainAliasCallback
}
prefs.edit().putString(PREF_ALIAS, alias).apply()
cachedAlias = alias
invalidateFactory()
promise.resolve(alias)
}
try {
KeyChain.choosePrivateKeyAlias(
activity,
callback,
/* keyTypes */ arrayOf("RSA", "EC"),
/* issuers */ null,
/* host */ host,
/* port */ -1,
/* alias */ cachedAlias,
)
} catch (e: Exception) {
promise.reject("pick_failed", e.message, e)
}
}
// ── secure fetch ────────────────────────────────────────────
/**
* Perform an HTTP request that presents the persisted client cert (if
* any) during the TLS handshake.
*
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
*
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
* reconstruct a standard `Response`-like object.
*/
@ReactMethod
fun fetchSecure(request: ReadableMap, promise: Promise) {
val url = request.getString("url")
if (url.isNullOrBlank()) {
promise.reject("bad_args", "url is required")
return
}
val method = (request.getString("method") ?: "GET").uppercase()
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
val headersMap = request.getMap("headers")
val bodyBase64 = request.getString("bodyBase64")
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
thread(name = "BulwarkClientCert.fetch") {
try {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = method
instanceFollowRedirects = true
if (bodyBytes != null) {
doOutput = true
setFixedLengthStreamingMode(bodyBytes.size)
}
}
if (conn is HttpsURLConnection && cachedAlias != null) {
conn.sslSocketFactory = buildOrReuseSocketFactory()
}
headersMap?.let { h ->
val it = h.keySetIterator()
while (it.hasNextKey()) {
val k = it.nextKey()
val v = h.getString(k)
if (v != null) conn.setRequestProperty(k, v)
}
}
conn.connect()
if (bodyBytes != null) {
conn.outputStream.use { out -> out.write(bodyBytes) }
}
val status = conn.responseCode
val statusText = conn.responseMessage ?: ""
val responseStream = try {
conn.inputStream
} catch (_: Exception) {
conn.errorStream
}
val responseBytes = if (responseStream != null) {
val buf = ByteArrayOutputStream()
responseStream.copyTo(buf)
responseStream.close()
buf.toByteArray()
} else {
ByteArray(0)
}
val out = Arguments.createMap()
out.putInt("status", status)
out.putString("statusText", statusText)
val headers = Arguments.createMap()
// headerFields is a `Map<String?, List<String>>`; the null
// key is the HTTP status line, which we don't need to copy.
for ((name, values) in conn.headerFields) {
if (name == null || values == null) continue
headers.putString(name, values.joinToString(", "))
}
out.putMap("headers", headers)
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
conn.disconnect()
promise.resolve(out)
} catch (e: Exception) {
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
}
}
}
// ── internals ───────────────────────────────────────────────
private fun invalidateFactory() {
cachedFactory = null
cachedFactoryForAlias = null
}
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
val existing = cachedFactory
if (existing != null && cachedFactoryForAlias == alias) return existing
val ctx = reactApplicationContext
val km = KeyChainKeyManager(ctx, alias)
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as java.security.KeyStore?)
val trustManagers = tmf.trustManagers
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
?: throw IllegalStateException("No X509TrustManager available")
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
val factory = sslContext.socketFactory
cachedFactory = factory
cachedFactoryForAlias = alias
return factory
}
/**
* X509ExtendedKeyManager that always returns the same KeyChain alias.
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
* them lazily during the TLS handshake, which is fine since the
* fetch already runs off-main-thread.
*/
private class KeyChainKeyManager(
private val context: Context,
private val alias: String,
) : X509ExtendedKeyManager() {
override fun chooseClientAlias(
keyTypes: Array<out String>?,
issuers: Array<out Principal>?,
socket: Socket?,
): String = alias
override fun chooseEngineClientAlias(
keyType: Array<out String>?,
issuers: Array<out Principal>?,
engine: SSLEngine?,
): String = alias
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
return try {
KeyChain.getCertificateChain(context, this.alias)
} catch (_: Exception) {
null
}
}
override fun getPrivateKey(alias: String?): PrivateKey? {
return try {
KeyChain.getPrivateKey(context, this.alias)
} catch (_: Exception) {
null
}
}
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
arrayOf(alias)
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
emptyArray()
override fun chooseServerAlias(
keyType: String?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = null
}
companion object {
const val MODULE_NAME = "BulwarkClientCert"
private const val PREFS_NAME = "bulwark_client_cert"
private const val PREF_ALIAS = "alias"
}
}
@@ -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,
@@ -148,7 +152,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
conn.readTimeout = 3000
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", "bulwark-mobile")
conn.inputStream.use { BitmapFactory.decodeStream(it) }
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
// server can't OOM the headless task with a multi-megabyte image.
// We also sample down to ~256x256 since it'll only render at 192px.
val stream = conn.inputStream
val bytes = stream.use { input ->
val limit = MAX_FAVICON_BYTES
val buf = ByteArrayOutputStream()
val chunk = ByteArray(8 * 1024)
var total = 0
while (true) {
val n = input.read(chunk)
if (n <= 0) break
total += n
if (total > limit) return@use null
buf.write(chunk, 0, n)
}
buf.toByteArray()
} ?: return null
val opts = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
var sample = 1
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
sample *= 2
}
val decodeOpts = BitmapFactory.Options().apply {
inSampleSize = sample
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
} catch (_: Exception) {
null
}
@@ -167,6 +200,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
fun removeListeners(count: Int) {}
companion object {
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
@Volatile private var currentInstance: BulwarkFcmModule? = null
fun emit(eventName: String, params: WritableMap?) {
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(BulwarkFcmModule(reactContext))
listOf(
BulwarkFcmModule(reactContext),
BulwarkClientCertModule(reactContext),
)
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
@@ -17,17 +17,36 @@ object NotificationTapStore {
return value
}
// The activity is exported (required by the LAUNCHER intent filter), so
// any third-party app on the device can craft a starting intent that
// includes these extras. Reject anything that doesn't look like the IDs
// 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
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
val subject = extras.getString(EXTRA_SUBJECT)
val payload = TapPayload(emailId, threadId, subject)
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
// Subject is human-readable text and may legitimately contain anything;
// 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 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
}
@@ -35,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"
}
+1
View File
@@ -20,6 +20,7 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
+23 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.19",
"version": "0.1.39",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.19",
"version": "0.1.39",
"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",
@@ -24,6 +25,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -5216,6 +5218,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",
@@ -5408,6 +5419,16 @@
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.19",
"version": "0.1.39",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -10,6 +10,7 @@
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
},
@@ -22,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",
@@ -29,6 +31,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
+8 -7
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should query events within date range', async () => {
it('should send a CalendarEvent/query without server-side filter', async () => {
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
// filter client-side. Asserting on the absence of `filter` keeps that
// contract pinned.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
@@ -50,12 +54,9 @@ describe('calendar operations', () => {
expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
inCalendars: ['cal-1'],
after: '2026-03-01T00:00:00Z',
before: '2026-03-31T23:59:59Z',
types: ['Event'],
});
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toBeUndefined();
expect(call[1].accountId).toBe('acc-1');
});
});
+60 -2
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -15,6 +16,9 @@ import {
createContact,
updateContact,
deleteContacts,
createAddressBook,
updateAddressBook,
deleteAddressBook,
} from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -50,14 +54,14 @@ describe('contacts operations', () => {
expect(result).toEqual(['c1', 'c2']);
});
it('should use default empty filter', async () => {
it('should omit filter when none provided', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
});
await queryContacts();
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({});
expect(call[1].filter).toBeUndefined();
});
});
@@ -116,4 +120,58 @@ describe('contacts operations', () => {
expect(call[1].destroy).toEqual(['c1', 'c2']);
});
});
describe('createAddressBook', () => {
it('creates a book and returns the created entity', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']],
});
const book = await createAddressBook('Work');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } });
expect(book).toEqual({ id: 'ab-9', name: 'Work' });
});
it('throws when the server refuses to create', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']],
});
await expect(createAddressBook('X')).rejects.toThrow('nope');
});
});
describe('updateAddressBook', () => {
it('forwards only settable properties', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
});
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
});
});
describe('deleteAddressBook', () => {
it('destroys the book by id', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
});
await deleteAddressBook('ab-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['ab-1']);
});
it('throws when destroy fails', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']],
});
await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use');
});
});
});
+20 -2
View File
@@ -90,6 +90,24 @@ describe('email operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
});
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
});
const userFilter = {
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
};
await queryEmails('mb-1', { filter: userFilter });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, userFilter],
});
});
});
describe('getEmails', () => {
@@ -197,7 +215,7 @@ describe('email operations', () => {
expect(result).toEqual(['e1']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'test query' });
expect(call[1].filter).toEqual({ text: 'test* query*' });
});
it('should include mailbox filter if specified', async () => {
@@ -208,7 +226,7 @@ describe('email operations', () => {
await searchEmails('query', 'mb-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' });
});
});
+53 -1
View File
@@ -1,5 +1,6 @@
import { File } from 'expo-file-system';
import { jmapClient } from './jmap-client';
import { secureFetch } from '../lib/client-cert';
export async function uploadBlob(
uri: string,
@@ -20,7 +21,7 @@ export async function uploadBlob(
// empty/garbage upload.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
@@ -60,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,
+139 -9
View File
@@ -8,8 +8,11 @@ const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime',
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
'participants', 'alerts', 'recurrenceRules',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
'recurrenceOverrides', 'excludedRecurrenceRules',
'replyTo', 'organizerCalendarAddress', 'sequence',
'locations', 'virtualLocations',
'progress', 'due', 'priority', 'percentComplete',
'links', 'created', 'updated',
];
@@ -69,38 +72,165 @@ export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
return all;
}
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
// / reply / cancellation emails to participants. We pass it whenever an event
// has participants so creating/updating/deleting a meeting notifies attendees.
function setArgs(
accountId: string,
payload: Record<string, unknown>,
sendSchedulingMessages?: boolean,
): Record<string, unknown> {
const args: Record<string, unknown> = { accountId, ...payload };
if (sendSchedulingMessages !== undefined) {
args.sendSchedulingMessages = sendSchedulingMessages;
}
return args;
}
export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
sendSchedulingMessages?: boolean,
): Promise<CalendarEvent> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', {
accountId,
[['CalendarEvent/set', setArgs(accountId, {
create: {
'new-event': { ...event, calendarIds: { [calendarId]: true } },
},
}, '0']],
}, sendSchedulingMessages), '0']],
USING,
);
return res.methodResponses[0][1].created['new-event'];
const result = methodResult<{
created?: Record<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-event'];
if (!created) {
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
}
return created;
}
// Batch-create many events in one CalendarEvent/set. Returns the number created.
export async function batchCreateEvents(
events: Partial<CalendarEvent>[],
calendarId: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
});
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, create }, '0']],
USING,
);
const result = methodResult<{ created?: Record<string, unknown> }>(res);
return result.created ? Object.keys(result.created).length : 0;
}
export async function updateEvent(
id: string,
changes: Partial<CalendarEvent>,
changes: Partial<CalendarEvent> | Record<string, unknown>,
sendSchedulingMessages?: boolean,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update event');
}
export async function deleteEvents(
ids: string[],
sendSchedulingMessages?: boolean,
): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
USING,
);
}
export async function deleteEvents(ids: string[]): Promise<void> {
// RSVP to an invitation: patch the participant's participationStatus via a JSON
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
export async function rsvpEvent(
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
): Promise<void> {
const accountId = jmapClient.accountId;
// Escape per RFC 6901: ~ → ~0, / → ~1.
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patch: Record<string, unknown> = {
[`participants/${escaped}/participationStatus`]: status,
};
if (replyTo) patch.replyTo = replyTo;
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[eventId];
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
}
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
USING,
);
const result = methodResult<{
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
notParsable?: string[];
notFound?: string[];
}>(res);
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
const parsed = result.parsed?.[blobId];
if (!parsed) return [];
return Array.isArray(parsed) ? parsed : [parsed];
}
export async function createCalendar(
name: string,
color?: string,
): Promise<Calendar> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', {
accountId,
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
}, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, Calendar>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-cal'];
if (!created) {
const err = result.notCreated?.['new-cal'];
throw new Error(err?.description || err?.type || 'Failed to create calendar');
}
return created;
}
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
USING,
);
}
+55
View File
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
USING,
);
}
export async function createAddressBook(name: string): Promise<AddressBook> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, AddressBook>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-book'];
if (created) return { ...created, name };
const err = result.notCreated?.['new-book'];
throw new Error(err?.description || err?.type || 'Failed to create address book');
}
export async function updateAddressBook(
id: string,
updates: Partial<AddressBook>,
): Promise<void> {
const accountId = jmapClient.accountId;
// Only forward server-settable properties.
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
}
export async function deleteAddressBook(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
}
/** Fetch all contact cards that live in a specific address book. */
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId });
return getContacts(ids);
}
+400 -7
View File
@@ -22,6 +22,55 @@ export async function getMailboxes(): Promise<Mailbox[]> {
return res.methodResponses[0][1].list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export interface MailboxChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function createMailbox(
data: { name: string; parentId?: string | null },
): Promise<string> {
@@ -83,6 +132,22 @@ export async function deleteMailbox(id: string): Promise<void> {
}
}
function buildMailboxQueryFilter(
mailboxId: string,
userFilter: Record<string, unknown> | undefined,
): Record<string, unknown> {
const inMailbox = { inMailbox: mailboxId };
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
if ('operator' in userFilter) {
return { operator: 'AND', conditions: [inMailbox, userFilter] };
}
return { ...inMailbox, ...userFilter };
}
export async function queryEmails(
mailboxId: string,
options?: {
@@ -91,21 +156,69 @@ 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 filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const res = await jmapClient.request([
['Email/query', {
accountId,
filter: { inMailbox: mailboxId, ...options?.filter },
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
position: options?.position ?? 0,
limit: options?.limit ?? 50,
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 }>) ?? [],
};
}
@@ -117,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([
@@ -135,6 +301,26 @@ export async function getFullEmail(id: string): Promise<Email> {
return email;
}
// Batch variant for offline sync. JMAP servers cap how many objects can be
// returned in a single Email/get; the caller should chunk to that ceiling
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
export async function getFullEmails(ids: string[]): Promise<Email[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
ids,
properties: EMAIL_FULL_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 512000,
}, '0'],
]);
return res.methodResponses[0][1].list as Email[];
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
@@ -172,6 +358,50 @@ export async function moveEmail(
]);
}
// Move several emails from one mailbox to another in a single Email/set.
export async function moveEmails(
ids: string[],
fromMailboxId: string,
toMailboxId: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
[`mailboxIds/${fromMailboxId}`]: null,
[`mailboxIds/${toMailboxId}`]: true,
};
}
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
}
// Batch delete: destroy outright when already in trash, otherwise move to trash.
export async function deleteEmails(
ids: string[],
trashMailboxId: string,
currentMailboxId: string,
): Promise<void> {
if (ids.length === 0) return;
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
} else {
await moveEmails(ids, currentMailboxId, trashMailboxId);
}
}
// Apply keyword maps to several emails in one round-trip.
export async function setKeywordsForEmails(
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
): Promise<void> {
if (updates.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {};
for (const u of updates) update[u.id] = { keywords: u.keywords };
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
@@ -372,6 +602,15 @@ export interface OutgoingAttachment {
cid?: string;
}
export interface SendEmailResult {
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
scheduled: boolean;
/** ISO timestamp the server resolved for a deferred send, when known. */
sendAt?: string;
emailId?: string;
emailSubmissionId?: string;
}
export async function sendEmail(
email: {
from: EmailAddress[];
@@ -387,7 +626,11 @@ export async function sendEmail(
},
identityId: string,
sentMailboxId: string,
): Promise<void> {
// When > 0 the message is held for this many seconds before delivery via the
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
// scheduling and the global send-delay (undo-send) window.
holdForSeconds?: number,
): Promise<SendEmailResult> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
from: email.from,
@@ -429,14 +672,164 @@ export async function sendEmail(
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
}
await jmapClient.request(
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter).
if (holdForSeconds && holdForSeconds > 0) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim())
.filter(Boolean)
.map((address) => ({ email: address }));
submissionCreate.envelope = {
mailFrom: {
email: email.from[0]?.email,
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
},
rcptTo,
};
}
const res = await jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', {
accountId,
create: { 'sub-1': { emailId: '#draft', identityId } },
create: { 'sub-1': submissionCreate },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let sendAt: string | undefined;
for (const [methodName, result] of res.methodResponses) {
if (methodName.endsWith('/error')) {
throw new Error((result as { description?: string }).description ?? 'Send failed');
}
if (methodName === 'Email/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.draft;
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message');
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
}
if (methodName === 'EmailSubmission/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
emailSubmissionId = created?.id;
sendAt = created?.sendAt;
}
}
return {
scheduled: !!(holdForSeconds && holdForSeconds > 0),
sendAt,
emailId,
emailSubmissionId,
};
}
export interface ScheduledEmail {
emailSubmissionId: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt: string;
undoStatus?: string;
subject?: string;
to?: EmailAddress[];
from?: EmailAddress[];
preview?: string;
}
// List pending (not-yet-delivered, not cancelled) scheduled submissions whose
// send time is still in the future, joined with a light Email/get so the UI
// can show subject/recipients. Empty when the server lacks FUTURERELEASE.
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
if (!jmapClient.hasDelayedSend()) return [];
const accountId = jmapClient.accountId;
const now = Date.now();
const queryRes = await jmapClient.request(
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const [queryName, queryBody] = queryRes.methodResponses[0];
if (queryName.endsWith('/error')) return [];
const ids = (queryBody.ids as string[]) ?? [];
if (ids.length === 0) return [];
const subRes = await jmapClient.request(
[['EmailSubmission/get', {
accountId,
ids,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'],
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const submissions = ((subRes.methodResponses[0][1].list as Array<{
id: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt?: string;
undoStatus?: string;
}>) ?? []).filter((s) => {
if (s.undoStatus !== 'pending' || !s.sendAt) return false;
const t = new Date(s.sendAt).getTime();
return Number.isFinite(t) && t > now;
});
if (submissions.length === 0) return [];
const emailIds = Array.from(new Set(submissions.map((s) => s.emailId)));
const emailRes = await jmapClient.request([
['Email/get', {
accountId,
ids: emailIds,
properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'],
}, '0'],
]);
const emailById = new Map(
((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]),
);
return submissions
.map((s): ScheduledEmail | null => {
const e = emailById.get(s.emailId);
if (!s.sendAt) return null;
return {
emailSubmissionId: s.id,
emailId: s.emailId,
identityId: s.identityId,
threadId: s.threadId ?? e?.threadId,
sendAt: s.sendAt,
undoStatus: s.undoStatus,
subject: e?.subject,
to: e?.to,
from: e?.from,
preview: e?.preview,
};
})
.filter((s): s is ScheduledEmail => s !== null)
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
}
// Cancel a pending scheduled send. The held message copy stays in Sent; only
// delivery is stopped (matches the webmail behaviour).
export async function cancelScheduledSend(emailSubmissionId: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['EmailSubmission/set', {
accountId,
update: { [emailSubmissionId]: { undoStatus: 'canceled' } },
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const failure = res.methodResponses[0][1].notUpdated?.[emailSubmissionId] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
}
}
+157 -8
View File
@@ -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,
});
}
+241 -23
View File
@@ -7,6 +7,12 @@ import type {
} from './types';
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
// Refresh OAuth access tokens this many ms before they actually expire so
// in-flight requests don't race the expiry window.
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
@@ -21,6 +27,13 @@ export interface StoredCredentials {
username: string;
password: string;
accessToken?: string;
// OAuth-only — present when credentials came in via the webmail handoff
// OAuth path rather than password auth. The token endpoint and client id
// are needed for refresh; without them the access token would just expire.
refreshToken?: string;
expiresAt?: number;
tokenEndpoint?: string;
clientId?: string;
}
export class JMAPClient {
@@ -92,6 +105,113 @@ export class JMAPClient {
return this.session;
}
// OAuth login via webmail handoff. The webmail did the OAuth dance against
// Stalwart and handed us a complete token bundle. We persist the bundle
// so subsequent launches can refresh without prompting the user again.
async connectWithOAuth(
serverUrl: string,
tokens: OAuthTokens,
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = {
serverUrl: baseUrl,
username: '',
password: '',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session);
// The JMAP session document carries the authenticated user's identifier
// — use it as the username so per-account storage keys are stable across
// restarts and OAuth re-logins.
const username = this.session.username || tokens.accessToken.slice(0, 8);
this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
return { session: this.session, username, accountId };
}
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
if (!this.credentials) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
// existing one when the response omits it.
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
const accountId = generateAccountId(
this.credentials.username,
this.credentials.serverUrl,
);
await SecureStore.setItemAsync(
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
}
private currentOAuthTokens(): OAuthTokens | null {
if (
!this.credentials?.accessToken ||
!this.credentials.refreshToken ||
!this.credentials.tokenEndpoint ||
!this.credentials.clientId
) {
return null;
}
return {
accessToken: this.credentials.accessToken,
refreshToken: this.credentials.refreshToken,
expiresAt: this.credentials.expiresAt,
tokenEndpoint: this.credentials.tokenEndpoint,
clientId: this.credentials.clientId,
};
}
// Proactive refresh: when the access token is about to expire, swap it for
// a fresh one. Quiet no-op for password credentials.
private async ensureFreshToken(): Promise<void> {
const tokens = this.currentOAuthTokens();
if (!tokens) return;
if (tokens.expiresAt == null) return;
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
} catch {
// Surface as AuthenticationError on the next 401; refresh may be
// temporarily failing (network) and the reactive retry path catches it.
}
}
// Reactive refresh after a 401. Returns true if a fresh token was obtained
// so the caller can retry the original request.
private async forceRefreshToken(): Promise<boolean> {
const tokens = this.currentOAuthTokens();
if (!tokens) return false;
try {
const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next);
return true;
} catch {
return false;
}
}
// Legacy single-slot restore - kept for backward-compat tests. New code
// should use loadAccount(accountId) driven by the account registry.
async restoreSession(): Promise<boolean> {
@@ -117,18 +237,38 @@ export class JMAPClient {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false;
let creds: StoredCredentials;
try {
creds = JSON.parse(stored);
} catch {
// Corrupt entry — credentials can't be used. Caller should evict.
return false;
}
// Errors past this point propagate so callers can distinguish
// unrecoverable (AuthenticationError) from transient (NetworkError) and
// avoid clearing credentials when the server is just unreachable.
this.credentials = creds;
try {
const creds: StoredCredentials = JSON.parse(stored);
this.credentials = creds;
this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl),
creds.serverUrl,
);
this._accountId = this.resolveAccountId(this.session);
return true;
} catch {
return false;
} catch (err) {
// Don't leave a half-populated client behind; the caller needs to know
// the session is unavailable. Credentials stay in memory so a retry
// after the network comes back doesn't need a re-login.
this.session = null;
this._accountId = null;
if (err instanceof AuthenticationError) throw err;
// Anything else is treated as transport-level. Wrapping rather than
// rethrowing the raw fetch error gives callers a single check.
throw new NetworkError(
err instanceof Error ? err.message : 'Server unreachable',
);
}
this._accountId = this.resolveAccountId(this.session);
return true;
}
async clearAccountCredentials(accountId: string): Promise<void> {
@@ -214,12 +354,18 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await fetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Invalid credentials');
@@ -259,14 +405,21 @@ export class JMAPClient {
methodCalls,
};
const response = await fetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(this.session!.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Session expired');
@@ -298,6 +451,59 @@ export class JMAPClient {
return core?.maxObjectsInGet || 500;
}
// ── Scheduled send (FUTURERELEASE) ────────────────────
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
// lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail
// implementation so behaviour stays in sync across platforms.
private get submissionCapability():
| { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined {
return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as
| { maxDelayedSend?: number; submissionExtensions?: unknown }
| undefined;
}
getMaxDelayedSend(): number {
const max = this.submissionCapability?.maxDelayedSend;
return typeof max === 'number' ? max : 0;
}
hasDelayedSend(): boolean {
const cap = this.submissionCapability;
if (!cap) return false;
const ext = cap.submissionExtensions;
// submissionExtensions is a map of extension name → params. FUTURERELEASE
// (RFC 4865) is the SMTP extension that backs deferred delivery.
const hasFutureRelease =
!!ext &&
typeof ext === 'object' &&
Object.keys(ext as Record<string, unknown>).some(
(k) => k.toUpperCase() === 'FUTURERELEASE',
);
return hasFutureRelease && this.getMaxDelayedSend() > 0;
}
// ── Stored credentials (per-account) ──────────────────
// Exposed so the unified-inbox aggregator can read another account's
// credentials without disturbing this client's live session, and persist a
// refreshed OAuth token back to secure storage.
async getStoredCredentials(accountId: string): Promise<StoredCredentials | null> {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return null;
try {
return JSON.parse(stored) as StoredCredentials;
} catch {
return null;
}
}
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
}
// ── Blob Download ─────────────────────────────────────
// Expand the RFC 6570 level-1 template the JMAP server advertises.
@@ -314,9 +520,12 @@ export class JMAPClient {
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await fetch(url, {
headers: { Authorization: this.authHeader },
});
await this.ensureFreshToken();
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) throw new AuthenticationError('Session expired');
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
return response.arrayBuffer();
@@ -332,6 +541,15 @@ export class AuthenticationError extends Error {
}
}
// Server unreachable / transient transport failure. Callers should keep
// stored credentials and surface an offline state rather than logging out.
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
+27 -5
View File
@@ -201,13 +201,35 @@ export function startPolling(
return () => clearInterval(timer);
}
export interface StartPushOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
}
/**
* Start real-time updates, preferring SSE with polling fallback.
* Start real-time updates, preferring SSE with polling fallback. The `_client`
* argument is unused today (the underlying event source uses the singleton
* `jmapClient`) but is part of the call signature so the host App can pass
* the active client reference and trigger React re-runs on account switch.
*/
export function startPushUpdates(
onStateChange: StateChangeHandler,
_client: unknown,
opts: StartPushOptions,
): () => void {
const cleanup = connectEventSource(onStateChange);
if (cleanup) return cleanup;
return startPolling(onStateChange);
const onError = opts.onError;
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
}
};
try {
const cleanup = connectEventSource(safeOnStateChange);
if (cleanup) return cleanup;
return startPolling(safeOnStateChange);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
return () => undefined;
}
}
+74 -4
View File
@@ -9,6 +9,10 @@ export interface JMAPSession {
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
state: string;
// RFC 8620 §2 — authenticated user identifier. Optional because not all
// servers populate it; OAuth login uses it as the account label when
// there's no password username to fall back on.
username?: string;
}
export interface JMAPAccountInfo {
@@ -284,6 +288,13 @@ export interface ContactCryptoKey {
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry' | string;
mediaType?: string;
pref?: number;
}
export interface ContactPronouns {
pronouns: string;
pref?: number;
@@ -318,12 +329,15 @@ export interface ContactCard {
relatedTo?: Record<string, ContactRelation>;
links?: Record<string, ContactLink>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
speakToAs?: ContactSpeakToAs;
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
@@ -352,13 +366,39 @@ export interface AddressBookWithCount extends AddressBook {
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
export interface Participant {
'@type'?: 'Participant';
name?: string;
email?: string;
sendTo?: Record<string, string>;
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
calendarAddress?: string;
kind?: string;
roles?: Record<string, boolean>;
participationStatus?: string;
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
participationComment?: string;
scheduleStatus?: string[];
expectReply?: boolean;
description?: string;
}
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
export interface EventLocation {
'@type'?: 'Location';
name?: string;
description?: string;
locationTypes?: Record<string, boolean>;
relativeTo?: string;
timeZone?: string;
coordinates?: string;
}
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
export interface VirtualLocation {
'@type'?: 'VirtualLocation';
name?: string;
description?: string;
uri: string;
features?: Record<string, boolean>;
}
export interface RecurrenceRule {
@@ -399,27 +439,56 @@ export interface CalendarEvent {
status?: string;
freeBusyStatus?: string;
participants?: Record<string, Participant>;
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
replyTo?: Record<string, string>;
organizerCalendarAddress?: string;
sequence?: number;
recurrenceRules?: RecurrenceRule[];
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
excludedRecurrenceRules?: RecurrenceRule[];
recurrenceId?: string;
originalId?: string;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>;
locations?: Record<string, EventLocation>;
virtualLocations?: Record<string, VirtualLocation>;
links?: Record<string, { href: string; rel?: string }>;
created?: string;
updated?: string;
progress?: string;
// Task-only (RFC 8984 JSTask) fields.
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
due?: string;
priority?: number;
percentComplete?: number;
color?: string;
// Client-only: account the event/occurrence belongs to (for shared/virtual).
localAccountId?: string;
}
export interface CalendarRights {
mayReadFreeBusy?: boolean;
mayReadItems?: boolean;
mayWriteAll?: boolean;
mayWriteOwn?: boolean;
mayUpdatePrivate?: boolean;
mayRSVP?: boolean;
mayShare?: boolean;
mayDelete?: boolean;
// Legacy short flag still read by the sidebar drawer.
mayWrite?: boolean;
}
export interface Calendar {
id: string;
name: string;
description?: string | null;
color?: string;
isVisible?: boolean;
isSubscribed?: boolean;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean>;
myRights?: CalendarRights;
}
// ─── Files (FileNode) ───────────────────────────────────
@@ -429,7 +498,7 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
@@ -465,4 +534,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;
+196
View File
@@ -0,0 +1,196 @@
import { CAPABILITIES } from './types';
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
import { jmapClient, type StoredCredentials } from './jmap-client';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
// Aggregated inbox across every logged-in account. Because the JMAP client is
// a single-account singleton (one live session at a time), we fetch each
// account's inbox "detached" here — reading its stored credentials directly and
// issuing requests against its own API URL — so the active account's live
// session is never disturbed.
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment',
];
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
export interface UnifiedEmail extends Email {
/** Registry account id this message belongs to (not the JMAP account id). */
sourceAccountId: string;
}
export interface UnifiedInboxResult {
emails: UnifiedEmail[];
/** accountId → error message for accounts that could not be fetched. */
errors: Record<string, string>;
}
function authHeaderFor(creds: StoredCredentials): string {
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
}
function originOf(url: string): string | null {
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
return m ? m[1] : null;
}
// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at
// the origin we actually connected to (servers often self-report unreachable
// container-internal hosts).
function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
const serverOrigin = originOf(serverUrl);
const apiOrigin = originOf(session.apiUrl);
if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl;
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
}
function resolveJmapAccountId(session: JMAPSession): string | null {
return (
session.primaryAccounts?.[CAPABILITIES.MAIL] ||
session.primaryAccounts?.[CAPABILITIES.CORE] ||
Object.keys(session.accounts ?? {})[0] ||
null
);
}
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
async function ensureFreshCredentials(
accountId: string,
creds: StoredCredentials,
): Promise<StoredCredentials> {
if (
!creds.accessToken ||
!creds.refreshToken ||
!creds.tokenEndpoint ||
!creds.clientId ||
creds.expiresAt == null
) {
return creds;
}
if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds;
try {
const tokens: OAuthTokens = {
accessToken: creds.accessToken,
refreshToken: creds.refreshToken,
expiresAt: creds.expiresAt,
tokenEndpoint: creds.tokenEndpoint,
clientId: creds.clientId,
};
const next = await refreshOAuthAccessToken(tokens);
const updated: StoredCredentials = {
...creds,
accessToken: next.accessToken,
refreshToken: next.refreshToken ?? creds.refreshToken,
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
};
await jmapClient.setStoredCredentials(accountId, updated);
return updated;
} catch {
// Fall back to the existing (possibly expired) token; the request will
// surface a 401 which we report as an error for this account.
return creds;
}
}
async function jmapPost(
apiUrl: string,
authHeader: string,
methodCalls: JMAPMethodCall[],
using: string[],
): Promise<Array<[string, Record<string, any>, string]>> {
const response = await secureFetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ using, methodCalls }),
});
if (response.status === 401) throw new Error('Session expired');
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
const body = await response.json();
return body.methodResponses ?? [];
}
async function fetchInboxForAccount(
accountId: string,
limit: number,
): Promise<UnifiedEmail[]> {
let creds = await jmapClient.getStoredCredentials(accountId);
if (!creds) throw new Error('No stored credentials');
creds = await ensureFreshCredentials(accountId, creds);
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
const authHeader = authHeaderFor(creds);
// Session discovery.
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Authorization: authHeader, Accept: 'application/json' },
});
if (sessionRes.status === 401) throw new Error('Session expired');
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteApiUrl(session, baseUrl);
const jmapAccountId = resolveJmapAccountId(session);
if (!jmapAccountId) throw new Error('No mail account in session');
// Find the inbox mailbox.
const mailboxResponses = await jmapPost(
apiUrl,
authHeader,
[['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? [];
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
if (!inbox) return [];
// Query + fetch the most recent messages.
const queryResponses = await jmapPost(
apiUrl,
authHeader,
[['Email/query', {
accountId: jmapAccountId,
filter: { inMailbox: inbox.id },
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? [];
if (ids.length === 0) return [];
const getResponses = await jmapPost(
apiUrl,
authHeader,
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const list = (getResponses[0]?.[1]?.list as Email[]) ?? [];
return list.map((e) => ({ ...e, sourceAccountId: accountId }));
}
export async function fetchUnifiedInbox(
accountIds: string[],
perAccountLimit = 25,
): Promise<UnifiedInboxResult> {
const errors: Record<string, string> = {};
const settled = await Promise.all(
accountIds.map(async (id) => {
try {
return await fetchInboxForAccount(id, perAccountLimit);
} catch (err) {
errors[id] = err instanceof Error ? err.message : 'Failed to load';
return [] as UnifiedEmail[];
}
}),
);
const emails = settled
.flat()
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
return { emails, errors };
}
+48
View File
@@ -13,6 +13,45 @@ export interface LatestRelease {
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
// asset or from the release body. Null when the release didn't publish one;
// the installer falls back to relying solely on Android's package signing
// check in that case (still strong, but less defense-in-depth).
apkSha256: string | null;
}
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
function extractSha256(body: string, apkName: string | undefined): string | null {
if (!body) return null;
// Prefer a line that explicitly references the APK so we don't pick up the
// hash of an unrelated asset.
const lines = body.split(/\r?\n/);
if (apkName) {
for (const line of lines) {
if (line.includes(apkName)) {
const m = SHA256_RE.exec(line);
if (m) return m[1].toLowerCase();
}
}
}
// Fallback: a single SHA256 anywhere in the body — common when the release
// notes just list one checksum.
const m = SHA256_RE.exec(body);
return m ? m[1].toLowerCase() : null;
}
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
if (!asset) return null;
try {
const res = await fetch(asset.browser_download_url);
if (!res.ok) return null;
const text = await res.text();
const m = SHA256_RE.exec(text);
return m ? m[1].toLowerCase() : null;
} catch {
return null;
}
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
const sha256Asset = apkAsset
? json.assets.find(
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
)
: undefined;
const apkSha256 =
(await fetchCompanionSha256(sha256Asset)) ??
extractSha256(json.body || '', apkAsset?.name);
return {
tag: json.tag_name,
name: json.name || json.tag_name,
@@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
apkSha256,
};
}
+17 -1
View File
@@ -140,7 +140,18 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const contacts = useContactsStore((s) => s.contacts);
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
const resolvedTheme = useResolvedTheme();
// Passively load the dedicated "Trusted Senders" address book once so its
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (!trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersLoaded, loadTrustedSendersBook]);
// Index the address book once per render so the trusted-sender check below
// is O(1) per email. Only used when the matching setting is on.
const addressBookEmails = React.useMemo(() => {
@@ -170,6 +181,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
const trusted = senderEmail
? isSenderTrusted(senderEmail)
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
: false;
@@ -270,7 +282,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect, and persist to the
// dedicated "Trusted Senders" address book so the trust syncs across
// devices (matches the webmail behavior).
addTrustedSender(senderEmail);
void addToTrustedSendersBook(senderEmail).catch(() => {});
setAllowOnce(true);
}
};
@@ -297,7 +313,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
<View style={[styles.webContainer, { height }]}>
<WebView
ref={webviewRef}
originWhitelist={['about:*']}
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
+143
View File
@@ -0,0 +1,143 @@
// Live banner that mirrors the UpdateBanner's footprint, surfaced while the
// offline mail sync is running. Hidden once the sync settles to idle/done.
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';
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
// permanent fixture; the Settings screen still shows the cache stats.
React.useEffect(() => {
if (sync.phase === 'done' || sync.phase === 'cancelled') {
const t = setTimeout(() => resetSync(), 4000);
setHideTimer(t);
return () => clearTimeout(t);
}
if (hideTimer) clearTimeout(hideTimer);
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sync.phase, resetSync]);
if (sync.phase === 'idle') return null;
const pct =
sync.total > 0
? Math.min(100, Math.round((sync.completed / sync.total) * 100))
: sync.phase === 'done' ? 100 : 0;
let title = 'Offline sync';
let subtitle = '';
switch (sync.phase) {
case 'scanning':
title = 'Syncing offline mail';
subtitle = 'Scanning recent messages…';
break;
case 'fetching':
title = 'Syncing offline mail';
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
break;
case 'done':
title = 'Offline mail ready';
subtitle = sync.fetched > 0
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}`
: 'Already up to date';
break;
case 'cancelled':
title = 'Sync cancelled';
subtitle = `${sync.completed}/${sync.total} processed`;
break;
case 'error':
title = 'Offline sync failed';
subtitle = sync.message ?? 'Unable to download';
break;
default:
return null;
}
const showCancel = sync.phase === 'scanning' || sync.phase === 'fetching';
const showDismiss = sync.phase === 'done' || sync.phase === 'cancelled' || sync.phase === 'error';
const isError = sync.phase === 'error';
return (
<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>
{!!subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
{(sync.phase === 'fetching' || sync.phase === 'scanning') && (
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${pct}%` }]} />
</View>
)}
</View>
{showCancel && (
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
)}
{showDismiss && (
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}>
<X size={14} color={c.primaryForeground} />
</Pressable>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.primary,
},
bannerError: { backgroundColor: c.error },
title: { ...typography.bodyMedium, color: c.primaryForeground },
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85, marginTop: 2 },
progressTrack: {
marginTop: 6,
height: 3,
borderRadius: radius.full,
backgroundColor: 'rgba(255,255,255,0.25)',
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: c.primaryForeground,
},
cancelButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
cancelText: { ...typography.captionMedium, color: c.primaryForeground },
dismiss: { padding: 4 },
});
}
+19 -7
View File
@@ -263,20 +263,30 @@ function buildEditorHtml(opts: {
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
var trimmed = (url || '').trim();
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, url); } catch (e) {}
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === url) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : url;
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = url.replace(/"/g, '&quot;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
@@ -409,7 +419,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
<View style={[styles.container, { height }]}>
<WebView
ref={webViewRef}
originWhitelist={['about:*']}
originWhitelist={['about:blank']}
source={{ html }}
onMessage={onMessage}
javaScriptEnabled
@@ -433,9 +443,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
style={styles.webview}
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
// Allow only the initial about:blank doc + data URIs we emit ourselves.
// Allow only the initial about:blank doc + data URIs we emit
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
// scheme so a paste-injected anchor can't navigate the editor.
const url = request.url;
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
return !url || url === 'about:blank' || url.startsWith('data:');
}}
/>
</View>
+34 -2
View File
@@ -8,6 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -427,6 +428,24 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>All inboxes</Text>
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>Scheduled</Text>
</Pressable>
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
{foldersExpanded ? (
@@ -487,7 +506,9 @@ function makeStyles(c: ThemePalette) {
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
// dark-mode value, which left light mode unreadable (dark bg + dark text).
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
@@ -537,7 +558,7 @@ function makeStyles(c: ThemePalette) {
marginHorizontal: spacing.sm,
marginTop: spacing.xs,
marginBottom: spacing.sm,
backgroundColor: '#1f1f1f',
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
@@ -665,6 +686,17 @@ function makeStyles(c: ThemePalette) {
},
sectionSettings: { padding: 4 },
quickRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
paddingHorizontal: spacing.md,
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
quickRowLabel: { ...typography.body, color: c.text },
empty: {
...typography.body,
color: c.textMuted,
+142
View File
@@ -0,0 +1,142 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Check, Tag } from 'lucide-react-native';
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
import type { Email } from '../api/types';
interface TagSheetProps {
visible: boolean;
onClose: () => void;
keywords: KeywordDef[];
/** The currently-selected emails, used to show which tags are already set. */
selectedEmails: Email[];
/** Toggle a keyword token on/off across the whole selection. */
onToggle: (token: string, on: boolean) => void;
}
export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
// A tag counts as "applied" only when every selected email carries it; in
// that case tapping removes it, otherwise tapping adds it to all.
const allHaveToken = React.useCallback(
(token: string) =>
selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.[token]),
[selectedEmails],
);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text>
) : (
keywords.map((kw) => {
const token = keywordToken(kw.id);
const applied = allHaveToken(token);
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
return (
<Pressable
key={kw.id}
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={dot} fill={dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
{applied && <Check size={16} color={c.primary} />}
</Pressable>
);
})
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
empty: { ...typography.body, color: c.textMuted, padding: spacing.lg, textAlign: 'center' },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
});
}
@@ -11,7 +11,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X } from 'lucide-react-native';
import { Check, X, Upload, Rss } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -24,6 +24,8 @@ interface CalendarSidebarDrawerProps {
hiddenCalendarIds: string[];
onToggle: (id: string) => void;
onClose: () => void;
onImport?: () => void;
onManageSubscriptions?: () => void;
}
export function CalendarSidebarDrawer({
@@ -32,6 +34,8 @@ export function CalendarSidebarDrawer({
hiddenCalendarIds,
onToggle,
onClose,
onImport,
onManageSubscriptions,
}: CalendarSidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -122,6 +126,29 @@ export function CalendarSidebarDrawer({
onToggle={onToggle}
/>
)}
{(onImport || onManageSubscriptions) && (
<View style={styles.actionsSection}>
{onManageSubscriptions && (
<Pressable
onPress={onManageSubscriptions}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Rss size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Subscriptions</Text>
</Pressable>
)}
{onImport && (
<Pressable
onPress={onImport}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Upload size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Import from file</Text>
</Pressable>
)}
</View>
)}
</ScrollView>
</SafeAreaView>
</Animated.View>
@@ -191,7 +218,7 @@ function makeStyles(c: ThemePalette) {
left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
@@ -251,5 +278,21 @@ function makeStyles(c: ThemePalette) {
},
rowName: { flex: 1, ...typography.body, color: c.text },
rowNameMuted: { color: c.textMuted },
actionsSection: {
marginTop: spacing.lg,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: c.border,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
minHeight: 44,
},
actionText: { ...typography.body, color: c.text },
});
}
+145 -19
View File
@@ -3,6 +3,7 @@ import {
Animated,
Dimensions,
Easing,
Linking,
Modal,
Pressable,
ScrollView,
@@ -15,13 +16,16 @@ import {
AlignLeft,
Bell,
Calendar as CalendarIcon,
Check,
Clock,
Copy,
HelpCircle,
MapPin,
Pencil,
Repeat,
Trash2,
Users,
Video,
X,
} from 'lucide-react-native';
import { format } from 'date-fns';
@@ -33,8 +37,16 @@ import {
getEventColor,
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
findParticipantByEmail,
isOrganizerParticipant,
} from '../../lib/calendar-invitation';
import { useAccountStore } from '../../stores/account-store';
import { useSheetDrag } from '../../lib/use-sheet-drag';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
@@ -42,6 +54,7 @@ interface EventDetailSheetProps {
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
onDuplicate?: (event: CalendarEvent) => void;
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
}
function formatRange(event: CalendarEvent): string {
@@ -84,22 +97,6 @@ function recurrenceLabel(event: CalendarEvent): string | null {
return label;
}
function alertLabel(event: CalendarEvent): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first) return null;
const offset = first.trigger?.offset;
if (!offset) return null;
if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event';
const min = offset.match(/-?PT(\d+)M$/);
if (min) return `${min[1]} minutes before`;
const hr = offset.match(/-?PT(\d+)H$/);
if (hr) return `${hr[1]} hours before`;
const day = offset.match(/-?P(\d+)D/);
if (day) return `${day[1]} days before`;
return null;
}
export function EventDetailSheet({
event,
calendars,
@@ -107,8 +104,11 @@ export function EventDetailSheet({
onEdit,
onDelete,
onDuplicate,
onRsvp,
}: EventDetailSheetProps) {
const c = useColors();
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
@@ -158,8 +158,26 @@ export function EventDetailSheet({
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event);
const recurrence = recurrenceLabel(event);
const alert = alertLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
// Can the signed-in user RSVP? Only when they appear as a non-organizer
// participant and the caller wired an onRsvp handler.
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
const myStatus = me?.participant.participationStatus;
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !me || rsvpBusy) return;
setRsvpBusy(true);
try {
await onRsvp(event, me.id, status);
} finally {
setRsvpBusy(false);
}
};
return (
<Modal
@@ -198,6 +216,22 @@ export function EventDetailSheet({
showsVerticalScrollIndicator={false}
>
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
{location ? (
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
) : null}
{videoUri ? (
<View style={styles.detailRow}>
<View style={styles.detailIcon}>
<Video size={16} color={c.textMuted} />
</View>
<Pressable
style={styles.joinBtn}
onPress={() => { void Linking.openURL(videoUri); }}
>
<Text style={styles.joinBtnText}>Join video call</Text>
</Pressable>
</View>
) : null}
{event.description ? (
<DetailRow
icon={<AlignLeft size={16} color={c.textMuted} />}
@@ -211,10 +245,10 @@ export function EventDetailSheet({
text={recurrence}
/>
)}
{alert && (
{reminders.length > 0 && (
<DetailRow
icon={<Bell size={16} color={c.textMuted} />}
text={alert}
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
/>
)}
{calendar && (
@@ -224,6 +258,38 @@ export function EventDetailSheet({
/>
)}
{canRsvp && (
<View style={styles.rsvpBlock}>
<Text style={styles.rsvpPrompt}>Going?</Text>
<View style={styles.rsvpButtons}>
<RsvpButton
label="Yes"
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
active={myStatus === 'accepted'}
activeColor={c.success}
disabled={rsvpBusy}
onPress={() => doRsvp('accepted')}
/>
<RsvpButton
label="Maybe"
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
active={myStatus === 'tentative'}
activeColor={c.warning}
disabled={rsvpBusy}
onPress={() => doRsvp('tentative')}
/>
<RsvpButton
label="No"
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
active={myStatus === 'declined'}
activeColor={c.error}
disabled={rsvpBusy}
onPress={() => doRsvp('declined')}
/>
</View>
</View>
)}
{participants.length > 0 && (
<View style={styles.participantsBlock}>
<View style={styles.participantsHeader}>
@@ -334,6 +400,39 @@ function ActionButton({
);
}
function RsvpButton({
label,
icon,
active,
activeColor,
disabled,
onPress,
}: {
label: string;
icon: React.ReactNode;
active: boolean;
activeColor: string;
disabled?: boolean;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
function statusColor(c: ThemePalette, status?: string): string {
switch (status) {
case 'accepted':
@@ -417,6 +516,33 @@ function makeStyles(c: ThemePalette) {
detailIcon: { width: 16, paddingTop: 2 },
detailText: { flex: 1, ...typography.body, color: c.text },
joinBtn: {
flex: 1,
alignSelf: 'flex-start',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.primaryBg,
},
joinBtnText: { ...typography.bodyMedium, color: c.primary },
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
rsvpBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
rsvpBtnText: { ...typography.caption, color: c.text },
participantsBlock: {
marginTop: spacing.sm,
gap: spacing.xs,
+147 -70
View File
@@ -17,13 +17,22 @@ import {
Calendar as CalendarIcon,
Clock,
MapPin,
Video,
Repeat,
Bell,
Plus,
Users,
Trash2,
AlignLeft,
} from 'lucide-react-native';
import { addHours, addMinutes, format } from 'date-fns';
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
import type {
Calendar,
CalendarEvent,
Participant,
EventLocation,
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
@@ -33,11 +42,17 @@ import {
getEventStartDate,
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import {
alertsToReminders,
remindersToAlerts,
formatReminder,
REMINDER_PRESETS,
type Reminder,
} from '../../lib/calendar-alerts';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'none', label: 'Does not repeat' },
@@ -47,16 +62,6 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'yearly', label: 'Yearly' },
];
const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [
{ value: 'none', label: 'No reminder' },
{ value: 'at_time', label: 'At time of event' },
{ value: '5', label: '5 minutes before' },
{ value: '15', label: '15 minutes before' },
{ value: '30', label: '30 minutes before' },
{ value: '60', label: '1 hour before' },
{ value: '1440', label: '1 day before' },
];
interface EventModalProps {
visible: boolean;
event?: CalendarEvent | null;
@@ -101,39 +106,30 @@ function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOp
return 'none';
}
function detectAlert(event: CalendarEvent | null | undefined): AlertOption {
const alerts = event?.alerts;
if (!alerts) return 'none';
const first = Object.values(alerts)[0];
if (!first) return 'none';
const offset = first.trigger?.offset;
if (!offset) return 'none';
if (offset === 'PT0S' || offset === '-PT0S') return 'at_time';
const min = offset.match(/-?PT(\d+)M$/);
if (min) {
const v = min[1];
if (v === '5' || v === '15' || v === '30') return v as AlertOption;
}
const hr = offset.match(/-?PT(\d+)H$/);
if (hr && hr[1] === '1') return '60';
const day = offset.match(/-?P(\d+)D/);
if (day && day[1] === '1') return '1440';
return 'none';
function detectLocation(event: CalendarEvent | null | undefined): string {
const locations = event?.locations;
if (!locations) return '';
const first = Object.values(locations)[0];
return first?.name || '';
}
function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined {
if (opt === 'none') return undefined;
let offset = '-PT0M';
if (opt === 'at_time') offset = 'PT0S';
else if (opt === '60') offset = '-PT1H';
else if (opt === '1440') offset = '-P1D';
else offset = `-PT${opt}M`;
return {
'a-1': {
trigger: { '@type': 'OffsetTrigger', offset },
action: 'display',
},
};
function detectVideoUrl(event: CalendarEvent | null | undefined): string {
const v = event?.virtualLocations;
if (!v) return '';
const first = Object.values(v)[0];
return first?.uri || '';
}
function buildLocations(name: string): Record<string, EventLocation> | undefined {
const trimmed = name.trim();
if (!trimmed) return undefined;
return { 'loc-1': { '@type': 'Location', name: trimmed } };
}
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | undefined {
const trimmed = uri.trim();
if (!trimmed) return undefined;
return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } };
}
function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined {
@@ -163,9 +159,11 @@ export function EventModal({
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
const [alert, setAlert] = React.useState<AlertOption>('none');
const [reminders, setReminders] = React.useState<Reminder[]>([]);
const [location, setLocation] = React.useState('');
const [videoUrl, setVideoUrl] = React.useState('');
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
const [alertOpen, setAlertOpen] = React.useState(false);
const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false);
const [calendarOpen, setCalendarOpen] = React.useState(false);
const [showStartDate, setShowStartDate] = React.useState(false);
const [showStartTime, setShowStartTime] = React.useState(false);
@@ -184,7 +182,9 @@ export function EventModal({
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {});
setRecurrence(detectRecurrence(event));
setAlert(detectAlert(event));
setReminders(alertsToReminders(event.alerts));
setLocation(detectLocation(event));
setVideoUrl(detectVideoUrl(event));
} else {
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
setTitle('');
@@ -195,7 +195,9 @@ export function EventModal({
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
setParticipants({});
setRecurrence('none');
setAlert('none');
setReminders([]);
setLocation('');
setVideoUrl('');
}
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
@@ -240,7 +242,10 @@ export function EventModal({
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
participants: Object.keys(participants).length > 0 ? participants : undefined,
recurrenceRules: recurrenceFromOption(recurrence),
alerts: alertFromOption(alert),
alerts: remindersToAlerts(reminders),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: buildLocations(location),
virtualLocations: buildVirtualLocations(videoUrl),
};
await onSave(data, calendarId);
onClose();
@@ -255,7 +260,21 @@ export function EventModal({
const selectedCalendar = calendars.find((c) => c.id === calendarId);
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.label || 'None';
const addReminder = (minutesBefore: number) => {
setReminderPickerOpen(false);
setReminders((prev) =>
prev.some((r) => r.minutesBefore === minutesBefore)
? prev
: [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore),
);
};
const removeReminder = (minutesBefore: number) => {
setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore));
};
const availablePresets = REMINDER_PRESETS.filter(
(p) => !reminders.some((r) => r.minutesBefore === p),
);
return (
<Modal
@@ -423,42 +442,80 @@ export function EventModal({
)}
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
<Pressable
style={styles.fieldButton}
onPress={() => setAlertOpen((v) => !v)}
>
<Text style={styles.fieldText}>{alertLabel}</Text>
</Pressable>
{alertOpen && (
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
{reminders.length > 0 && (
<View style={{ gap: spacing.xs }}>
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
<Pressable
onPress={() => removeReminder(r.minutesBefore)}
hitSlop={8}
style={styles.reminderRemove}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
))}
</View>
)}
{availablePresets.length > 0 && (
<Pressable
style={styles.addRow}
onPress={() => setReminderPickerOpen((v) => !v)}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addRowText}>
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
</Text>
</Pressable>
)}
{reminderPickerOpen && (
<View style={styles.popover}>
{ALERT_OPTIONS.map((opt) => (
{availablePresets.map((preset) => (
<Pressable
key={opt.value}
style={[
styles.popoverRow,
opt.value === alert && styles.popoverRowActive,
]}
onPress={() => {
setAlert(opt.value);
setAlertOpen(false);
}}
key={preset}
style={styles.popoverRow}
onPress={() => addReminder(preset)}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
</Pressable>
))}
</View>
)}
</Section>
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
<TextInput
value={location}
onChangeText={setLocation}
placeholder="Place or address"
placeholderTextColor={c.textMuted}
style={styles.fieldButton}
/>
</Section>
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
<TextInput
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.fieldButton}
/>
</Section>
<Section
icon={<MapPin size={16} color={c.textMuted} />}
icon={<AlignLeft size={16} color={c.textMuted} />}
label="Description"
>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Notes, location, link…"
placeholder="Notes, agenda…"
placeholderTextColor={c.textMuted}
multiline
style={styles.descriptionInput}
@@ -629,6 +686,26 @@ function makeStyles(c: ThemePalette) {
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
reminderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
reminderRemove: { padding: 2 },
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
},
addRowText: { ...typography.body, color: c.primary },
popover: {
borderWidth: 1,
borderColor: c.border,
+391
View File
@@ -0,0 +1,391 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { uploadBlob } from '../../api/blob';
import { parseCalendarBlob } from '../../api/calendar';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
type DocumentPickerModule = typeof import('expo-document-picker');
let documentPickerModule: DocumentPickerModule | null = null;
function loadDocumentPicker(): DocumentPickerModule | null {
if (documentPickerModule) return documentPickerModule;
try {
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
return documentPickerModule;
} catch {
return null;
}
}
interface Props {
visible: boolean;
onClose: () => void;
calendars: Calendar[];
/** Imports the selected events into the chosen calendar; returns count. */
onImport: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
onImported?: (count: number) => void;
}
function eventDateLabel(event: Partial<CalendarEvent>): string | null {
if (!event.start) return null;
const d = parseISO(event.start);
if (isNaN(d.getTime())) return null;
return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm');
}
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const writable = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
[calendars],
);
const [parsed, setParsed] = React.useState<Partial<CalendarEvent>[]>([]);
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [calendarId, setCalendarId] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
setParsed([]);
setSelected(new Set());
setError(null);
setLoading(false);
setImporting(false);
setResult(null);
}, []);
React.useEffect(() => {
if (visible) {
resetState();
if (writable[0]) setCalendarId(writable[0].id);
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, slideY, overlayOpacity, resetState]);
const pickFile = async () => {
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
'Import unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
);
return;
}
setError(null);
let res;
try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker');
return;
}
if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).');
return;
}
setLoading(true);
try {
// Upload the .ics so the server can parse it (CalendarEvent/parse).
const { blobId } = await uploadBlob(asset.uri, 'text/calendar');
const events = await parseCalendarBlob(blobId);
const usable = events.filter((e) => !!e.start || e['@type'] === 'Task');
if (usable.length === 0) {
setError('No events were found in that file.');
return;
}
setParsed(usable);
setSelected(new Set(usable.map((_, i) => i)));
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
} finally {
setLoading(false);
}
};
const toggle = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
const selectNone = () => setSelected(new Set());
const doImport = async () => {
if (!calendarId) {
Alert.alert('No calendar', 'Create a calendar before importing events.');
return;
}
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setImporting(true);
try {
const count = await onImport(toImport, calendarId);
setResult(count);
onImported?.(count);
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.');
} finally {
setImporting(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import calendar</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
{result !== null ? (
<View style={styles.center}>
<View style={styles.successBadge}>
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result} event{result === 1 ? '' : 's'}.
</Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} disabled={loading}>
{loading ? (
<ActivityIndicator color={c.primary} />
) : (
<Upload size={32} color={c.textMuted} />
)}
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text>
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
) : (
<>
{writable.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
{writable.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable>
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable>
</View>
</View>
<ScrollView style={{ flex: 1 }}>
{parsed.map((event, idx) => {
const isSelected = selected.has(idx);
const date = eventDateLabel(event);
return (
<Pressable
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
</View>
<Clock size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text>
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
</View>
</Pressable>
);
})}
</ScrollView>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
</Button>
</View>
</>
)}
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '85%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
dropZone: {
width: '100%',
borderWidth: 2,
borderColor: c.border,
borderStyle: 'dashed',
borderRadius: radius.lg,
paddingVertical: spacing.xxxl,
alignItems: 'center',
gap: spacing.sm,
},
dropTitle: { ...typography.bodyMedium, color: c.text },
dropHint: { ...typography.caption, color: c.textMuted },
successBadge: {
width: 56, height: 56, borderRadius: radius.full,
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
},
successText: { ...typography.bodyMedium, color: c.text },
calChips: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.sm },
calChip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
maxWidth: 160,
},
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
calSwatch: { width: 10, height: 10, borderRadius: 5 },
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
toolbarCount: { ...typography.caption, color: c.textMuted },
link: { ...typography.captionMedium, color: c.primary },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 48,
},
rowPressed: { backgroundColor: c.surfaceHover },
checkbox: {
width: 20, height: 20, borderRadius: radius.xs,
borderWidth: 1, borderColor: c.border,
alignItems: 'center', justifyContent: 'center',
},
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
rowName: { ...typography.body, color: c.text },
rowDate: { ...typography.caption, color: c.textMuted, marginTop: 1 },
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
@@ -0,0 +1,262 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
import { formatDistanceToNow } from 'date-fns';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
interface Props {
visible: boolean;
onClose: () => void;
}
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
const [name, setName] = React.useState('');
const [url, setUrl] = React.useState('');
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
const [adding, setAdding] = React.useState(false);
const [formError, setFormError] = React.useState<string | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
if (visible) {
setName('');
setUrl('');
setColor(CALENDAR_COLOR_PALETTE[0]);
setFormError(null);
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const handleAdd = async () => {
const n = name.trim();
const u = url.trim();
if (!n || !u) {
setFormError('Enter a name and a feed URL.');
return;
}
if (!/^(https?|webcal):\/\//i.test(u)) {
setFormError('URL must start with https://, http:// or webcal://');
return;
}
setFormError(null);
setAdding(true);
try {
const sub = await addSubscription({ name: n, url: u, color });
if (sub.lastError) {
setFormError(sub.lastError);
} else {
setName('');
setUrl('');
}
} catch (e) {
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
} finally {
setAdding(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Rss size={20} color={c.text} />
<Text style={styles.title}>Calendar subscriptions</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
<Text style={styles.sectionLabel}>Add a feed</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Name (e.g. Holidays)"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://… or webcal://…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.input}
/>
<View style={styles.colorRow}>
{CALENDAR_COLOR_PALETTE.map((col) => (
<Pressable
key={col}
onPress={() => setColor(col)}
style={[
styles.colorDot,
{ backgroundColor: col },
color === col && styles.colorDotActive,
]}
/>
))}
</View>
{!!formError && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{formError}</Text>
</View>
)}
<Button
onPress={() => { void handleAdd(); }}
disabled={adding}
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
>
{adding ? 'Subscribing…' : 'Subscribe'}
</Button>
{subscriptions.length > 0 && (
<>
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
{subscriptions.map((sub) => {
const busy = !!syncing[sub.id];
return (
<View key={sub.id} style={styles.subRow}>
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
<Text style={styles.subMeta} numberOfLines={1}>
{sub.lastError
? sub.lastError
: sub.lastSyncAt
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
: 'Not synced yet'}
</Text>
</View>
<Pressable
onPress={() => { void syncSubscription(sub.id); }}
hitSlop={6}
style={styles.subBtn}
disabled={busy}
>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<RefreshCw size={16} color={c.textSecondary} />
)}
</Pressable>
<Pressable
onPress={() => { void removeSubscription(sub.id); }}
hitSlop={6}
style={styles.subBtn}
>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
);
})}
</>
)}
</ScrollView>
<View style={{ height: insets.bottom }} />
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '88%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { flex: 1, ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
scroll: { padding: spacing.lg, gap: spacing.sm },
sectionLabel: { ...typography.bodySemibold, color: c.text, marginBottom: spacing.xs },
input: {
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
marginBottom: spacing.sm,
},
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
colorDotActive: { borderColor: c.text },
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
marginBottom: spacing.sm,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
subRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
subSwatch: { width: 14, height: 14, borderRadius: 7 },
subName: { ...typography.body, color: c.text },
subMeta: { ...typography.caption, color: c.textMuted, marginTop: 1 },
subBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
});
}
+371
View File
@@ -0,0 +1,371 @@
import React from 'react';
import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
import {
Calendar as CalendarIcon,
Check,
ListChecks,
Plus,
Trash2,
X,
} from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
import { useSheetDrag } from '../../lib/use-sheet-drag';
interface TasksSheetProps {
visible: boolean;
tasks: CalendarEvent[];
calendars: Calendar[];
onClose: () => void;
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
onToggle: (id: string) => Promise<void> | void;
onDelete: (id: string) => Promise<void> | void;
}
function isCompleted(task: CalendarEvent): boolean {
return task.progress === 'completed';
}
function dueLabel(task: CalendarEvent): string | null {
if (!task.due) return null;
const d = parseISO(task.due);
if (isNaN(d.getTime())) return null;
return format(d, 'EEE, MMM d');
}
// Tasks first by completion (open first), then by due date (soonest first,
// undated last), then title.
function compareTasks(a: CalendarEvent, b: CalendarEvent): number {
const ac = isCompleted(a);
const bc = isCompleted(b);
if (ac !== bc) return ac ? 1 : -1;
const ad = a.due ? parseISO(a.due).getTime() : Infinity;
const bd = b.due ? parseISO(b.due).getTime() : Infinity;
if (ad !== bd) return ad - bd;
return (a.title || '').localeCompare(b.title || '');
}
export function TasksSheet({
visible,
tasks,
calendars,
onClose,
onCreate,
onToggle,
onDelete,
}: TasksSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
const writableCalendars = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
[calendars],
);
const [title, setTitle] = React.useState('');
const [calendarId, setCalendarId] = React.useState('');
const [due, setDue] = React.useState<Date | null>(null);
const [showDuePicker, setShowDuePicker] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (visible && !calendarId && writableCalendars[0]) {
setCalendarId(writableCalendars[0].id);
}
}, [visible, calendarId, writableCalendars]);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: Dimensions.get('window').height, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]);
const handleAdd = async () => {
const t = title.trim();
if (!t || !calendarId || saving) return;
setSaving(true);
try {
const task: Partial<CalendarEvent> = {
title: t,
progress: 'needs-action',
};
if (due) {
task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss");
}
await onCreate(task, calendarId);
setTitle('');
setDue(null);
} finally {
setSaving(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<ListChecks size={20} color={c.text} />
<Text style={styles.headerTitle}>Tasks</Text>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
</View>
</View>
<View style={styles.addRow}>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Add a task…"
placeholderTextColor={c.textMuted}
style={styles.addInput}
returnKeyType="done"
onSubmitEditing={() => { void handleAdd(); }}
/>
<Pressable
onPress={() => setShowDuePicker(true)}
style={[styles.dueChip, due && styles.dueChipActive]}
hitSlop={6}
>
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
</Pressable>
<Pressable
onPress={() => { void handleAdd(); }}
disabled={!title.trim() || !calendarId || saving}
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
{writableCalendars.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.calChips}
>
{writableCalendars.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{sorted.length === 0 ? (
<View style={styles.empty}>
<ListChecks size={32} color={c.surfaceActive} />
<Text style={styles.emptyText}>No tasks yet</Text>
</View>
) : (
sorted.map((task) => {
const completed = isCompleted(task);
const due = dueLabel(task);
return (
<View key={task.id} style={styles.taskRow}>
<Pressable
onPress={() => { void onToggle(task.id); }}
style={[styles.checkbox, completed && styles.checkboxChecked]}
hitSlop={8}
>
{completed && <Check size={14} color={c.primaryForeground} />}
</Pressable>
<View style={styles.taskText}>
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
{task.title || 'Untitled task'}
</Text>
{due && <Text style={styles.taskDue}>{due}</Text>}
</View>
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
<Trash2 size={16} color={c.textMuted} />
</Pressable>
</View>
);
})
)}
</ScrollView>
</SafeAreaView>
</Animated.View>
{showDuePicker && (
<DateTimePicker
value={due ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowDuePicker(false);
if (d) setDue(d);
}}
/>
)}
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '85%',
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.surfaceActive },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
headerTitle: { flex: 1, ...typography.h3, color: c.text },
closeBtn: { padding: 4 },
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
},
addInput: {
flex: 1,
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
},
dueChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
height: 38,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
dueChipText: { ...typography.caption, color: c.primary },
addBtn: {
width: 38,
height: 38,
borderRadius: radius.sm,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
addBtnDisabled: { opacity: 0.5 },
calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm },
calChip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
maxWidth: 160,
},
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
calSwatch: { width: 10, height: 10, borderRadius: 5 },
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
list: { flexGrow: 0 },
listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm },
emptyText: { ...typography.body, color: c.textMuted },
taskRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
checkbox: {
width: 22,
height: 22,
borderRadius: radius.xs,
borderWidth: 2,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary },
taskText: { flex: 1 },
taskTitle: { ...typography.body, color: c.text },
taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted },
taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 },
taskDelete: { padding: 4 },
});
}
@@ -0,0 +1,218 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BookUser, X, Check, Plus } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore } from '../../stores/contacts-store';
interface Props {
visible: boolean;
onClose: () => void;
/** Book the contact(s) currently live in - shown with a check, not selectable. */
currentBookId?: string | null;
onPick: (addressBookId: string) => void;
title?: string;
}
export default function AddressBookPickerSheet({
visible, onClose, currentBookId, onPick, title = 'Move to address book',
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const addressBooks = useContactsStore((s) => s.addressBooks);
const createAddressBook = useContactsStore((s) => s.createAddressBook);
const [creating, setCreating] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
React.useEffect(() => {
if (visible) {
setCreating(false);
setNewName('');
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const handleCreate = async () => {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
try {
const book = await createAddressBook(name);
setCreating(false);
setNewName('');
onPick(book.id);
} catch (err) {
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{addressBooks.map((book) => {
const isCurrent = book.id === currentBookId;
const canWrite = book.myRights?.mayWrite !== false;
const canTarget = canWrite && !isCurrent;
return (
<Pressable
key={book.id}
onPress={canTarget ? () => onPick(book.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]}
>
<BookUser size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text style={[styles.rowLabel, !canTarget && styles.rowLabelDisabled]} numberOfLines={1}>
{book.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
{creating ? (
<View style={styles.createRow}>
<TextInput
style={styles.createInput}
placeholder="New address book name"
placeholderTextColor={c.textMuted}
value={newName}
onChangeText={setNewName}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void handleCreate(); }}
/>
<Pressable
onPress={() => { void handleCreate(); }}
disabled={!newName.trim() || busy}
style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]}
hitSlop={6}
>
<Check size={16} color={c.primaryForeground} />
</Pressable>
</View>
) : (
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Plus size={16} color={c.primary} />
<Text style={[styles.rowLabel, { color: c.primary }]}>New address book…</Text>
</Pressable>
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
rowLabelDisabled: { color: c.textMuted },
createRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
createInput: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
createBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.primary,
borderRadius: radius.full,
},
createBtnDisabled: { opacity: 0.4 },
});
}
@@ -0,0 +1,391 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system/legacy';
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
import type { ContactCard } from '../../api/types';
import { parseVCard, detectDuplicates } from '../../lib/vcard';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
import { useContactsStore } from '../../stores/contacts-store';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
// expo-document-picker is loaded lazily on first use; its native module is not
// linked into every build (matches the FilesScreen upload flow).
type DocumentPickerModule = typeof import('expo-document-picker');
let documentPickerModule: DocumentPickerModule | null = null;
function loadDocumentPicker(): DocumentPickerModule | null {
if (documentPickerModule) return documentPickerModule;
try {
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
return documentPickerModule;
} catch {
return null;
}
}
interface Props {
visible: boolean;
onClose: () => void;
/** Address book the imported contacts are created in. */
targetBookId: string | null;
targetBookName?: string;
onChangeTarget?: () => void;
onImported?: (count: number) => void;
}
export default function ContactImportSheet({
visible, onClose, targetBookId, targetBookName, onChangeTarget, onImported,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const existing = useContactsStore((s) => s.contacts);
const importContacts = useContactsStore((s) => s.importContacts);
const [parsed, setParsed] = React.useState<ContactCard[]>([]);
const [selected, setSelected] = React.useState<Set<number>>(new Set());
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
const [error, setError] = React.useState<string | null>(null);
const [importing, setImporting] = React.useState(false);
const [result, setResult] = React.useState<number | null>(null);
const slideY = React.useRef(new Animated.Value(900)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const resetState = React.useCallback(() => {
setParsed([]);
setSelected(new Set());
setDuplicates(new Map());
setError(null);
setImporting(false);
setResult(null);
}, []);
React.useEffect(() => {
if (visible) {
resetState();
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity, resetState]);
const pickFile = async () => {
const picker = loadDocumentPicker();
if (!picker) {
Alert.alert(
'Import unavailable',
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
);
return;
}
setError(null);
let res;
try {
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not open the file picker');
return;
}
if (res.canceled || res.assets.length === 0) return;
const asset = res.assets[0];
if (asset.size && asset.size > 5 * 1024 * 1024) {
setError('That file is too large (max 5 MB).');
return;
}
try {
const text = await FileSystem.readAsStringAsync(asset.uri);
const contacts = parseVCard(text);
if (contacts.length === 0) {
setError('No contacts were found in that file.');
return;
}
const dupes = detectDuplicates(existing, contacts);
const initial = new Set<number>();
contacts.forEach((_, idx) => { if (!dupes.has(idx)) initial.add(idx); });
setParsed(contacts);
setDuplicates(dupes);
setSelected(initial);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
}
};
const toggle = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
const selectNone = () => setSelected(new Set());
const doImport = async () => {
if (!targetBookId) {
Alert.alert('No address book', 'Create an address book before importing contacts.');
return;
}
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setImporting(true);
try {
const count = await importContacts(toImport, targetBookId);
setResult(count);
onImported?.(count);
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed.');
} finally {
setImporting(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.title}>Import contacts</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={20} color={c.text} />
</Pressable>
</View>
{result !== null ? (
<View style={styles.center}>
<View style={styles.successBadge}>
<Check size={26} color={c.primaryForeground} />
</View>
<Text style={styles.successText}>
Imported {result} contact{result === 1 ? '' : 's'}.
</Text>
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
</View>
) : parsed.length === 0 ? (
<View style={styles.center}>
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }}>
<Upload size={32} color={c.textMuted} />
<Text style={styles.dropTitle}>Choose a vCard file</Text>
<Text style={styles.dropHint}>.vcf / .vcard — single or multiple contacts</Text>
</Pressable>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
) : (
<>
<Pressable
style={styles.targetRow}
onPress={onChangeTarget}
disabled={!onChangeTarget}
>
<BookUser size={16} color={c.textSecondary} />
<Text style={styles.targetText} numberOfLines={1}>
Import into {targetBookName || 'address book'}
</Text>
{onChangeTarget && <Text style={styles.targetChange}>Change</Text>}
</Pressable>
<View style={styles.toolbar}>
<Text style={styles.toolbarCount}>
{parsed.length} found · {selected.size} selected
</Text>
<View style={{ flexDirection: 'row', gap: spacing.md }}>
<Pressable onPress={selectAll} hitSlop={6}>
<Text style={styles.link}>All</Text>
</Pressable>
<Pressable onPress={selectNone} hitSlop={6}>
<Text style={styles.link}>None</Text>
</Pressable>
</View>
</View>
<ScrollView style={{ flex: 1 }}>
{parsed.map((contact, idx) => {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const isDupe = duplicates.has(idx);
const isSelected = selected.has(idx);
return (
<Pressable
key={idx}
onPress={() => toggle(idx)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected && <Check size={13} color={c.primaryForeground} />}
</View>
<FileText size={16} color={c.textMuted} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.rowName} numberOfLines={1}>{name || email || '—'}</Text>
{!!email && !!name && (
<Text style={styles.rowEmail} numberOfLines={1}>{email}</Text>
)}
</View>
{isDupe && (
<Text style={styles.dupeBadge}>Duplicate</Text>
)}
</Pressable>
);
})}
</ScrollView>
{!!error && (
<View style={styles.errorBox}>
<AlertTriangle size={16} color={c.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
<Button
onPress={() => { void doImport(); }}
disabled={importing || selected.size === 0}
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
>
{importing ? 'Importing…' : `Import ${selected.size}`}
</Button>
</View>
</>
)}
</SafeAreaView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '85%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.h3, color: c.text },
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
dropZone: {
width: '100%',
borderWidth: 2,
borderColor: c.border,
borderStyle: 'dashed',
borderRadius: radius.lg,
paddingVertical: spacing.xxxl,
alignItems: 'center',
gap: spacing.sm,
},
dropTitle: { ...typography.bodyMedium, color: c.text },
dropHint: { ...typography.caption, color: c.textMuted },
successBadge: {
width: 56, height: 56, borderRadius: radius.full,
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
},
successText: { ...typography.bodyMedium, color: c.text },
targetRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
targetText: { ...typography.caption, color: c.textSecondary, flex: 1 },
targetChange: { ...typography.captionMedium, color: c.primary },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
toolbarCount: { ...typography.caption, color: c.textMuted },
link: { ...typography.captionMedium, color: c.primary },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 48,
},
rowPressed: { backgroundColor: c.surfaceHover },
checkbox: {
width: 20, height: 20, borderRadius: radius.xs,
borderWidth: 1, borderColor: c.border,
alignItems: 'center', justifyContent: 'center',
},
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
rowName: { ...typography.body, color: c.text },
rowEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
dupeBadge: {
...typography.small,
color: c.warning,
backgroundColor: c.warningBg,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
overflow: 'hidden',
},
errorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
borderWidth: 1,
borderColor: c.errorBorder,
},
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: c.border,
},
});
}
@@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) {
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
+190
View File
@@ -0,0 +1,190 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Tag, X, Plus } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
interface Props {
visible: boolean;
onClose: () => void;
/** Called with the chosen tag (existing or freshly typed). */
onPick: (keyword: string) => void;
title?: string;
}
export default function TagAssignSheet({
visible, onClose, onPick, title = 'Add tag',
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const keywords = useContactsStore(selectKeywordsUsed);
const [input, setInput] = React.useState('');
const slideY = React.useRef(new Animated.Value(600)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
React.useEffect(() => {
if (visible) {
setInput('');
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const submitNew = () => {
const kw = input.trim();
if (!kw) return;
onPick(kw);
};
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="New tag name"
placeholderTextColor={c.textMuted}
value={input}
onChangeText={setInput}
autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={submitNew}
/>
<Pressable
onPress={submitNew}
disabled={!input.trim()}
style={[styles.addBtn, !input.trim() && styles.addBtnDisabled]}
hitSlop={6}
>
<Plus size={16} color={c.primaryForeground} />
</Pressable>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{keywords.length === 0 ? (
<Text style={styles.empty}>No tags yet. Type one above.</Text>
) : (
keywords.map((kw) => (
<Pressable
key={kw.keyword}
onPress={() => onPick(kw.keyword)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<Tag size={16} color={c.textSecondary} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.keyword}</Text>
<Text style={styles.rowCount}>{kw.count}</Text>
</Pressable>
))
)}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
title: { ...typography.bodySemibold, color: c.text },
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
inputRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
input: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
addBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: c.primary,
borderRadius: radius.full,
},
addBtnDisabled: { opacity: 0.4 },
empty: { ...typography.caption, color: c.textMuted, textAlign: 'center', paddingVertical: spacing.xl },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowLabel: { ...typography.body, color: c.text, flex: 1 },
rowCount: { ...typography.caption, color: c.textMuted },
});
}
+3
View File
@@ -1,4 +1,7 @@
export { default as AddressBookPickerSheet } from './AddressBookPickerSheet';
export { default as ContactImportSheet } from './ContactImportSheet';
export { default as ContactListRow } from './ContactListRow';
export { default as ContactPickerSheet } from './ContactPickerSheet';
export { default as ContactsSidebarDrawer } from './ContactsSidebarDrawer';
export { default as TagAssignSheet } from './TagAssignSheet';
export { default as FieldBlock, FieldRow } from './FieldBlock';
@@ -0,0 +1,305 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native';
import {
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
} from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { parseCalendarBlob } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useAccountStore } from '../../stores/account-store';
import { useSettingsStore } from '../../stores/settings-store';
import {
findCalendarAttachment,
findParticipantByEmail,
getOrganizerName,
buildReplyTo,
inferInvitationMethod,
type InvitationMethod,
} from '../../lib/calendar-invitation';
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
interface Props {
email: Email;
}
export function CalendarInvitationBanner({ email }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const calendars = useCalendarStore((s) => s.calendars);
const importEvents = useCalendarStore((s) => s.importEvents);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
const [state, setState] = React.useState<BannerState>('loading');
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
if (!attachment || !enabled) return;
setState('loading');
(async () => {
try {
const events = await parseCalendarBlob(attachment.blobId);
if (cancelled) return;
if (events.length === 0) {
setState('error');
return;
}
const parsed = events[0];
setEvent(parsed);
setMethod(inferInvitationMethod(parsed));
setState('parsed');
} catch {
if (!cancelled) setState('error');
}
})();
return () => { cancelled = true; };
}, [attachment, enabled]);
if (!attachment || !enabled || state === 'error') return null;
if (state === 'loading') {
return (
<View style={styles.banner}>
<ActivityIndicator size="small" color={c.primary} />
<Text style={styles.loadingText}>Reading invitation…</Text>
</View>
);
}
if (!event) return null;
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
const startDate = startStr ? parseISO(startStr) : null;
const dateLabel = startDate && !isNaN(startDate.getTime())
? (event.showWithoutTime
? format(startDate, 'EEEE, MMM d, yyyy')
: format(startDate, 'EEE, MMM d · HH:mm'))
: null;
const organizer = getOrganizerName(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
if (busy || !writableCalendar) return;
setBusy(true);
setNotice(null);
try {
// Make sure the event exists in a local calendar (dedupes by UID).
await importEvents([event], writableCalendar.id);
// Re-read from the store to get the server-assigned id + participant.
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
const participant = stored && activeEmail
? findParticipantByEmail(stored, [activeEmail])
: me;
if (stored && participant) {
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
setRsvpStatus(status);
setNotice('Response sent');
} else {
setNotice('Added to calendar');
}
setState('done');
} catch {
setNotice('Could not send your response');
} finally {
setBusy(false);
}
};
const handleImport = async () => {
if (busy || !writableCalendar) return;
setBusy(true);
setNotice(null);
try {
const count = await importEvents([event], writableCalendar.id);
setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar');
setState('done');
} catch {
setNotice('Could not add the event');
} finally {
setBusy(false);
}
};
return (
<View style={styles.banner}>
<View style={styles.headerRow}>
<View style={styles.iconBadge}>
<CalendarDays size={18} color={c.primary} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.title} numberOfLines={2}>{event.title || 'Calendar invitation'}</Text>
{method === 'cancel' && <Text style={styles.cancelled}>This event was cancelled</Text>}
</View>
</View>
{dateLabel && (
<Row icon={<Clock size={15} color={c.textMuted} />} text={dateLabel} styles={styles} />
)}
{location ? (
<Row icon={<MapPin size={15} color={c.textMuted} />} text={location} styles={styles} />
) : null}
{videoUri ? (
<Pressable style={styles.detailRow} onPress={() => { void Linking.openURL(videoUri); }}>
<Video size={15} color={c.textMuted} />
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>Join video call</Text>
</Pressable>
) : null}
{organizer ? (
<Row icon={<CalendarPlus size={15} color={c.textMuted} />} text={`Organizer: ${organizer}`} styles={styles} />
) : null}
{notice && <Text style={styles.notice}>{notice}</Text>}
{state !== 'done' && (
<View style={styles.actions}>
{canRsvp ? (
<>
<RsvpBtn label="Yes" active={rsvpStatus === 'accepted'} activeColor={c.success}
icon={<Check size={15} color={rsvpStatus === 'accepted' ? c.textInverse : c.success} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} />
<RsvpBtn label="Maybe" active={rsvpStatus === 'tentative'} activeColor={c.warning}
icon={<HelpCircle size={15} color={rsvpStatus === 'tentative' ? c.textInverse : c.warning} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} />
<RsvpBtn label="No" active={rsvpStatus === 'declined'} activeColor={c.error}
icon={<X size={15} color={rsvpStatus === 'declined' ? c.textInverse : c.error} />}
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
</>
) : (
<Pressable
style={[styles.importBtn, busy && { opacity: 0.5 }]}
onPress={() => { void handleImport(); }}
disabled={busy}
>
{busy ? (
<ActivityIndicator size="small" color={c.primaryForeground} />
) : (
<CalendarPlus size={16} color={c.primaryForeground} />
)}
<Text style={styles.importBtnText}>Add to calendar</Text>
</Pressable>
)}
</View>
)}
{!writableCalendar && state !== 'done' && (
<View style={styles.warnRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.warnText}>No writable calendar available</Text>
</View>
)}
</View>
);
}
function Row({
icon, text, styles,
}: {
icon: React.ReactNode;
text: string;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<View style={styles.detailRow}>
{icon}
<Text style={styles.detailText} numberOfLines={2}>{text}</Text>
</View>
);
}
function RsvpBtn({
label, icon, active, activeColor, disabled, onPress, c, styles,
}: {
label: string;
icon: React.ReactNode;
active: boolean;
activeColor: string;
disabled?: boolean;
onPress: () => void;
c: ThemePalette;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
gap: spacing.xs,
},
loadingText: { ...typography.caption, color: c.textMuted },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs },
iconBadge: {
width: 32, height: 32, borderRadius: radius.sm,
backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center',
},
title: { ...typography.bodySemibold, color: c.text },
cancelled: { ...typography.caption, color: c.error, marginTop: 2 },
detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 },
detailText: { flex: 1, ...typography.caption, color: c.textSecondary },
notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs },
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
rsvpBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
rsvpBtnText: { ...typography.caption, color: c.text },
importBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: c.primary,
},
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
warnText: { ...typography.caption, color: c.warning },
});
}
+149 -4
View File
@@ -1,16 +1,36 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import { ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const DAY_OPTIONS = [
{ value: '1', label: 'Last 24 hours' },
{ value: '3', label: 'Last 3 days' },
{ value: '7', label: 'Last 7 days' },
{ value: '14', label: 'Last 14 days' },
{ value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
];
function formatRelativeTime(ms: number | undefined): string {
if (!ms) return 'never';
const diff = Date.now() - ms;
if (diff < 60_000) return 'just now';
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
}
const DEBUG_CATEGORIES = [
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
@@ -36,9 +56,36 @@ export function AboutDataSettings() {
const [syncEnabled, setSyncEnabled] = useState(true);
const [confirmReset, setConfirmReset] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
const sync = useOfflineCacheStore((s) => s.sync);
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays });
};
const handleClearCache = () => {
Alert.alert(
'Clear offline mail',
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
],
);
};
const handleReset = () => {
resetExternalContentPolicy('ask');
@@ -72,6 +119,80 @@ export function AboutDataSettings() {
</View>
</View>
<SettingsSection
title="Offline mail"
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
>
<SettingItem
label="Cache recent mail"
description="When on, the app keeps message bodies for the selected window on this device."
>
<ToggleSwitch
checked={offlineEnabled}
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
/>
</SettingItem>
<SettingItem
label="Window"
description="How far back to cache, measured by message receipt date."
>
<Select
value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={DAY_OPTIONS}
/>
</SettingItem>
<View style={styles.cacheStatsBox}>
<View style={styles.cacheStatsHeader}>
<CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}>
{cacheCount === 0
? 'Nothing cached yet'
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
</Text>
</View>
<Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching'
? `Downloading ${sync.completed}/${sync.total}…`
: sync.phase === 'scanning'
? 'Scanning recent mail…'
: sync.phase === 'error'
? `Last sync failed: ${sync.message ?? 'unknown error'}`
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
</Text>
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
]}
/>
</View>
)}
<View style={styles.cacheActions}>
<Button
variant="outline"
size="sm"
onPress={handleSyncNow}
disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{syncBusy ? 'Syncing…' : 'Sync now'}
</Button>
<Button
variant="outline"
size="sm"
onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy}
>
Clear cache
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
@@ -170,5 +291,29 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground,
marginBottom: spacing.sm,
},
cacheStatsBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
gap: spacing.sm,
},
cacheStatsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
progressTrack: {
height: 4,
borderRadius: radius.full,
backgroundColor: c.border,
overflow: 'hidden',
},
progressFill: { height: '100%', backgroundColor: c.primary },
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
});
}
@@ -1,11 +1,18 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { jmapClient } from '../../api/jmap-client';
import {
clearClientCertAlias,
getClientCertAlias,
isClientCertSupported,
pickClientCertAlias,
} from '../../lib/client-cert';
interface Session {
id: string;
@@ -30,8 +37,96 @@ export function AccountSecuritySettings() {
const [twoFactor, setTwoFactor] = useState(false);
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
const certSupported = isClientCertSupported();
const [certAlias, setCertAlias] = useState<string | null>(null);
const [certBusy, setCertBusy] = useState(false);
useEffect(() => {
if (!certSupported) return;
void getClientCertAlias().then(setCertAlias);
}, [certSupported]);
const onPickCert = async () => {
if (certBusy) return;
setCertBusy(true);
try {
// Hint the picker with the JMAP server host so it can pre-filter to certs
// issued for it when the OEM picker honours that.
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
const alias = await pickClientCertAlias(host);
setCertAlias(alias);
} catch (err) {
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
} finally {
setCertBusy(false);
}
};
const onClearCert = async () => {
if (certBusy) return;
Alert.alert(
'Stop using client certificate?',
'New requests will no longer present a certificate. The certificate stays installed in Android.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
setCertBusy(true);
try {
await clearClientCertAlias();
setCertAlias(null);
} finally {
setCertBusy(false);
}
},
},
],
);
};
return (
<View style={styles.container}>
{certSupported && (
<SettingsSection
title="TLS client certificate"
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
>
<View style={styles.certRow}>
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.certStatus}>
{certAlias ? `Active: ${certAlias}` : 'Not set'}
</Text>
<Text style={styles.certHint}>
Install the certificate via Android Settings → Security → Encryption &amp; credentials, then pick it here.
</Text>
</View>
</View>
<View style={styles.certActions}>
<Button
variant="outline"
size="sm"
disabled={certBusy}
onPress={() => { void onPickCert(); }}
>
{certAlias ? 'Choose another' : 'Pick certificate'}
</Button>
{certAlias && (
<Button
variant="outline"
size="sm"
disabled={certBusy}
onPress={() => { void onClearCert(); }}
>
Clear
</Button>
)}
</View>
</SettingsSection>
)}
<SettingsSection title="Password" description="Change the password for this account.">
<View style={{ gap: spacing.md }}>
<View style={styles.pwField}>
@@ -196,5 +291,14 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
borderRadius: radius.sm,
},
certRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
paddingVertical: spacing.md,
},
certStatus: { ...typography.bodyMedium, color: c.text },
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
});
}
@@ -16,6 +16,8 @@ export function ComposingSettings() {
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -37,6 +39,14 @@ export function ComposingSettings() {
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
};
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
{ label: 'Off', value: 0 },
{ label: '5s', value: 5 },
{ label: '10s', value: 10 },
{ label: '20s', value: 20 },
{ label: '30s', value: 30 },
];
return (
<SettingsSection
title="Composing"
@@ -49,6 +59,29 @@ export function ComposingSettings() {
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Undo send delay</Text>
<Text style={styles.subDescription}>
Hold outgoing mail for a few seconds so you can cancel it. Requires server support.
</Text>
<View style={styles.segmentRow}>
{SEND_DELAY_OPTIONS.map((opt) => {
const active = sendDelaySeconds === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => updateSetting('sendDelaySeconds', opt.value)}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
<SettingItem
label="Attachment reminder"
description="Warn before sending if the message mentions an attachment but none is attached."
@@ -110,6 +143,19 @@ function makeStyles(c: ThemePalette) {
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground },
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
segment: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
segmentActive: { backgroundColor: c.primary, borderColor: c.primary },
segmentText: { ...typography.caption, color: c.text },
segmentTextActive: { color: c.primaryForeground, fontWeight: '600' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
+271 -35
View File
@@ -1,30 +1,50 @@
import React from 'react';
import { Alert, Share, Text } from 'react-native';
import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Download } from 'lucide-react-native';
import {
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import Dialog from '../Dialog';
import { ContactImportSheet } from '../contacts';
import { useSettingsStore } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { typography, type ThemePalette } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export function ContactsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
const books = useContactsStore(selectAddressBooksWithCount);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const createAddressBook = useContactsStore((s) => s.createAddressBook);
const renameAddressBook = useContactsStore((s) => s.renameAddressBook);
const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook);
const [exporting, setExporting] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false);
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editName, setEditName] = React.useState('');
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
const exportLabel =
exportable.length === 0
? 'No contacts to export'
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
const importTargetBookId = books[0]?.id ?? null;
const handleExport = async () => {
if (exportable.length === 0 || exporting) return;
@@ -50,40 +70,256 @@ export function ContactsSettings() {
}
};
const startRename = (id: string, name: string) => {
setAdding(false);
setEditingId(id);
setEditName(name);
};
const commitRename = async () => {
const name = editName.trim();
if (!editingId || !name || busy) { setEditingId(null); return; }
setBusy(true);
try {
await renameAddressBook(editingId, name);
setEditingId(null);
} catch (err) {
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
const commitCreate = async () => {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
try {
await createAddressBook(name);
setAdding(false);
setNewName('');
} catch (err) {
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
} finally {
setBusy(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
const { id } = deleteTarget;
setDeleteTarget(null);
try {
await deleteAddressBook(id);
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
}
};
return (
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
<>
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
>
Export
</Button>
</SettingItem>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Import contacts"
description="Importing vCard files is currently available on the web client only."
<SettingItem
label="Import contacts"
description="Import contacts from a vCard (.vcf) file with duplicate detection."
>
<Button
variant="outline"
size="sm"
icon={<Upload size={14} color={c.text} />}
onPress={() => setImportOpen(true)}
disabled={books.length === 0}
>
Import
</Button>
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={c.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
>
Export
</Button>
</SettingItem>
</SettingsSection>
<SettingsSection
title="Address books"
description="Create, rename, and remove the address books that organize your contacts."
>
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
</SettingItem>
</SettingsSection>
{books.map((book) => (
<View key={book.id} style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
{editingId === book.id ? (
<>
<TextInput
style={styles.bookInput}
value={editName}
onChangeText={setEditName}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitRename(); }}
placeholderTextColor={c.textMuted}
/>
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.iconBtn}>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => setEditingId(null)} hitSlop={6} style={styles.iconBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</>
) : (
<>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.bookName} numberOfLines={1}>{book.name}</Text>
<Text style={styles.bookCount}>
{book.count} contact{book.count === 1 ? '' : 's'}
</Text>
</View>
{book.myRights?.mayWrite !== false && (
<Pressable onPress={() => startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}>
<Pencil size={15} color={c.textSecondary} />
</Pressable>
)}
{book.myRights?.mayDelete !== false && books.length > 1 && (
<Pressable
onPress={() => setDeleteTarget({ id: book.id, name: book.name, count: book.count })}
hitSlop={6}
style={styles.iconBtn}
>
<Trash2 size={15} color={c.error} />
</Pressable>
)}
</>
)}
</View>
))}
{adding ? (
<View style={styles.bookRow}>
<BookUser size={16} color={c.textSecondary} />
<TextInput
style={styles.bookInput}
value={newName}
onChangeText={setNewName}
placeholder="New address book name"
placeholderTextColor={c.textMuted}
autoFocus
returnKeyType="done"
onSubmitEditing={() => { void commitCreate(); }}
/>
<Pressable
onPress={() => { void commitCreate(); }}
disabled={!newName.trim() || busy}
hitSlop={6}
style={styles.iconBtn}
>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable onPress={() => { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={styles.addRow}
onPress={() => { setEditingId(null); setAdding(true); }}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addLabel}>New address book</Text>
</Pressable>
)}
<View style={{ marginTop: spacing.sm }}>
<Button
variant="ghost"
size="sm"
onPress={() => { void fetchAddressBooks(); void fetchContacts(); }}
>
Refresh
</Button>
</View>
</SettingsSection>
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
targetBookId={importTargetBookId}
targetBookName={books[0]?.name}
onImported={() => { void fetchContacts(); }}
/>
<Dialog
visible={deleteTarget !== null}
title="Delete address book"
message={
deleteTarget
? `Delete "${deleteTarget.name}"?${deleteTarget.count > 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be removed from this book.` : ''} This cannot be undone.`
: ''
}
variant="destructive"
confirmText="Delete"
onConfirm={() => { void confirmDelete(); }}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
bookRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
bookName: { ...typography.body, color: c.text },
bookCount: { ...typography.caption, color: c.textMuted, marginTop: 1 },
bookInput: {
flex: 1,
...typography.body,
color: c.text,
height: componentSizes.inputHeight,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.background,
},
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
},
addLabel: { ...typography.body, color: c.primary },
});
}
@@ -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({
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
offsetToMinutesBefore,
minutesBeforeToOffset,
alertsToReminders,
remindersToAlerts,
formatReminder,
} from '../calendar-alerts';
describe('calendar-alerts', () => {
describe('offsetToMinutesBefore', () => {
it('parses minute offsets', () => {
expect(offsetToMinutesBefore('-PT15M')).toBe(15);
expect(offsetToMinutesBefore('-PT30M')).toBe(30);
});
it('parses hour and day offsets', () => {
expect(offsetToMinutesBefore('-PT1H')).toBe(60);
expect(offsetToMinutesBefore('-P1D')).toBe(60 * 24);
});
it('treats PT0S as at-time (0)', () => {
expect(offsetToMinutesBefore('PT0S')).toBe(0);
});
it('returns negative minutesBefore for post-start offsets', () => {
expect(offsetToMinutesBefore('PT10M')).toBe(-10);
});
it('returns null for garbage', () => {
expect(offsetToMinutesBefore('nonsense')).toBeNull();
});
});
describe('minutesBeforeToOffset', () => {
it('round-trips through offsetToMinutesBefore', () => {
for (const m of [0, 5, 15, 30, 60, 120, 60 * 24, 60 * 24 * 7]) {
expect(offsetToMinutesBefore(minutesBeforeToOffset(m))).toBe(m);
}
});
it('emits PT0S at time of event', () => {
expect(minutesBeforeToOffset(0)).toBe('PT0S');
});
});
describe('alertsToReminders / remindersToAlerts', () => {
it('extracts and de-dupes reminders, sorted by lead time desc', () => {
const reminders = alertsToReminders({
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
b: { trigger: { '@type': 'OffsetTrigger', offset: '-P1D' }, action: 'display' },
c: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
});
expect(reminders).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 15 }]);
});
it('builds an alerts map from reminders', () => {
const alerts = remindersToAlerts([{ minutesBefore: 15 }, { minutesBefore: 0 }]);
expect(alerts && Object.keys(alerts)).toHaveLength(2);
const offsets = Object.values(alerts!).map((a) => a.trigger.offset);
expect(offsets).toContain('-PT15M');
expect(offsets).toContain('PT0S');
});
it('returns undefined for an empty reminder list', () => {
expect(remindersToAlerts([])).toBeUndefined();
});
it('round-trips alerts → reminders → alerts', () => {
const reminders = [{ minutesBefore: 60 }, { minutesBefore: 10 }];
const back = alertsToReminders(remindersToAlerts(reminders));
expect(back).toEqual([{ minutesBefore: 60 }, { minutesBefore: 10 }]);
});
});
describe('formatReminder', () => {
it('formats common offsets', () => {
expect(formatReminder(0)).toBe('At time of event');
expect(formatReminder(15)).toBe('15 minutes before');
expect(formatReminder(60)).toBe('1 hour before');
expect(formatReminder(60 * 24)).toBe('1 day before');
expect(formatReminder(60 * 24 * 7)).toBe('1 week before');
});
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import {
parseVCard,
generateVCard,
contactToVCard,
detectDuplicates,
} from '../vcard';
import type { ContactCard } from '../../api/types';
describe('parseVCard', () => {
it('parses a basic 3.0 card with name, email, and phone', () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Jane Doe',
'N:Doe;Jane;;;',
'EMAIL;TYPE=WORK:jane@example.com',
'TEL;TYPE=CELL:+1-555-0100',
'END:VCARD',
].join('\r\n');
const [card] = parseVCard(vcf);
expect(card).toBeTruthy();
const emails = Object.values(card.emails ?? {});
expect(emails[0].address).toBe('jane@example.com');
expect(emails[0].contexts).toEqual({ work: true });
const phones = Object.values(card.phones ?? {});
expect(phones[0].number).toBe('+1-555-0100');
expect(phones[0].features).toEqual({ cell: true });
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
expect(given).toBe('Jane');
expect(surname).toBe('Doe');
});
it('parses multiple cards in one file', () => {
const vcf = [
'BEGIN:VCARD', 'VERSION:3.0', 'FN:A', 'EMAIL:a@x.com', 'END:VCARD',
'BEGIN:VCARD', 'VERSION:3.0', 'FN:B', 'EMAIL:b@x.com', 'END:VCARD',
].join('\r\n');
expect(parseVCard(vcf)).toHaveLength(2);
});
it('unfolds folded lines and decodes escaped values', () => {
const vcf = [
'BEGIN:VCARD',
'VERSION:3.0',
'FN:Long Name',
'NOTE:line one\\nline two\\, still going',
'END:VCARD',
].join('\r\n');
const [card] = parseVCard(vcf);
const note = Object.values(card.notes ?? {})[0]?.note;
expect(note).toBe('line one\nline two, still going');
});
it('parses CATEGORIES into keywords', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD';
const [card] = parseVCard(vcf);
expect(card.keywords).toEqual({ Friends: true, VIP: true });
});
it('skips cards with neither a name nor an email', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nNOTE:orphan\r\nEND:VCARD';
expect(parseVCard(vcf)).toHaveLength(0);
});
it('strips mailto:/tel: URI schemes', () => {
const vcf = 'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:U\r\nEMAIL:mailto:u@x.com\r\nTEL:tel:+15550111\r\nEND:VCARD';
const [card] = parseVCard(vcf);
expect(Object.values(card.emails ?? {})[0].address).toBe('u@x.com');
expect(Object.values(card.phones ?? {})[0].number).toBe('+15550111');
});
});
describe('generateVCard', () => {
it('round-trips name and email through parse → generate → parse', () => {
const original = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nN:Doe;Jane;;;\r\nEMAIL;TYPE=WORK:jane@example.com\r\nEND:VCARD';
const [parsed] = parseVCard(original);
const generated = generateVCard([parsed]);
expect(generated).toContain('FN:Jane Doe');
expect(generated).toContain('EMAIL;TYPE=WORK:jane@example.com');
const [reparsed] = parseVCard(generated);
expect(Object.values(reparsed.emails ?? {})[0].address).toBe('jane@example.com');
});
it('contactToVCard emits a single BEGIN/END block', () => {
const card: ContactCard = {
id: 'c1',
addressBookIds: {},
name: { full: 'Solo', components: [{ kind: 'given', value: 'Solo' }] },
emails: { e0: { address: 'solo@x.com' } },
};
const out = contactToVCard(card);
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
expect(out.match(/END:VCARD/g)).toHaveLength(1);
});
});
describe('detectDuplicates', () => {
const existing: ContactCard[] = [
{ id: 'x1', addressBookIds: {}, emails: { e0: { address: 'dupe@x.com' } } },
];
it('flags an incoming contact that shares an email (case-insensitive)', () => {
const incoming: ContactCard[] = [
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'fresh@x.com' } } },
{ id: 'i1', addressBookIds: {}, emails: { e0: { address: 'DUPE@x.com' } } },
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.has(0)).toBe(false);
expect(dupes.get(1)).toBe('x1');
});
it('returns an empty map when nothing overlaps', () => {
const incoming: ContactCard[] = [
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'new@x.com' } } },
];
expect(detectDuplicates(existing, incoming).size).toBe(0);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { eachYearOfInterval, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent, ContactCard, AnniversaryDate } from '../api/types';
import { getContactDisplayName } from './contact-utils';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
// A read-only virtual calendar holding generated birthday events.
export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name,
color: BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
isDefault: false,
myRights: {
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayWrite: false,
mayDelete: false,
mayRSVP: false,
},
};
}
function parseBirthdayDate(date: AnniversaryDate): { month: number; day: number; year?: number } | null {
if (typeof date === 'string') {
if (date.startsWith('--')) {
const m = date.match(/^--(\d{2})-(\d{2})$/);
return m ? { month: parseInt(m[1], 10), day: parseInt(m[2], 10) } : null;
}
const parsed = parseISO(date);
if (!isNaN(parsed.getTime())) {
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
}
return null;
}
if ('utc' in date && date['@type'] === 'Timestamp') {
const parsed = parseISO(date.utc);
if (!isNaN(parsed.getTime())) {
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
}
return null;
}
// PartialDate
if (date.month && date.day) {
return { month: date.month, day: date.day, year: date.year || undefined };
}
return null;
}
// Generate virtual all-day birthday events for the given range from contacts
// that carry a `birth` anniversary.
export function generateBirthdayEvents(
contacts: ContactCard[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) return events;
const years = eachYearOfInterval({ start, end });
const endYear = end.getFullYear();
if (!years.some((y) => y.getFullYear() === endYear)) {
years.push(new Date(endYear, 0, 1));
}
for (const contact of contacts) {
if (!contact.anniversaries) continue;
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
if (anniversary.kind !== 'birth') continue;
const parsed = parseBirthdayDate(anniversary.date);
if (!parsed) continue;
const displayName = getContactDisplayName(contact);
if (!displayName) continue;
for (const yearDate of years) {
const year = yearDate.getFullYear();
// Feb 29 in a non-leap year: clamp to Feb 28.
let occMonth = parsed.month;
let occDay = parsed.day;
const occurrence = new Date(year, occMonth - 1, occDay);
if (occurrence.getMonth() !== occMonth - 1) {
occurrence.setDate(0);
occMonth = occurrence.getMonth() + 1;
occDay = occurrence.getDate();
}
if (occurrence < start || occurrence > end) continue;
const mm = String(occMonth).padStart(2, '0');
const dd = String(occDay).padStart(2, '0');
const age = parsed.year ? year - parsed.year : undefined;
const ageText = age && age > 0 ? ` (${age})` : '';
events.push({
id: `birthday-${contact.id}-${key}-${year}`,
'@type': 'Event',
uid: `birthday-${contact.id}-${key}`,
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
title: `🎂 ${displayName}${ageText}`,
start: `${year}-${mm}-${dd}T00:00:00`,
duration: 'P1D',
showWithoutTime: true,
status: 'confirmed',
freeBusyStatus: 'free',
color: BIRTHDAY_CALENDAR_COLOR,
});
}
}
}
return events;
}
+103
View File
@@ -0,0 +1,103 @@
import type { Alert, CalendarEvent } from '../api/types';
// A reminder, expressed as "minutes before the event start". 0 means "at the
// time of the event". Negative values (reminder *after* start) are preserved
// on round-trip but the picker only offers non-negative presets.
export interface Reminder {
/** Minutes before start. 0 = at time of event. */
minutesBefore: number;
}
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
// Parse an ISO 8601 duration offset ("-PT15M", "-P1D", "PT0S") to minutes.
// Positive return value = minutes *before* the anchor (JSCalendar offsets for
// reminders are negative, e.g. "-PT15M" fires 15 min before start).
export function offsetToMinutesBefore(offset: string | undefined): number | null {
if (!offset) return null;
const m = DURATION_RE.exec(offset);
if (!m) return null;
const negative = m[1] === '-';
const weeks = parseInt(m[2] || '0', 10);
const days = parseInt(m[3] || '0', 10);
const hours = parseInt(m[4] || '0', 10);
const minutes = parseInt(m[5] || '0', 10);
const seconds = parseInt(m[6] || '0', 10);
const totalMinutes = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + minutes + Math.round(seconds / 60);
// A negative offset (fires before start) is a positive "minutesBefore".
// `|| 0` collapses -0 to 0 so callers get a clean "at time of event".
return (negative ? totalMinutes : -totalMinutes) || 0;
}
export function minutesBeforeToOffset(minutesBefore: number): string {
if (minutesBefore === 0) return 'PT0S';
const sign = minutesBefore > 0 ? '-' : '';
let mins = Math.abs(minutesBefore);
const days = Math.floor(mins / (24 * 60));
mins -= days * 24 * 60;
const hours = Math.floor(mins / 60);
mins -= hours * 60;
let out = 'P';
if (days > 0) out += `${days}D`;
if (hours > 0 || mins > 0) {
out += 'T';
if (hours > 0) out += `${hours}H`;
if (mins > 0) out += `${mins}M`;
}
if (out === 'P') out = 'PT0M';
return `${sign}${out}`;
}
// Pull the list of reminders out of an event's `alerts` map. Only display-style
// offset alerts relative to start are surfaced in the editor; anything exotic is
// dropped from the UI list (but the event keeps it until the user saves).
export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
if (!alerts) return [];
const out: Reminder[] = [];
for (const alert of Object.values(alerts)) {
const trigger = alert?.trigger;
if (!trigger) continue;
if (trigger.offset === undefined) continue; // skip AbsoluteTriggers in the picker
const minutesBefore = offsetToMinutesBefore(trigger.offset);
if (minutesBefore === null) continue;
out.push({ minutesBefore });
}
// De-dupe + sort soonest-to-event last (largest lead time first).
const seen = new Set<number>();
return out
.filter((r) => (seen.has(r.minutesBefore) ? false : (seen.add(r.minutesBefore), true)))
.sort((a, b) => b.minutesBefore - a.minutesBefore);
}
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
if (reminders.length === 0) return undefined;
const alerts: Record<string, Alert> = {};
reminders.forEach((r, i) => {
alerts[`reminder-${i + 1}`] = {
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
action: 'display',
};
});
return alerts;
}
export function formatReminder(minutesBefore: number): string {
if (minutesBefore === 0) return 'At time of event';
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
if (minutesBefore < 60 * 24) {
const h = minutesBefore / 60;
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
}
if (minutesBefore < 60 * 24 * 7) {
const d = minutesBefore / (60 * 24);
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
}
const w = minutesBefore / (60 * 24 * 7);
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
}
// Presets offered by the reminder picker (minutes before start).
export const REMINDER_PRESETS: number[] = [
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
];
+148
View File
@@ -0,0 +1,148 @@
import type { CalendarEvent, Participant, Email, Attachment } from '../api/types';
// ─── Address helpers ─────────────────────────────────────
export function normalizeEmail(value?: string | null): string | null {
if (!value) return null;
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
return normalized || null;
}
export function getParticipantEmail(p: Participant): string | null {
const direct = normalizeEmail(p.email);
if (direct) return direct;
if (p.calendarAddress) {
const addr = normalizeEmail(p.calendarAddress);
if (addr) return addr;
}
if (p.sendTo) {
for (const addr of Object.values(p.sendTo)) {
const n = normalizeEmail(addr);
if (n) return n;
}
}
return null;
}
export function isOrganizerParticipant(p: Participant): boolean {
return Boolean(p.roles?.owner || p.roles?.chair);
}
export function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (isOrganizerParticipant(p)) {
const e = getParticipantEmail(p);
if (e) return e;
}
}
}
if (event.organizerCalendarAddress) return normalizeEmail(event.organizerCalendarAddress);
return null;
}
export function getOrganizerName(event: Partial<CalendarEvent>): string | null {
if (event.participants) {
for (const p of Object.values(event.participants)) {
if (isOrganizerParticipant(p)) return p.name || getParticipantEmail(p);
}
if (event.organizerCalendarAddress) {
for (const p of Object.values(event.participants)) {
if (p.calendarAddress === event.organizerCalendarAddress) return p.name || getParticipantEmail(p);
}
}
}
return getOrganizerEmail(event);
}
// Find the participant entry that matches one of the given user emails.
export function findParticipantByEmail(
event: Partial<CalendarEvent>,
emails: string[],
): { id: string; participant: Participant } | null {
if (!event.participants || emails.length === 0) return null;
const wanted = new Set(emails.map((e) => e.toLowerCase()).filter(Boolean));
for (const [id, p] of Object.entries(event.participants)) {
const e = getParticipantEmail(p);
if (e && wanted.has(e)) return { id, participant: p };
}
return null;
}
// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP
// reply (Stalwart exposes the organizer as organizerCalendarAddress).
export function buildReplyTo(event: Partial<CalendarEvent>): Record<string, string> | null {
if (event.replyTo) return event.replyTo;
if (event.organizerCalendarAddress) {
const addr = event.organizerCalendarAddress.startsWith('mailto:')
? event.organizerCalendarAddress
: `mailto:${event.organizerCalendarAddress}`;
return { imip: addr };
}
return null;
}
// ─── iMIP method detection ───────────────────────────────
export type InvitationMethod =
| 'publish' | 'request' | 'reply' | 'add'
| 'cancel' | 'refresh' | 'counter' | 'declinecounter' | 'unknown';
const KNOWN_METHODS = new Set<InvitationMethod>([
'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter',
]);
function normalizeMethod(value?: string | null): InvitationMethod {
if (!value) return 'unknown';
const v = value.trim().toLowerCase();
return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown';
}
// JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST`
// arrives as `text/calendar`. The raw ICS METHOD line is the reliable source.
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
const m = rawText.match(/^METHOD:(\S+)/m);
return m ? normalizeMethod(m[1]) : 'unknown';
}
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
if (!event.participants) return false;
return Object.values(event.participants).some(
(p) =>
p.roles?.attendee &&
!isOrganizerParticipant(p) &&
(p.participationStatus !== 'needs-action' || !!p.participationComment),
);
}
export function inferInvitationMethod(event: Partial<CalendarEvent>): InvitationMethod {
if (event.status === 'cancelled') return 'cancel';
if (looksLikeReply(event)) return 'reply';
if (event.participants && Object.keys(event.participants).length > 0) {
if (Object.values(event.participants).some(isOrganizerParticipant) || event.organizerCalendarAddress) {
return 'request';
}
}
return 'unknown';
}
// ─── Calendar attachment detection ───────────────────────
const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']);
function isCalendarType(type?: string | null): boolean {
if (!type) return false;
const base = type.split(';')[0].trim().toLowerCase();
return CAL_MIME.has(base);
}
export function findCalendarAttachment(email: Pick<Email, 'attachments'>): Attachment | null {
if (!email.attachments) return null;
for (const att of email.attachments) {
const name = att.name?.toLowerCase() || '';
if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) {
return att;
}
}
return null;
}
+35
View File
@@ -0,0 +1,35 @@
import { CAPABILITIES } from '../api/types';
import { useAuthStore } from '../stores/auth-store';
// When the session is null (cold start, offline restore) we assume features
// are available so they don't flicker off mid-restore. Once the live session
// arrives, the real capability set takes over.
function sessionHasCapability(capability: string): boolean {
const session = useAuthStore.getState().session;
if (!session) return true;
return capability in session.capabilities;
}
export function hasCalendarCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CALENDARS);
}
export function hasContactsCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CONTACTS);
}
export function hasFilesCapability(): boolean {
return sessionHasCapability(CAPABILITIES.FILES);
}
export function useHasCalendar(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
}
export function useHasContacts(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
}
export function useHasFiles(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
}
+185
View File
@@ -0,0 +1,185 @@
// Bridge to the native BulwarkClientCert module. Wraps the alias pick / get /
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
// closely enough that callers can swap them out.
//
// On non-Android platforms (or when the user has not picked a cert) we fall
// straight back to the global `fetch` so we don't pay any bridge overhead.
type Native = {
getAlias(): Promise<string | null>;
pickAlias(host: string | null): Promise<string | null>;
clearAlias(): Promise<void>;
fetchSecure(request: {
url: string;
method: string;
headers: Record<string, string>;
bodyBase64: string | null;
timeoutMs: number;
}): Promise<{
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}>;
};
// Lazy-loaded so unit tests (which run in plain Node) don't have to parse the
// `react-native` entry point. We resolve the module on first access; the
// result is cached by the require cache.
let nativeProbed = false;
let nativeModule: Native | null = null;
function getNative(): Native | null {
if (nativeProbed) return nativeModule;
nativeProbed = true;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const rn = require('react-native') as {
Platform: { OS: string };
NativeModules: Record<string, unknown>;
};
if (rn.Platform.OS !== 'android') return (nativeModule = null);
nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
} catch {
nativeModule = null;
}
return nativeModule;
}
// Cached so the hot path can short-circuit without a bridge round-trip.
let cachedAlias: string | null | undefined;
export function isClientCertSupported(): boolean {
return getNative() != null;
}
export async function getClientCertAlias(): Promise<string | null> {
const native = getNative();
if (!native) return null;
if (cachedAlias !== undefined) return cachedAlias;
cachedAlias = await native.getAlias();
return cachedAlias;
}
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
const native = getNative();
if (!native) throw new Error('Client certificates require Android');
const alias = await native.pickAlias(host);
cachedAlias = alias;
return alias;
}
export async function clearClientCertAlias(): Promise<void> {
const native = getNative();
if (!native) return;
await native.clearAlias();
cachedAlias = null;
}
// ── secureFetch ───────────────────────────────────────────────
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
return globalThis.btoa ? globalThis.btoa(bin) : btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function bodyToBase64(body: BodyInit | null | undefined): Promise<string | null> {
if (body == null) return null;
if (typeof body === 'string') {
// UTF-8 encode then base64.
const encoder = new TextEncoder();
return bytesToBase64(encoder.encode(body));
}
if (body instanceof ArrayBuffer) {
return bytesToBase64(new Uint8Array(body));
}
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
}
// Blob / FormData / URLSearchParams / ReadableStream are not used in the
// current code paths. If they appear, the caller should pre-serialize.
throw new Error(`secureFetch: unsupported body type ${(body as object).constructor?.name}`);
}
function flattenHeaders(input: HeadersInit | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!input) return out;
if (Array.isArray(input)) {
for (const [k, v] of input) out[k] = v;
return out;
}
if (typeof Headers !== 'undefined' && input instanceof Headers) {
input.forEach((v, k) => { out[k] = v; });
return out;
}
return { ...(input as Record<string, string>) };
}
function buildResponse(raw: {
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}): Response {
const bytes = raw.bodyBase64 ? base64ToBytes(raw.bodyBase64) : new Uint8Array(0);
const blob = new Blob([bytes as unknown as BlobPart]);
return new Response(blob, {
status: raw.status,
statusText: raw.statusText,
headers: raw.headers,
});
}
/**
* Drop-in replacement for `fetch` that routes through the native client when
* the user has selected a client certificate. When no cert is set (or we're
* on iOS), it just calls the platform `fetch` and the caller pays no
* bridge overhead.
*/
export async function secureFetch(
url: string,
init?: RequestInit,
): Promise<Response> {
const native = getNative();
if (!native) return fetch(url, init);
const alias = await getClientCertAlias();
if (!alias) return fetch(url, init);
const method = init?.method ?? 'GET';
const headers = flattenHeaders(init?.headers);
const bodyBase64 = await bodyToBase64(init?.body);
// Force Content-Length to come from the body bytes - some servers (Stalwart
// behind nginx in particular) reject chunked uploads when an explicit
// length isn't provided alongside the bytes.
if (bodyBase64 != null) {
const decoded = base64ToBytes(bodyBase64);
headers['Content-Length'] = String(decoded.byteLength);
}
try {
const raw = await native.fetchSecure({
url,
method,
headers,
bodyBase64,
timeoutMs: 30_000,
});
return buildResponse(raw);
} catch (err) {
// Surface as a TypeError to mimic fetch's network-error contract; keep
// the original message for diagnostics.
const msg = err instanceof Error ? err.message : String(err);
throw new TypeError(`secureFetch failed: ${msg}`);
}
}
+40 -14
View File
@@ -1,7 +1,8 @@
import { File, Paths } from 'expo-file-system';
import { Directory, File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
import { getClientCertAlias, secureFetch } from './client-cert';
const RFC822 = 'message/rfc822';
@@ -21,6 +22,40 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
// Routes the download via the client-cert-aware native module when the user
// has picked a cert, and via expo-file-system's native streaming downloader
// otherwise. The streaming path scales to large attachments without buffering
// in JS, so we keep using it as the default.
async function downloadInto(
url: string,
dest: File,
parent: Directory,
): Promise<File> {
const alias = await getClientCertAlias();
if (!alias) {
// The static returns a separately-typed `FileSystemFile`; we already
// have a fully-typed `File` referencing the same uri, so we ignore the
// return value and re-use our `dest` reference for downstream code.
await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
return dest;
}
const response = await secureFetch(url, {
headers: { Authorization: authHeader() },
});
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
if (!parent.exists) parent.create({ intermediates: true, idempotent: true });
if (dest.exists) dest.delete();
const buffer = await response.arrayBuffer();
dest.create();
dest.write(new Uint8Array(buffer));
return dest;
}
export async function shareAttachment(
blobId: string,
name: string | undefined,
@@ -30,10 +65,7 @@ export async function shareAttachment(
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
@@ -57,10 +89,7 @@ export async function downloadAttachment(
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
const downloaded = await downloadInto(url, dest, Paths.document);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
@@ -72,7 +101,7 @@ export async function downloadAttachment(
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
const r = await fetch(url, { headers: { Authorization: authHeader() } });
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
}
@@ -85,10 +114,7 @@ function safeFilename(subject: string | undefined): string {
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
Binary file not shown.
+84 -2
View File
@@ -3,12 +3,18 @@ import { File, Paths } from 'expo-file-system';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import type { ReleaseAsset } from '../api/updates';
import { Sha256 } from './sha256';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
// Bytes per file-read chunk while computing the post-download SHA-256. Larger
// chunks are faster but balloon the JS string buffer (base64 reads on this
// platform). 256 KiB keeps memory bounded while still amortising the bridge.
const HASH_CHUNK_BYTES = 256 * 1024;
export interface InstallProgress {
phase: 'downloading' | 'installing';
phase: 'downloading' | 'verifying' | 'installing';
/** 0-1 during download; undefined for installing phase */
progress?: number;
/** Bytes received during current download */
@@ -19,10 +25,51 @@ export interface InstallProgress {
export type InstallProgressListener = (p: InstallProgress) => void;
export interface InstallParams {
asset: ReleaseAsset;
/** Optional lower-case hex SHA-256. When present, mismatched downloads abort. */
expectedSha256?: string | null;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise<string> {
const hasher = new Sha256();
let offset = 0;
while (offset < totalBytes || totalBytes === 0) {
const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES);
const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, {
encoding: FileSystemLegacy.EncodingType.Base64,
position: offset,
length,
});
if (!chunkB64) break;
const bytes = base64ToBytes(chunkB64);
if (bytes.length === 0) break;
hasher.update(bytes);
offset += bytes.length;
if (totalBytes > 0) {
onProgress?.({ phase: 'verifying', progress: offset / totalBytes });
}
if (bytes.length < length) break;
}
return hasher.digestHex();
}
export async function downloadAndInstallApk(
asset: ReleaseAsset,
params: InstallParams | ReleaseAsset,
onProgress?: InstallProgressListener,
): Promise<void> {
// Backwards-compat: callers used to pass the asset directly. Promote to the
// params shape so the verification path is opt-in by passing a checksum.
const { asset, expectedSha256 }: InstallParams =
'asset' in params ? params : { asset: params, expectedSha256: null };
if (Platform.OS !== 'android') {
throw new Error('APK install is only supported on Android');
}
@@ -53,6 +100,33 @@ export async function downloadAndInstallApk(
throw new Error('APK download failed');
}
// Size sanity check. GitHub's `assets[].size` is signed by their TLS cert,
// so a byte-count mismatch is a strong signal the file was truncated /
// tampered with in transport. Cheap to compute, fail-fast.
const info = await FileSystemLegacy.getInfoAsync(result.uri);
const actualSize = (info as { size?: number }).size ?? 0;
if (asset.size > 0 && actualSize !== asset.size) {
await tryDelete(result.uri);
throw new Error(
`APK size mismatch: expected ${asset.size} bytes, got ${actualSize}`,
);
}
// SHA-256 verification when the release publishes one. Without it, we still
// get Android's package-signer check at install time — which rejects an APK
// signed with a different key — but that fails late (after the user sees a
// confusing system dialog). Bailing here surfaces the problem cleanly.
if (expectedSha256) {
onProgress?.({ phase: 'verifying', progress: 0 });
const actual = await hashFile(result.uri, actualSize || asset.size, onProgress);
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
await tryDelete(result.uri);
throw new Error(
`APK checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
}
onProgress?.({ phase: 'installing' });
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
@@ -64,6 +138,14 @@ export async function downloadAndInstallApk(
});
}
async function tryDelete(uri: string): Promise<void> {
try {
await FileSystemLegacy.deleteAsync(uri, { idempotent: true });
} catch {
/* best effort */
}
}
/**
* Open the system "Install unknown apps" settings page for this app, so the
* user can grant permission to install APKs from the package installer.
+166
View File
@@ -0,0 +1,166 @@
import * as WebBrowser from 'expo-web-browser';
import { secureFetch } from './client-cert';
// Webmail-mediated login. The app opens the webmail's normal login page with
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
// uses its existing password and OAuth flows, and once the user is signed
// in, redirects back to the app's custom scheme with credentials packed into
// the URL fragment. Fragments aren't sent to the server, so password and
// token material don't appear in HTTP access logs along the way.
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export interface OAuthTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: number; // epoch ms
tokenEndpoint: string;
clientId: string;
}
export type HandoffResult =
| {
flow: 'password';
serverUrl: string;
username: string;
password: string;
}
| {
flow: 'oauth';
serverUrl: string;
tokens: OAuthTokens;
};
export class HandoffError extends Error {}
export class HandoffCancelledError extends HandoffError {
constructor() {
super('Sign-in cancelled');
}
}
function randomState(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
function buildHandoffUrl(webmailUrl: string, state: string): string {
const base = webmailUrl.replace(/\/+$/, '');
const params = new URLSearchParams({
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
mobile_state: state,
});
return `${base}/login?${params.toString()}`;
}
function parseFragment(url: string): URLSearchParams {
const hashIdx = url.indexOf('#');
if (hashIdx === -1) return new URLSearchParams();
return new URLSearchParams(url.slice(hashIdx + 1));
}
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
const state = randomState();
const handoffUrl = buildHandoffUrl(webmailUrl, state);
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
if (result.type === 'cancel' || result.type === 'dismiss') {
throw new HandoffCancelledError();
}
if (result.type !== 'success' || !result.url) {
throw new HandoffError(`Sign-in failed: ${result.type}`);
}
const params = parseFragment(result.url);
const err = params.get('error');
if (err) throw new HandoffError(err);
// CSRF guard: the state we generated must round-trip through the webmail
// unchanged. A mismatch means the redirect didn't come from the flow we
// started, and the rest of the fragment shouldn't be trusted.
if (params.get('state') !== state) {
throw new HandoffError('State mismatch');
}
const flow = params.get('flow');
const serverUrl = params.get('server_url');
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
if (flow === 'password') {
const username = params.get('username');
const password = params.get('password');
if (!username || !password) {
throw new HandoffError('Sign-in response missing credentials');
}
return { flow: 'password', serverUrl, username, password };
}
if (flow === 'oauth') {
const accessToken = params.get('access_token');
const tokenEndpoint = params.get('token_endpoint');
const clientId = params.get('client_id');
if (!accessToken || !tokenEndpoint || !clientId) {
throw new HandoffError('Sign-in response missing OAuth tokens');
}
const refreshToken = params.get('refresh_token') ?? undefined;
const expiresIn = params.get('expires_in');
return {
flow: 'oauth',
serverUrl,
tokens: {
accessToken,
refreshToken,
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
tokenEndpoint,
clientId,
},
};
}
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
}
// OAuth refresh — exchanges the refresh token at the original token endpoint
// for a new access token. Returns the updated bundle so the caller can
// persist it.
export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAuthTokens> {
if (!tokens.refreshToken) {
throw new HandoffError('No refresh token available');
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refreshToken,
client_id: tokens.clientId,
});
const response = await secureFetch(tokens.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
throw new HandoffError(`Token refresh failed: ${response.status}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (!data.access_token) {
throw new HandoffError('Token refresh response missing access_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? tokens.refreshToken,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
};
}
WebBrowser.maybeCompleteAuthSession();
+148
View File
@@ -0,0 +1,148 @@
// Background sync that fills the offline mail cache. Runs the discovery
// query (Email/query filtered by `after`), then fetches full bodies in
// batches, reporting progress to the offline-cache-store so the
// OfflineCacheBanner and the Settings screen can show live updates.
import { jmapClient } from '../api/jmap-client';
import { queryEmailsByFilter, getFullEmails } from '../api/email';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import type { Email } from '../api/types';
// Approximate the on-disk size of a serialised Email so we can show "Y MB
// downloaded" without measuring AsyncStorage usage. Body values dominate; the
// envelope is small. Using JSON.stringify().length is correct in code-units
// not bytes, but it's close enough for a UI-facing estimate.
function approxSize(email: Email): number {
try {
return JSON.stringify(email).length;
} catch {
return 0;
}
}
// Hard cap so a misconfigured "30 days" against a noisy account doesn't try
// to enumerate 100k messages.
const DISCOVERY_LIMIT = 5000;
// Each Email/get request is bounded by the server's maxObjectsInGet. We
// further chunk to keep individual responses small (a 50-message response
// with full bodies + body values is already several MB).
const FETCH_CHUNK_FALLBACK = 25;
export interface RunOptions {
days: number;
}
export async function runOfflineSync(opts: RunOptions): Promise<void> {
const cache = useOfflineCacheStore.getState();
// If a sync is in flight, request its abort and wait for the next caller
// to start fresh. We don't queue — the most recent intent wins.
if (cache.sync.phase === 'scanning' || cache.sync.phase === 'fetching') {
cache.requestAbort();
return;
}
if (!cache.hydrated) await cache.hydrate();
cache.resetSync();
const startedAt = Date.now();
cache.setSyncState({ phase: 'scanning', startedAt });
const since = new Date(startedAt - opts.days * 24 * 60 * 60 * 1000).toISOString();
let ids: string[] = [];
try {
ids = await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT);
} catch (err) {
cache.setSyncState({
phase: 'error',
message: err instanceof Error ? err.message : 'Discovery query failed',
finishedAt: Date.now(),
});
return;
}
// Drop entries from the cache that fell out of the lookback window — the
// user expects the cache to track "the last X days", not grow forever.
const keepSet = new Set(ids);
const stale = Object.keys(cache.index.entries).filter((id) => !keepSet.has(id));
if (stale.length > 0) {
await cache.remove(stale);
}
const total = ids.length;
cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 });
// Skip already-cached ids — bodies on disk are immutable per messageId.
const toFetch = ids.filter((id) => !cache.has(id));
const skipped = total - toFetch.length;
cache.setSyncState({ completed: skipped });
if (toFetch.length === 0) {
cache.setSyncState({
phase: 'done',
finishedAt: Date.now(),
});
return;
}
const chunkSize = Math.min(
FETCH_CHUNK_FALLBACK,
Math.max(1, jmapClient.getMaxObjectsInGet()),
);
let fetched = 0;
let bytes = 0;
let completed = skipped;
for (let i = 0; i < toFetch.length; i += chunkSize) {
if (useOfflineCacheStore.getState().consumeAbort()) {
cache.setSyncState({
phase: 'cancelled',
finishedAt: Date.now(),
});
return;
}
const chunk = toFetch.slice(i, i + chunkSize);
let emails: Email[];
try {
emails = await getFullEmails(chunk);
} catch (err) {
cache.setSyncState({
phase: 'error',
message: err instanceof Error ? err.message : 'Fetch failed',
finishedAt: Date.now(),
});
return;
}
for (const email of emails) {
const size = approxSize(email);
await cache.put(email, size);
fetched += 1;
bytes += size;
completed += 1;
}
// Some chunked ids may have been deleted server-side between query and
// fetch — they don't come back. Account for that in the completed count
// so the progress bar still finishes at 100%.
completed += chunk.length - emails.length;
cache.setSyncState({ completed, fetched, bytes });
}
cache.setSyncState({
phase: 'done',
completed: total,
fetched,
bytes,
finishedAt: Date.now(),
});
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
+132 -48
View File
@@ -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
View File
@@ -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
+131
View File
@@ -0,0 +1,131 @@
// Minimal SHA-256 implementation. Used to verify APK update downloads against
// the checksum published in the GitHub release body — the JS runtime has no
// built-in `crypto.subtle.digest` and we don't currently depend on
// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per
// MB on a modern phone, which is fine for the rare update flow.
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotr(x: number, n: number): number {
return (x >>> n) | (x << (32 - n));
}
export class Sha256 {
private h = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
private buffer = new Uint8Array(64);
private bufferLen = 0;
private byteCount = 0;
update(bytes: Uint8Array): void {
let i = 0;
this.byteCount += bytes.length;
while (i < bytes.length) {
const room = 64 - this.bufferLen;
const chunk = Math.min(room, bytes.length - i);
this.buffer.set(bytes.subarray(i, i + chunk), this.bufferLen);
this.bufferLen += chunk;
i += chunk;
if (this.bufferLen === 64) {
this.process(this.buffer);
this.bufferLen = 0;
}
}
}
digestHex(): string {
// Final block: append 0x80, pad with zeros, then 8-byte big-endian length.
const totalBits = this.byteCount * 8;
this.buffer[this.bufferLen++] = 0x80;
if (this.bufferLen > 56) {
while (this.bufferLen < 64) this.buffer[this.bufferLen++] = 0;
this.process(this.buffer);
this.bufferLen = 0;
}
while (this.bufferLen < 56) this.buffer[this.bufferLen++] = 0;
// 64-bit length, big-endian. We can't represent >2^53 - JS bitops are
// 32-bit, so split into hi/lo words.
const hi = Math.floor(totalBits / 0x100000000);
const lo = totalBits >>> 0;
this.buffer[56] = (hi >>> 24) & 0xff;
this.buffer[57] = (hi >>> 16) & 0xff;
this.buffer[58] = (hi >>> 8) & 0xff;
this.buffer[59] = hi & 0xff;
this.buffer[60] = (lo >>> 24) & 0xff;
this.buffer[61] = (lo >>> 16) & 0xff;
this.buffer[62] = (lo >>> 8) & 0xff;
this.buffer[63] = lo & 0xff;
this.process(this.buffer);
let out = '';
for (let i = 0; i < 8; i++) {
out += this.h[i].toString(16).padStart(8, '0');
}
return out;
}
private process(block: Uint8Array): void {
const w = new Uint32Array(64);
for (let i = 0; i < 16; i++) {
w[i] =
(block[i * 4] << 24) |
(block[i * 4 + 1] << 16) |
(block[i * 4 + 2] << 8) |
block[i * 4 + 3];
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
}
let a = this.h[0], b = this.h[1], c = this.h[2], d = this.h[3];
let e = this.h[4], f = this.h[5], g = this.h[6], hh = this.h[7];
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const mj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + mj) | 0;
hh = g;
g = f;
f = e;
e = (d + temp1) | 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) | 0;
}
this.h[0] = (this.h[0] + a) | 0;
this.h[1] = (this.h[1] + b) | 0;
this.h[2] = (this.h[2] + c) | 0;
this.h[3] = (this.h[3] + d) | 0;
this.h[4] = (this.h[4] + e) | 0;
this.h[5] = (this.h[5] + f) | 0;
this.h[6] = (this.h[6] + g) | 0;
this.h[7] = (this.h[7] + hh) | 0;
}
}
export function sha256Hex(bytes: Uint8Array): string {
const h = new Sha256();
h.update(bytes);
return h.digestHex();
}
+20
View File
@@ -0,0 +1,20 @@
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
// available (Hermes/React Native 0.81 exposes it), falling back to a
// Math.random implementation. These IDs are only used as temporary client-side
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
// the Math.random fallback's weaker entropy is acceptable.
export function generateUUID(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
if (c?.randomUUID) {
try {
return c.randomUUID();
} catch {
// fall through to the manual implementation
}
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
+1203 -116
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -23,6 +23,8 @@ export type RootStackParamList = {
ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean };
GroupDetail: { groupId: string };
AddAccount: undefined;
Scheduled: undefined;
UnifiedInbox: undefined;
};
export type MainTabsParamList = {
+95 -4
View File
@@ -16,6 +16,7 @@ import {
CalendarDays,
LayoutGrid,
List as ListIcon,
ListChecks,
Menu,
} from 'lucide-react-native';
import {
@@ -47,11 +48,17 @@ import {
type RecurrenceEditScope,
} from '../components/calendar/RecurrenceScopeDialog';
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
import { TasksSheet } from '../components/calendar/TasksSheet';
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
type EventDayIndex,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { useContactsStore } from '../stores/contacts-store';
import type { Calendar, CalendarEvent } from '../api/types';
type ViewMode = 'month' | 'week' | 'agenda';
@@ -108,6 +115,9 @@ export default function CalendarScreen() {
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
const contacts = useContactsStore((s) => s.contacts);
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
@@ -125,6 +135,9 @@ export default function CalendarScreen() {
const [modalVisible, setModalVisible] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
const [sidebarVisible, setSidebarVisible] = React.useState(false);
const [tasksVisible, setTasksVisible] = React.useState(false);
const [importVisible, setImportVisible] = React.useState(false);
const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const hydrate = useCalendarStore((s) => s.hydrate);
@@ -134,12 +147,36 @@ export default function CalendarScreen() {
const createEvent = useCalendarStore((s) => s.createEvent);
const updateEvent = useCalendarStore((s) => s.updateEvent);
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
const importEvents = useCalendarStore((s) => s.importEvents);
const tasks = useCalendarStore((s) => s.tasks);
const createTask = useCalendarStore((s) => s.createTask);
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
const deleteTask = useCalendarStore((s) => s.deleteTask);
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
const allCalendars = useCalendarStore((s) => s.calendars);
const storeCalendars = useCalendarStore((s) => s.calendars);
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
const loading = useCalendarStore((s) => s.loading);
const error = useCalendarStore((s) => s.error);
const allEvents = useCalendarStore((s) => s.events);
const storeEvents = useCalendarStore((s) => s.events);
// The birthday calendar is a client-side virtual calendar: its events are
// generated from contacts for the visible range and merged in alongside the
// server calendars. Toggling it on/off is instant (no refetch).
const birthdayEvents = React.useMemo(() => {
if (!showBirthdayCalendar) return [];
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
const allCalendars = React.useMemo(
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
[storeCalendars, showBirthdayCalendar],
);
const allEvents = React.useMemo(
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
[storeEvents, birthdayEvents],
);
const calendars = React.useMemo(
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
@@ -210,23 +247,30 @@ export default function CalendarScreen() {
setModalVisible(true);
}, []);
const isReadOnlyEvent = React.useCallback(
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect]);
}, [openEditDirect, isReadOnlyEvent]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent]);
}, [deleteEvent, isReadOnlyEvent]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
@@ -293,6 +337,15 @@ export default function CalendarScreen() {
</Text>
</View>
<View style={styles.headerActions}>
{enableCalendarTasks && (
<Pressable
style={styles.headerBtn}
onPress={() => setTasksVisible(true)}
hitSlop={6}
>
<ListChecks size={20} color={c.text} />
</Pressable>
)}
<View style={styles.viewToggle}>
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
const Icon =
@@ -397,6 +450,20 @@ export default function CalendarScreen() {
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
onRsvp={async (ev, participantId, status) => {
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev));
setDetailEvent((cur) =>
cur && cur.id === ev.id && cur.participants?.[participantId]
? {
...cur,
participants: {
...cur.participants,
[participantId]: { ...cur.participants[participantId], participationStatus: status },
},
}
: cur,
);
}}
/>
<EventModal
@@ -422,6 +489,30 @@ export default function CalendarScreen() {
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
/>
<TasksSheet
visible={tasksVisible}
tasks={tasks}
calendars={allCalendars}
onClose={() => setTasksVisible(false)}
onCreate={createTask}
onToggle={toggleTaskComplete}
onDelete={deleteTask}
/>
<ICalImportSheet
visible={importVisible}
calendars={storeCalendars}
onClose={() => setImportVisible(false)}
onImport={importEvents}
/>
<ICalSubscriptionSheet
visible={subscriptionsVisible}
onClose={() => setSubscriptionsVisible(false)}
/>
</SafeAreaView>
);
+239 -4
View File
@@ -9,8 +9,9 @@ import {
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText,
Undo2, Redo2, FileText, Clock, Check,
} from 'lucide-react-native';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { File as FsFile } from 'expo-file-system';
@@ -33,6 +34,7 @@ import {
} from '../lib/contact-utils';
import { getIdentities } from '../api/identity';
import { sendEmail, type OutgoingAttachment } from '../api/email';
import { jmapClient } from '../api/jmap-client';
import { uploadBlob } from '../api/blob';
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
import { stripDangerousTags } from '../lib/email-html';
@@ -256,11 +258,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
// can only show one field at a time, so we walk date → time.
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
const customDraftRef = React.useRef<Date>(new Date());
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const initialTo = React.useMemo<Recipient[]>(() => {
if (!replyTo) {
@@ -737,7 +745,96 @@ export default function ComposeScreen({ route, navigation }: Props) {
});
};
const onSend = async () => {
// Translate an absolute "send at" time into the HOLDFOR seconds the server
// expects, clamping to what it actually supports. Returns null (and alerts)
// when scheduling isn't possible so the caller can abort.
const resolveHoldForScheduledAt = (date: Date): number | null => {
const seconds = Math.ceil((date.getTime() - Date.now()) / 1000);
if (seconds <= 0) {
Alert.alert(
t('email_composer.schedule_past_title', 'Pick a future time'),
t('email_composer.schedule_past_body', 'The scheduled time must be in the future.'),
);
return null;
}
if (!jmapClient.hasDelayedSend()) {
Alert.alert(
t('email_composer.schedule_unsupported_title', 'Scheduling unavailable'),
t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.'),
);
return null;
}
const max = jmapClient.getMaxDelayedSend();
if (max > 0 && seconds > max) {
Alert.alert(
t('email_composer.schedule_too_late_title', 'Too far ahead'),
t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'),
);
return null;
}
return seconds;
};
// The Send button: applies the global undo-send delay when the server
// supports it, otherwise sends immediately.
const onSend = () => {
const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined;
void performSend(holdFor);
};
const onScheduleConfirm = (date: Date) => {
const holdFor = resolveHoldForScheduledAt(date);
if (holdFor == null) return;
setScheduleSheetOpen(false);
void performSend(holdFor, date);
};
const schedulePresets = React.useMemo(() => {
const now = new Date();
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
const tomorrowMorning = new Date(now);
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
tomorrowMorning.setHours(8, 0, 0, 0);
return [
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) },
{ label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },
{ label: t('email_composer.schedule_tomorrow', 'Tomorrow, 8:00 AM'), date: tomorrowMorning },
];
}, [t, scheduleSheetOpen]);
const startCustomPicker = () => {
customDraftRef.current = new Date(Date.now() + 3600 * 1000);
setScheduleSheetOpen(false);
setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date');
};
const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => {
if (event.type === 'dismissed' || !selected) {
setCustomStage(null);
return;
}
if (Platform.OS === 'ios') {
// Single spinner — keep it open, just remember the latest value.
customDraftRef.current = selected;
return;
}
// Android: combine the date step with the existing time, then ask for time.
if (customStage === 'date') {
const d = new Date(customDraftRef.current);
d.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
customDraftRef.current = d;
setCustomStage('time');
return;
}
if (customStage === 'time') {
const d = new Date(customDraftRef.current);
d.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
setCustomStage(null);
onScheduleConfirm(d);
}
};
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
if (!canSend || !primaryIdentity || !sentMailbox) return;
if (!(await passesAttachmentReminder())) return;
setSending(true);
@@ -775,7 +872,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const outgoing = [...inlineFromBody, ...fileAttachments];
await sendEmail(
const result = await sendEmail(
{
from,
to: finalTo.map((r) => ({ name: r.name || undefined, email: r.email })),
@@ -793,7 +890,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
},
primaryIdentity.id,
sentMailbox.id,
holdForSeconds,
);
// Confirm an explicit "send later" so the user knows it didn't go out
// now. The brief undo-send delay stays silent — it's meant to be
// invisible unless the user cancels from the Scheduled view.
if (scheduledAt && result.scheduled) {
const when = result.sendAt ? new Date(result.sendAt) : scheduledAt;
Alert.alert(
t('email_composer.scheduled_title', 'Scheduled'),
t('email_composer.scheduled_body', 'Your message will be sent at {time}.').replace(
'{time}',
when.toLocaleString(),
),
);
}
navigation.goBack();
} catch (e) {
Alert.alert(
@@ -828,10 +939,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
>
<Paperclip size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setScheduleSheetOpen(true)}
style={styles.headerBtn}
hitSlop={8}
disabled={!canSend}
>
<Clock size={20} color={canSend ? c.text : c.textMuted} />
</Pressable>
<Button
variant="default"
size="sm"
onPress={() => { void onSend(); }}
onPress={onSend}
disabled={!canSend}
icon={
sending ? (
@@ -1083,6 +1202,93 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</View>
</Modal>
{/* Schedule send sheet */}
<Modal
visible={scheduleSheetOpen}
transparent
animationType="fade"
onRequestClose={() => setScheduleSheetOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
{!jmapClient.hasDelayedSend() && (
<Text style={styles.scheduleWarning}>
{t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.')}
</Text>
)}
{schedulePresets.map((preset) => (
<Pressable
key={preset.label}
style={styles.scheduleRow}
onPress={() => onScheduleConfirm(preset.date)}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>{preset.label}</Text>
<Text style={styles.scheduleRowTime}>
{preset.date.toLocaleString(undefined, {
weekday: 'short', hour: 'numeric', minute: '2-digit',
})}
</Text>
</Pressable>
))}
<Pressable style={styles.scheduleRow} onPress={startCustomPicker}>
<Check size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>
{t('email_composer.schedule_custom', 'Pick date & time…')}
</Text>
</Pressable>
<Pressable
style={styles.scheduleCancel}
onPress={() => setScheduleSheetOpen(false)}
>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
{customStage !== null && Platform.OS === 'ios' && (
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<DateTimePicker
value={customDraftRef.current}
mode="datetime"
display="spinner"
minimumDate={new Date()}
onChange={onCustomPickerChange}
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={() => setCustomStage(null)}>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
<Pressable
style={styles.modalConfirm}
onPress={() => { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
>
<Text style={styles.modalConfirmText}>
{t('email_composer.schedule_send', 'Schedule send')}
</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
)}
{customStage !== null && Platform.OS !== 'ios' && (
<DateTimePicker
value={customDraftRef.current}
mode={customStage === 'time' ? 'time' : 'date'}
display="default"
minimumDate={customStage === 'date' ? new Date() : undefined}
onChange={onCustomPickerChange}
/>
)}
</SafeAreaView>
);
}
@@ -1302,5 +1508,34 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.primary,
},
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
scheduleCard: {
width: '100%',
maxWidth: 420,
backgroundColor: c.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
padding: spacing.lg,
gap: spacing.xs,
},
scheduleWarning: {
...typography.caption,
color: c.error,
marginBottom: spacing.xs,
},
scheduleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
scheduleRowLabel: { ...typography.body, color: c.text, flex: 1 },
scheduleRowTime: { ...typography.caption, color: c.textMuted },
scheduleCancel: {
alignItems: 'center',
paddingVertical: spacing.sm,
marginTop: spacing.xs,
},
});
}
+25 -1
View File
@@ -12,7 +12,7 @@ import {
ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages,
Clock, KeyRound,
Clock, KeyRound, FolderInput,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -27,6 +27,7 @@ import { contactToVCard } from '../lib/vcard';
import SenderAvatar from '../components/SenderAvatar';
import Dialog from '../components/Dialog';
import { ContactActivity } from '../components/contacts/ContactActivity';
import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -84,9 +85,11 @@ export default function ContactDetailScreen() {
const allContacts = useContactsStore((s) => s.contacts);
const deleteContact = useContactsStore((s) => s.deleteContact);
const createContact = useContactsStore((s) => s.createContact);
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [moreOpen, setMoreOpen] = React.useState(false);
const [moveOpen, setMoveOpen] = React.useState(false);
if (!contact) {
return (
@@ -232,12 +235,26 @@ export default function ContactDetailScreen() {
}
};
const doMove = async (bookId: string) => {
setMoveOpen(false);
try {
await moveContactsToAddressBook([contact.id], bookId);
} catch (err) {
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const moreItems = [
!isGroup(contact) && {
icon: <Copy size={16} color={c.text} />,
label: 'Duplicate',
onPress: () => { void doDuplicate(); },
},
addressBooks.length > 0 && {
icon: <FolderInput size={16} color={c.text} />,
label: 'Move to address book',
onPress: () => setMoveOpen(true),
},
{
icon: <Share2 size={16} color={c.text} />,
label: 'Export vCard',
@@ -614,6 +631,13 @@ export default function ContactDetailScreen() {
items={moreItems}
onClose={() => setMoreOpen(false)}
/>
<AddressBookPickerSheet
visible={moveOpen}
onClose={() => setMoveOpen(false)}
currentBookId={bookIds[0] ?? null}
onPick={(id) => { void doMove(id); }}
/>
</SafeAreaView>
);
}
+106 -2
View File
@@ -9,12 +9,13 @@ import {
ActivityIndicator,
RefreshControl,
ScrollView,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -25,7 +26,12 @@ import {
type ContactCategory,
} from '../stores/contacts-store';
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
import { ContactListRow } from '../components/contacts';
import {
ContactListRow,
AddressBookPickerSheet,
ContactImportSheet,
TagAssignSheet,
} from '../components/contacts';
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
@@ -91,6 +97,8 @@ export default function ContactsScreen() {
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
@@ -99,6 +107,11 @@ export default function ContactsScreen() {
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [selection, setSelection] = React.useState<Set<string>>(new Set());
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
const [moveSheetOpen, setMoveSheetOpen] = React.useState(false);
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
const [importOpen, setImportOpen] = React.useState(false);
const [importTargetOpen, setImportTargetOpen] = React.useState(false);
const [importTargetBookId, setImportTargetBookId] = React.useState<string | null>(null);
React.useEffect(() => {
void hydrate();
@@ -183,6 +196,45 @@ export default function ContactsScreen() {
await bulkDelete(ids);
};
// Default target for moves/imports: the currently-viewed address book, else
// the first available book.
const defaultBookId = React.useMemo(() => {
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
return addressBooks[0]?.id ?? null;
}, [selectedCategory, addressBooks]);
const handleBulkMove = async (bookId: string) => {
const ids = Array.from(selection);
setMoveSheetOpen(false);
clearSelection();
try {
await moveContactsToAddressBook(ids, bookId);
} catch (err) {
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const handleBulkTag = async (keyword: string) => {
const ids = Array.from(selection);
setTagSheetOpen(false);
clearSelection();
try {
await addKeywordToContacts(ids, keyword);
} catch (err) {
Alert.alert('Tagging failed', err instanceof Error ? err.message : 'Unknown error');
}
};
const openImport = () => {
setImportTargetBookId(defaultBookId);
setImportOpen(true);
};
const importTargetName = React.useMemo(
() => addressBooks.find((b) => b.id === importTargetBookId)?.name,
[addressBooks, importTargetBookId],
);
const title = categoryTitle(selectedCategory, addressBooks, groups);
// Top chip row: All / per-address-book / Groups
@@ -234,6 +286,22 @@ export default function ContactsScreen() {
</Pressable>
<Text style={styles.headerTitle}>{selection.size} selected</Text>
<View style={styles.headerActions}>
{addressBooks.length > 0 && (
<Pressable
onPress={() => setMoveSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<FolderInput size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => setTagSheetOpen(true)}
style={styles.headerIconBtn}
hitSlop={8}
>
<Tag size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setConfirmBulkDelete(true)}
style={styles.headerIconBtn}
@@ -258,6 +326,13 @@ export default function ContactsScreen() {
>
<Search size={20} color={c.text} />
</Pressable>
<Pressable
onPress={openImport}
style={styles.headerIconBtn}
hitSlop={8}
>
<Upload size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => navigation.navigate('ContactForm', {})}
style={styles.addBtn}
@@ -380,6 +455,35 @@ export default function ContactsScreen() {
onConfirm={doBulkDelete}
onCancel={() => setConfirmBulkDelete(false)}
/>
<AddressBookPickerSheet
visible={moveSheetOpen}
onClose={() => setMoveSheetOpen(false)}
onPick={(id) => { void handleBulkMove(id); }}
/>
<TagAssignSheet
visible={tagSheetOpen}
onClose={() => setTagSheetOpen(false)}
onPick={(kw) => { void handleBulkTag(kw); }}
/>
<ContactImportSheet
visible={importOpen}
onClose={() => setImportOpen(false)}
targetBookId={importTargetBookId}
targetBookName={importTargetName}
onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
onImported={() => { void fetchContacts(); }}
/>
<AddressBookPickerSheet
visible={importTargetOpen}
onClose={() => setImportTargetOpen(false)}
currentBookId={importTargetBookId}
title="Import into address book"
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
/>
</SafeAreaView>
);
}
+74 -2
View File
@@ -5,6 +5,7 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag,
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -13,11 +14,13 @@ import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
import { TagSheet } from '../components/TagSheet';
import { UndoSnackbar } from '../components/UndoSnackbar';
import { OfflineBanner } from '../components/OfflineBanner';
import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useKeywordsStore } from '../stores/keywords-store';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
@@ -191,6 +194,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
const archiveEmailsBatch = useEmailStore((s) => s.archiveEmailsBatch);
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
@@ -240,6 +252,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
// Move-to-folder picker triggered by the swipe action.
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
// Batch (multi-select) sheets.
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
@@ -385,11 +400,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
clearSelection();
return;
}
const ids = Array.from(selectedIds);
await Promise.all(ids.map((id) => deleteEmailAction(id, trash.id, currentMailboxId)));
await deleteEmailsBatch(Array.from(selectedIds), trash.id, currentMailboxId);
clearSelection();
};
const handleBulkArchive = async () => {
await archiveEmailsBatch(Array.from(selectedIds));
clearSelection();
};
const canArchiveSelection =
archiveMailboxId != null && currentMailboxId !== archiveMailboxId;
const handleBatchMovePick = (toId: string) => {
const ids = Array.from(selectedIds);
setBatchMoveOpen(false);
clearSelection();
void moveEmailsToMailbox(ids, toId);
};
const handleBatchTagToggle = (token: string, on: boolean) => {
void setKeywordForEmails(Array.from(selectedIds), token, on);
};
// Local input state for uninterrupted typing; debounce into the store.
const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
React.useEffect(() => {
@@ -486,6 +519,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<MailOpen size={20} color={c.text} />
)}
</Pressable>
<Pressable
onPress={() => setTagSheetOpen(true)}
style={styles.headerButton}
hitSlop={6}
>
<Tag size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setBatchMoveOpen(true)}
style={styles.headerButton}
hitSlop={6}
>
<FolderInput size={20} color={c.text} />
</Pressable>
{canArchiveSelection && (
<Pressable
onPress={() => { void handleBulkArchive(); }}
style={styles.headerButton}
hitSlop={6}
>
<Archive size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => { void handleBulkDelete(); }}
style={styles.headerButton}
@@ -891,6 +947,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}}
/>
<MoveSheet
visible={batchMoveOpen}
onClose={() => setBatchMoveOpen(false)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
onPick={handleBatchMovePick}
/>
<TagSheet
visible={tagSheetOpen}
onClose={() => setTagSheetOpen(false)}
keywords={keywordDefs}
selectedEmails={selectedEmails}
onToggle={handleBatchTagToggle}
/>
<UndoSnackbar />
</SafeAreaView>
);
+4
View File
@@ -14,6 +14,7 @@ import {
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import EmailBodyView from '../components/EmailBodyView';
import { CalendarInvitationBanner } from '../components/email/CalendarInvitationBanner';
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
@@ -434,6 +435,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Calendar invitation (auto-detected .ics) */}
<CalendarInvitationBanner email={email} />
{/* Body */}
<View style={styles.bodyBlock}>
<EmailBodyView email={email} senderEmail={from?.email} />
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -1,12 +1,15 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { Eye, EyeOff, X } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button, Input } from '../components';
import { useAuthStore } from '../stores/auth-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
interface LoginScreenProps {
onLogin?: () => void;
isAddMode?: boolean;
@@ -17,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
@@ -27,6 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const [showPassword, setShowPassword] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
const handleLogin = async () => {
if (!canSubmit) {
@@ -41,6 +46,25 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
}
};
const handleWebmailHandoff = async () => {
if (!canHandoff) return;
try {
const before = useAuthStore.getState().isAuthenticated;
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
// loginViaWebmail swallows the "user cancelled the browser" case
// silently — only fire the navigation hook when authentication
// actually completed.
const after = useAuthStore.getState().isAuthenticated;
if (after && !before) {
onLogin?.();
} else if (isAddMode && after) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -146,17 +170,24 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
<View style={styles.dividerLine} />
</View>
{/* OAuth buttons */}
{/* Webmail-mediated login */}
<View style={styles.oauthRow}>
<Button variant="outline" size="md" disabled>
SSO Provider
<Button
variant="outline"
size="md"
onPress={() => {
void handleWebmailHandoff();
}}
disabled={!canHandoff || isLoading}
>
Sign in via webmail
</Button>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
<Text style={styles.footerLink}>Privacy Policy</Text>
</View>
</View>
+196
View File
@@ -0,0 +1,196 @@
import React from 'react';
import {
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft, Clock, X } from 'lucide-react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import {
listScheduledEmails,
cancelScheduledSend,
type ScheduledEmail,
} from '../api/email';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
function recipientLabel(item: ScheduledEmail): string {
const first = item.to?.[0];
if (!first) return '(no recipient)';
const name = first.name || first.email;
const extra = (item.to?.length ?? 0) - 1;
return extra > 0 ? `${name} +${extra}` : name;
}
export default function ScheduledScreen({ navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [items, setItems] = React.useState<ScheduledEmail[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [cancelling, setCancelling] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setItems(await listScheduledEmails());
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load scheduled messages');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const onCancel = (item: ScheduledEmail) => {
Alert.alert(
'Cancel scheduled send?',
'The message will not be delivered. A copy stays in your Sent folder.',
[
{ text: 'Keep scheduled', style: 'cancel' },
{
text: 'Cancel send',
style: 'destructive',
onPress: () => {
setCancelling(item.emailSubmissionId);
void (async () => {
try {
await cancelScheduledSend(item.emailSubmissionId);
setItems((prev) =>
prev.filter((i) => i.emailSubmissionId !== item.emailSubmissionId),
);
} catch (e) {
Alert.alert('Cancel failed', e instanceof Error ? e.message : String(e));
} finally {
setCancelling(null);
}
})();
},
},
],
);
};
const renderItem = ({ item }: { item: ScheduledEmail }) => (
<View style={styles.row}>
<View style={styles.rowMain}>
<View style={styles.sendAtRow}>
<Clock size={13} color={c.primary} />
<Text style={styles.sendAt}>{new Date(item.sendAt).toLocaleString()}</Text>
</View>
<Text style={styles.subject} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
<Text style={styles.recipient} numberOfLines={1}>
To: {recipientLabel(item)}
</Text>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
<Pressable
onPress={() => onCancel(item)}
style={styles.cancelBtn}
hitSlop={8}
disabled={cancelling === item.emailSubmissionId}
>
{cancelling === item.emailSubmissionId ? (
<ActivityIndicator size="small" color={c.error} />
) : (
<X size={18} color={c.error} />
)}
</Pressable>
</View>
);
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>Scheduled</Text>
<View style={styles.headerBtn} />
</View>
{loading ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
</View>
) : error ? (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Pressable onPress={() => { void load(); }}>
<Text style={styles.retry}>Retry</Text>
</Pressable>
</View>
) : items.length === 0 ? (
<View style={styles.center}>
<Clock size={40} color={c.textMuted} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No scheduled messages</Text>
<Text style={styles.emptyHint}>
Use the clock icon in the composer to send a message later.
</Text>
</View>
) : (
<FlatList
data={items}
keyExtractor={(i) => i.emailSubmissionId}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
refreshing={loading}
onRefresh={() => { void load(); }}
/>
)}
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
error: { ...typography.body, color: c.error, textAlign: 'center' },
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
emptyText: { ...typography.body, color: c.textSecondary },
emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center', maxWidth: 280 },
listContent: { paddingVertical: spacing.sm },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
rowMain: { flex: 1, minWidth: 0, gap: 2 },
sendAtRow: { flexDirection: 'row', alignItems: 'center', gap: 5 },
sendAt: { ...typography.caption, color: c.primary, fontWeight: '600' },
subject: { ...typography.bodyMedium, color: c.text },
recipient: { ...typography.caption, color: c.textSecondary },
preview: { ...typography.caption, color: c.textMuted },
cancelBtn: {
width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm,
},
});
}
+35 -10
View File
@@ -36,6 +36,7 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -155,6 +156,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
// Subscribe to locale so labels re-render when the user picks a different language.
const locale = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t);
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const unavailableTabs = React.useMemo(() => {
const set = new Set<Tab>();
if (!hasCalendar) set.add('calendar');
if (!hasContacts) set.add('contacts');
if (!hasFiles) set.add('files');
return set;
}, [hasCalendar, hasContacts, hasFiles]);
const groupedTabs = React.useMemo(() => {
void locale; // dependency: re-translate on locale change
return groupTabs().map((g) => ({
@@ -178,6 +189,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
if (unavailableTabs.has(tab.id)) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
@@ -210,14 +222,20 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title.
Settings is a tab root, so the back button is only rendered when a
parent passes onBack (it'd otherwise lead nowhere). */}
<View style={styles.header}>
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={c.text} />
</Pressable>
{onBack ? (
<Pressable
onPress={onBack}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={c.text} />
</Pressable>
) : (
<View style={styles.headerLeftSpacer} />
)}
<Settings size={20} color={c.mutedForeground} />
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
</View>
@@ -235,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}
@@ -263,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} />
@@ -310,6 +334,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.md,
},
headerBackBtnPressed: { backgroundColor: c.accent },
headerLeftSpacer: { width: spacing.sm },
headerTitle: { ...typography.h3, color: c.text },
scrollArea: { flex: 1 },
+234
View File
@@ -0,0 +1,234 @@
import React from 'react';
import {
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft, Star, Paperclip, AlertTriangle } from 'lucide-react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import SenderAvatar from '../components/SenderAvatar';
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
function senderName(email: UnifiedEmail): string {
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
}
function formatRelativeTime(date: Date): string {
const diffMin = Math.floor((Date.now() - date.getTime()) / 60000);
if (diffMin < 1) return 'now';
if (diffMin < 60) return `${diffMin}m`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay}d`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function UnifiedInboxScreen({ navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accounts = useAccountStore((s) => s.accounts);
const switchAccount = useAuthStore((s) => s.switchAccount);
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [loading, setLoading] = React.useState(true);
const [opening, setOpening] = React.useState(false);
const accountById = React.useMemo(
() => new Map(accounts.map((a) => [a.id, a])),
[accounts],
);
const accountIds = React.useMemo(() => accounts.map((a) => a.id), [accounts]);
const load = React.useCallback(async () => {
setLoading(true);
try {
const result = await fetchUnifiedInbox(accountIds);
setEmails(result.emails);
setErrors(result.errors);
} finally {
setLoading(false);
}
}, [accountIds]);
React.useEffect(() => {
void load();
}, [load]);
const onOpen = React.useCallback(
(email: UnifiedEmail) => {
if (opening) return;
setOpening(true);
void (async () => {
try {
const auth = useAuthStore.getState();
if (auth.activeAccountId !== email.sourceAccountId) {
await switchAccount(email.sourceAccountId);
if (useAuthStore.getState().activeAccountId !== email.sourceAccountId) return;
}
navigation.navigate('EmailThread', {
emailId: email.id,
threadId: email.threadId,
subject: email.subject,
});
} finally {
setOpening(false);
}
})();
},
[opening, switchAccount, navigation],
);
const errorCount = Object.keys(errors).length;
const renderItem = ({ item }: { item: UnifiedEmail }) => {
const acc = accountById.get(item.sourceAccountId);
const unread = !item.keywords?.$seen;
const starred = !!item.keywords?.$flagged;
return (
<Pressable
onPress={() => onOpen(item)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
>
<View style={styles.avatarWrap}>
<SenderAvatar
name={senderName(item)}
email={item.from?.[0]?.email}
size={componentSizes.avatarMd}
/>
{acc && <View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />}
</View>
<View style={styles.content}>
<View style={styles.line}>
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
{senderName(item)}
</Text>
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.time}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
<View style={styles.line}>
{acc && (
<Text style={styles.account} numberOfLines={1}>
{acc.email || acc.username}
</Text>
)}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
) : null}
</View>
</Pressable>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>All inboxes</Text>
<View style={styles.headerBtn}>
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
</View>
</View>
{errorCount > 0 && (
<View style={styles.errorBanner}>
<AlertTriangle size={14} color={c.error} />
<Text style={styles.errorBannerText} numberOfLines={1}>
{errorCount === 1
? '1 account could not be loaded'
: `${errorCount} accounts could not be loaded`}
</Text>
</View>
)}
{loading && emails.length === 0 ? (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
<Text style={styles.loadingText}>Loading all inboxes…</Text>
</View>
) : emails.length === 0 ? (
<View style={styles.center}>
<Text style={styles.loadingText}>No mail across your accounts</Text>
</View>
) : (
<FlatList
data={emails}
keyExtractor={(item) => `${item.sourceAccountId}:${item.id}`}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshing={loading}
onRefresh={() => { void load(); }}
/>
)}
</SafeAreaView>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: c.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
errorBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.errorBg,
},
errorBannerText: { ...typography.caption, color: c.error, flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm },
loadingText: { ...typography.body, color: c.textSecondary },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
row: {
flexDirection: 'row',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
rowPressed: { backgroundColor: c.surfaceHover },
avatarWrap: { position: 'relative' },
accountDot: {
position: 'absolute',
right: -2, bottom: -2,
width: 12, height: 12,
borderRadius: 6,
borderWidth: 2,
borderColor: c.background,
},
content: { flex: 1, minWidth: 0, gap: 1 },
line: { flexDirection: 'row', alignItems: 'center', gap: 6 },
sender: { ...typography.bodyMedium, color: c.text, flex: 1 },
bold: { fontWeight: '700' },
time: { ...typography.caption, color: c.textMuted },
subject: { ...typography.body, color: c.text },
account: { ...typography.caption, color: c.textMuted },
preview: { ...typography.caption, color: c.textMuted },
});
}
+58 -20
View File
@@ -1,42 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(),
getItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connect: vi.fn(),
logout: vi.fn(),
restoreSession: vi.fn(),
loadAccount: vi.fn(),
consumeLegacyCredentials: vi.fn(async () => null),
clearAccountCredentials: vi.fn(async () => undefined),
clearAllCredentials: vi.fn(async () => undefined),
reset: vi.fn(),
accountId: 'acc-1',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'user',
serverUrl: 'https://mail.example.com',
},
AuthenticationError: class AuthenticationError extends Error {
constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; }
},
NetworkError: class NetworkError extends Error {
constructor(msg: string) { super(msg); this.name = 'NetworkError'; }
},
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
const mockRestore = jmapClient.restoreSession as ReturnType<typeof vi.fn>;
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
function resetAccountStore(): void {
useAccountStore.setState({
accounts: [],
activeAccountId: null,
defaultAccountId: null,
});
}
beforeEach(() => {
vi.clearAllMocks();
// Reset store state
resetAccountStore();
useAuthStore.setState({
isAuthenticated: false,
isLoading: false,
hasRestoredSession: false,
error: null,
serverUrl: null,
username: null,
session: null,
accountId: null,
activeAccountId: null,
client: null,
});
});
@@ -83,7 +104,7 @@ describe('auth-store', () => {
});
describe('logout', () => {
it('should reset all state', async () => {
it('should reset all state when no other accounts remain', async () => {
useAuthStore.setState({ isAuthenticated: true, serverUrl: 'x', username: 'y' });
mockLogout.mockResolvedValue(undefined);
@@ -97,23 +118,40 @@ describe('auth-store', () => {
});
describe('restoreSession', () => {
it('should restore on success', async () => {
mockRestore.mockResolvedValue(true);
it('should return false when there is no registered account', async () => {
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(false);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().hasRestoredSession).toBe(true);
});
it('should restore when loadAccount succeeds for the active account', async () => {
useAccountStore.setState({
accounts: [
{
id: 'acc-1',
serverUrl: 'https://mail.example.com',
username: 'user',
displayName: 'user',
email: 'user',
avatarColor: '#000',
lastLoginAt: 0,
isConnected: false,
hasError: false,
isDefault: true,
},
],
activeAccountId: 'acc-1',
defaultAccountId: 'acc-1',
});
mockLoadAccount.mockResolvedValue(true);
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('should return false when no session to restore', async () => {
mockRestore.mockResolvedValue(false);
const restored = await useAuthStore.getState().restoreSession();
expect(restored).toBe(false);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
describe('clearError', () => {
+4 -1
View File
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
},
}));
@@ -271,7 +272,9 @@ describe('calendar-store', () => {
{ title: 'New' } as any,
);
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' });
// The third arg is the iMIP scheduling flag — undefined here since the
// event has no participants to notify.
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined);
});
});
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
},
}));
+64 -7
View File
@@ -2,8 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/email', () => ({
getMailboxes: vi.fn(),
// New helpers used by the incremental-sync path. Default behavior: behave
// like a first-ever load — no prior state, full re-query expected.
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
getMailboxChanges: vi.fn(async () => null),
queryEmails: vi.fn(),
getEmailQueryChanges: vi.fn(async () => null),
getEmails: vi.fn(),
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
getEmailChanges: vi.fn(async () => null),
getFullEmail: vi.fn(),
setEmailKeywords: vi.fn(),
moveEmail: vi.fn(),
@@ -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();
});
});
+257 -25
View File
@@ -1,12 +1,16 @@
import { create } from 'zustand';
import { jmapClient, AuthenticationError } from '../api/jmap-client';
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
import type { JMAPSession } from '../api/types';
import { useAccountStore } from './account-store';
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 { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
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
@@ -35,15 +39,37 @@ export interface AuthState {
client: typeof jmapClient | null;
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
restoreSession: () => Promise<boolean>;
retrySession: () => Promise<boolean>;
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();
}
@@ -104,11 +130,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);
@@ -125,6 +153,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) {
@@ -138,13 +169,83 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginViaWebmail: async (webmailUrl, opts) => {
set({ isLoading: true, error: null });
let result;
try {
result = await runWebmailHandoff(webmailUrl);
} catch (err) {
if (err instanceof HandoffCancelledError) {
// User closed the browser tab — quiet exit, no error banner.
set({ isLoading: false, error: null });
return;
}
const message = err instanceof Error ? err.message : 'Sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
if (result.flow === 'password') {
// Hand the credentials to the existing password login path so account
// registration + feature-store wiring all behave identically to a
// manual sign-in.
await get().login(result.serverUrl, result.username, result.password, opts);
return;
}
// OAuth — the webmail did the dance against Stalwart and handed us a
// token bundle. Bootstrap the JMAP session with Bearer auth and let
// ensure/forceRefreshToken keep it alive going forward.
try {
if (opts?.addAccount && get().isAuthenticated) {
jmapClient.reset();
useContactsStore.getState().reset();
useCalendarStore.getState().reset();
}
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState();
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
});
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'OAuth sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
},
logout: async () => {
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) {
@@ -155,7 +256,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
jmapClient.reset();
resetFeatureStores();
clearAccountFeatureStores(currentId);
// Switch to next remaining account, if any
const remaining = accountStore.accounts;
@@ -189,7 +290,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);
@@ -216,14 +317,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;
}
@@ -286,15 +423,64 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return false;
}
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
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;
}
} catch (err) {
if (err instanceof NetworkError) {
// Server unreachable. Keep credentials, mark account offline, and
// surface the cached UI so the user can still browse persisted
// mail / contacts / calendar. The login screen would lose their
// settings without recourse, which is the bug we're fixing here.
accountStore.setActiveAccount(target.id);
accountStore.updateAccount(target.id, {
isConnected: false,
hasError: true,
errorMessage: err.message,
});
set({
isAuthenticated: true,
isLoading: false,
hasRestoredSession: true,
error: null,
serverUrl: target.serverUrl,
username: target.username,
session: null,
accountId: null,
activeAccountId: target.id,
client: jmapClient,
});
return true;
}
if (err instanceof AuthenticationError) {
// 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;
}
throw err;
}
accountStore.setActiveAccount(target.id);
accountStore.updateAccount(target.id, { isConnected: true, hasError: false });
accountStore.updateAccount(target.id, {
isConnected: true,
hasError: false,
errorMessage: undefined,
});
const session = jmapClient.currentSession!;
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
@@ -309,5 +495,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
// Re-attempt session establishment for the currently active account
// without disturbing UI state on failure. Used by the network-recovery
// watcher and any explicit "retry" button. Idempotent: returns true if
// a session is already live.
retrySession: async () => {
const { activeAccountId, session } = get();
if (!activeAccountId) return false;
if (session) return true;
const accountStore = useAccountStore.getState();
const target = accountStore.getAccountById(activeAccountId);
if (!target) return false;
try {
const ok = await jmapClient.loadAccount(activeAccountId);
if (!ok) return false;
accountStore.updateAccount(activeAccountId, {
isConnected: true,
hasError: false,
errorMessage: undefined,
});
const fresh = jmapClient.currentSession!;
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
refetchFeatureStores();
return true;
} catch (err) {
if (err instanceof AuthenticationError) {
// Now we know the credentials are bad — fall back to logout flow.
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
accountStore.removeAccount(activeAccountId);
set({
isAuthenticated: false,
isLoading: false,
hasRestoredSession: true,
error: 'Session expired',
serverUrl: null,
username: null,
session: null,
accountId: null,
activeAccountId: null,
client: null,
});
}
// NetworkError or anything else: stay where we are.
return false;
}
},
clearError: () => set({ error: null }),
}));
+129 -6
View File
@@ -9,10 +9,19 @@ import {
createEvent as apiCreateEvent,
updateEvent as apiUpdateEvent,
deleteEvents as apiDeleteEvents,
batchCreateEvents as apiBatchCreateEvents,
rsvpEvent as apiRsvpEvent,
createCalendar as apiCreateCalendar,
} from '../api/calendar';
import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion';
// Does the event carry attendees the server should notify over iMIP? Used to
// decide whether to set sendSchedulingMessages on create/update/delete.
function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boolean {
return !!event?.participants && Object.keys(event.participants).length > 0;
}
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
export interface LoadedRange {
@@ -23,6 +32,7 @@ export interface LoadedRange {
export interface CalendarState {
calendars: Calendar[];
events: CalendarEvent[];
tasks: CalendarEvent[];
hiddenCalendarIds: string[];
loadedRange: LoadedRange | null;
loading: boolean;
@@ -38,6 +48,20 @@ export interface CalendarState {
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
updateEvent: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
deleteEvent: (id: string) => Promise<void>;
// Invitations / scheduling
rsvpEvent: (
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
) => Promise<void>;
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
createCalendar: (name: string, color?: string) => Promise<Calendar>;
// Tasks
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
updateTask: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
toggleTaskComplete: (id: string) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
toggleCalendarVisibility: (id: string) => void;
setCalendarHidden: (id: string, hidden: boolean) => void;
reset: () => void;
@@ -66,6 +90,7 @@ export const useCalendarStore = create<CalendarState>()(
(set, get) => ({
calendars: [],
events: [],
tasks: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -89,6 +114,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,18 +128,21 @@ 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)) ?? [];
const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : [];
// Stalwart returns both Events and Tasks from CalendarEvent/query; tasks
// lack `start` and must be filtered out client-side.
// Stalwart returns both Events and Tasks from CalendarEvent/query. Events
// have a `start`; tasks are surfaced separately so they don't pollute the
// grid (they're shown in the task list with their due date instead).
const onlyEvents = raw.filter((e) => {
const t = (e as { '@type'?: string })['@type'];
return (t === 'Event' || t === undefined) && !!e.start;
});
const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task');
const events = expandRecurringEvents(onlyEvents, after, before);
set({ events, loadedRange: { after, before }, loading: false });
set({ events, tasks, loadedRange: { after, before }, loading: false });
} catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
@@ -142,6 +175,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;
@@ -159,7 +193,11 @@ export const useCalendarStore = create<CalendarState>()(
},
createEvent: async (event, calendarId) => {
const created = await apiCreateEvent(event, calendarId);
const created = await apiCreateEvent(
event,
calendarId,
hasSchedulingParticipants(event) ? true : undefined,
);
set({ events: [...get().events, created] });
return created;
},
@@ -168,7 +206,11 @@ export const useCalendarStore = create<CalendarState>()(
// Resolve client-side expanded occurrence IDs back to the master event ID.
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
await apiUpdateEvent(realId, changes);
// Notify attendees if either the stored event or the incoming changes
// carry participants.
const schedule =
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
await apiUpdateEvent(realId, changes, schedule ? true : undefined);
set({
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)),
});
@@ -177,10 +219,89 @@ export const useCalendarStore = create<CalendarState>()(
deleteEvent: async (id) => {
const storeEvent = get().events.find((e) => e.id === id);
const realId = storeEvent?.originalId || id;
await apiDeleteEvents([realId]);
await apiDeleteEvents(
[realId],
hasSchedulingParticipants(storeEvent) ? true : undefined,
);
set({ events: get().events.filter((e) => e.id !== id) });
},
rsvpEvent: async (eventId, participantId, status, replyTo) => {
if (!participantId || participantId.includes('..')) {
throw new Error('Invalid participant ID');
}
const storeEvent = get().events.find((e) => e.id === eventId);
const realId = storeEvent?.originalId || eventId;
await apiRsvpEvent(realId, participantId, status, replyTo);
set({
events: get().events.map((e) => {
if (e.id !== eventId || !e.participants?.[participantId]) return e;
return {
...e,
participants: {
...e.participants,
[participantId]: { ...e.participants[participantId], participationStatus: status },
},
};
}),
});
},
importEvents: async (events, calendarId) => {
if (events.length === 0) return 0;
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
// already exists; create the rest. (We don't attempt cross-calendar linking
// on mobile — a duplicate is simply skipped.)
let toCreate = events;
try {
const existingIds = await queryEvents([], '', '');
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
} catch {
// Couldn't dedupe — proceed and let the server reject genuine dupes.
}
if (toCreate.length === 0) return 0;
const count = await apiBatchCreateEvents(toCreate, calendarId);
await get().refresh();
return count;
},
createCalendar: async (name, color) => {
const created = await apiCreateCalendar(name, color);
set({ calendars: [...get().calendars, created] });
return created;
},
createTask: async (task, calendarId) => {
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId);
await get().refresh();
},
updateTask: async (id, changes) => {
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiUpdateEvent(realId, changes);
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
},
toggleTaskComplete: async (id) => {
const task = get().tasks.find((t) => t.id === id);
if (!task) return;
const completed = task.progress === 'completed';
const next: Partial<CalendarEvent> = completed
? { progress: 'in-process', percentComplete: 0 }
: { progress: 'completed', percentComplete: 100 };
await get().updateTask(id, next);
},
deleteTask: async (id) => {
const task = get().tasks.find((t) => t.id === id);
const realId = task?.originalId || id;
await apiDeleteEvents([realId]);
set({ tasks: get().tasks.filter((t) => t.id !== id) });
},
toggleCalendarVisibility: (id) => {
const { hiddenCalendarIds } = get();
const next = hiddenCalendarIds.includes(id)
@@ -204,6 +325,7 @@ export const useCalendarStore = create<CalendarState>()(
reset: () => set({
calendars: [],
events: [],
tasks: [],
loadedRange: null,
loading: false,
error: null,
@@ -218,6 +340,7 @@ export const useCalendarStore = create<CalendarState>()(
partialize: (state) => ({
calendars: state.calendars,
events: state.events,
tasks: state.tasks,
loadedRange: state.loadedRange,
}),
},
+168
View File
@@ -0,0 +1,168 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { CalendarEvent } from '../api/types';
import {
createCalendar,
deleteCalendar,
parseCalendarBlob,
queryEvents,
getEvents,
deleteEvents,
} from '../api/calendar';
import { uploadBytes } from '../api/blob';
import { useCalendarStore } from './calendar-store';
export interface CalendarSubscription {
id: string;
name: string;
url: string;
color?: string;
/** The local Stalwart calendar that mirrors this remote feed. */
calendarId: string;
lastSyncAt: number | null;
lastError: string | null;
}
interface SubscriptionsState {
subscriptions: CalendarSubscription[];
syncing: Record<string, boolean>;
addSubscription: (input: { name: string; url: string; color?: string }) => Promise<CalendarSubscription>;
removeSubscription: (id: string) => Promise<void>;
syncSubscription: (id: string) => Promise<void>;
syncAll: () => Promise<void>;
}
// webcal:// is just iCalendar over HTTP(S) — browsers/clients map it to https.
function normalizeFeedUrl(url: string): string {
return url.trim().replace(/^webcal:\/\//i, 'https://');
}
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
const res = await fetch(normalizeFeedUrl(url));
if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`);
const text = await res.text();
if (!/BEGIN:VCALENDAR/i.test(text)) {
throw new Error('That URL did not return an iCalendar feed');
}
const bytes = new TextEncoder().encode(text);
const { blobId } = await uploadBytes(bytes, 'text/calendar');
const events = await parseCalendarBlob(blobId);
return events.filter((e) => !!e.start);
}
// Reconcile the local calendar with the remote feed: delete events whose UID
// disappeared upstream, then import the current set (importEvents dedupes by
// UID, so unchanged events are left in place).
async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> {
const parsed = await fetchAndParseFeed(url);
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
try {
const allIds = await queryEvents([], '', '');
const all = allIds.length > 0 ? await getEvents(allIds) : [];
const stale = all
.filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid))
.map((e) => e.id);
if (stale.length > 0) await deleteEvents(stale);
} catch {
// Non-fatal: if we can't enumerate existing events, still import new ones.
}
await useCalendarStore.getState().importEvents(parsed, calendarId);
}
export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
persist(
(set, get) => ({
subscriptions: [],
syncing: {},
addSubscription: async ({ name, url, color }) => {
// Create a dedicated local calendar to hold the feed's events.
const calendar = await createCalendar(name, color);
const sub: CalendarSubscription = {
id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
name,
url: url.trim(),
color,
calendarId: calendar.id,
lastSyncAt: null,
lastError: null,
};
set({ subscriptions: [...get().subscriptions, sub] });
// Make the new calendar visible immediately.
await useCalendarStore.getState().fetchCalendars();
try {
set({ syncing: { ...get().syncing, [sub.id]: true } });
await syncFeedIntoCalendar(sub.calendarId, sub.url);
set({
subscriptions: get().subscriptions.map((s) =>
s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed';
set({
subscriptions: get().subscriptions.map((s) =>
s.id === sub.id ? { ...s, lastError: message } : s,
),
});
} finally {
set({ syncing: { ...get().syncing, [sub.id]: false } });
}
return sub;
},
removeSubscription: async (id) => {
const sub = get().subscriptions.find((s) => s.id === id);
set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
if (sub) {
try {
await deleteCalendar(sub.calendarId);
} catch {
// best-effort
}
await useCalendarStore.getState().fetchCalendars();
await useCalendarStore.getState().refresh();
}
},
syncSubscription: async (id) => {
const sub = get().subscriptions.find((s) => s.id === id);
if (!sub) return;
set({ syncing: { ...get().syncing, [id]: true } });
try {
await syncFeedIntoCalendar(sub.calendarId, sub.url);
set({
subscriptions: get().subscriptions.map((s) =>
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed';
set({
subscriptions: get().subscriptions.map((s) =>
s.id === id ? { ...s, lastError: message } : s,
),
});
} finally {
set({ syncing: { ...get().syncing, [id]: false } });
}
},
syncAll: async () => {
const subs = get().subscriptions;
for (const sub of subs) {
await get().syncSubscription(sub.id);
}
},
}),
{
name: 'calendar-subscriptions',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ subscriptions: state.subscriptions }),
},
),
);
+163
View File
@@ -9,6 +9,10 @@ import {
createContact as apiCreateContact,
updateContact as apiUpdateContact,
deleteContacts as apiDeleteContacts,
createAddressBook as apiCreateAddressBook,
updateAddressBook as apiUpdateAddressBook,
deleteAddressBook as apiDeleteAddressBook,
getContactsInBook,
} from '../api/contacts';
import { jmapClient } from '../api/jmap-client';
import {
@@ -20,6 +24,10 @@ import {
const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1';
// Dedicated JMAP address book that backs the "trusted senders" allow-list.
// Mirrors the webmail so the same book is shared across clients.
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
export type ContactCategory =
| { type: 'all' }
| { type: 'addressBook'; addressBookId: string }
@@ -35,6 +43,13 @@ export interface ContactsState {
error: string | null;
hydrated: boolean;
// Trusted senders are stored as contacts in a dedicated JMAP address book so
// the allow-list syncs across devices (matches the webmail behavior).
trustedSendersBookId: string | null;
trustedSenderEmails: string[];
trustedSendersLoaded: boolean;
trustedSendersLoading: boolean;
hydrate: () => Promise<void>;
fetchAddressBooks: () => Promise<void>;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
@@ -45,11 +60,25 @@ export interface ContactsState {
updateContact: (id: string, changes: Partial<ContactCard>) => Promise<void>;
deleteContact: (id: string) => Promise<void>;
bulkDelete: (ids: string[]) => Promise<void>;
importContacts: (contacts: Partial<ContactCard>[], addressBookId: string) => Promise<number>;
addContactKeyword: (id: string, keyword: string) => Promise<void>;
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
// Address book management
createAddressBook: (name: string) => Promise<AddressBook>;
renameAddressBook: (id: string, name: string) => Promise<void>;
deleteAddressBook: (id: string) => Promise<void>;
// Trusted senders address book. Passive loads (createIfMissing=false) only
// read an existing book; the book is created lazily on the first add.
loadTrustedSendersBook: (createIfMissing?: boolean) => Promise<void>;
addToTrustedSendersBook: (email: string) => Promise<void>;
removeFromTrustedSendersBook: (email: string) => Promise<void>;
isTrustedAddressBookSender: (email: string) => boolean;
setSelectedCategory: (category: ContactCategory) => void;
reset: () => void;
}
@@ -70,6 +99,11 @@ export const useContactsStore = create<ContactsState>()(
error: null,
hydrated: false,
trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,
trustedSendersLoading: false,
hydrate: async () => {
if (get().hydrated) return;
try {
@@ -87,6 +121,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 +134,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 +150,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;
@@ -154,6 +194,22 @@ export const useContactsStore = create<ContactsState>()(
set({ contacts: get().contacts.filter((c) => !idSet.has(c.id)) });
},
importContacts: async (incoming, addressBookId) => {
let imported = 0;
for (const contact of incoming) {
try {
// Strip the temporary client-side id; the server assigns a real one.
const { id: _id, addressBookIds: _abIds, ...data } = contact;
const created = await apiCreateContact(data, addressBookId);
set({ contacts: [...get().contacts, created] });
imported++;
} catch (err) {
console.warn('[contacts-store] import failed for one contact', err);
}
}
return imported;
},
addContactKeyword: async (id, keyword) => {
const kw = keyword.trim();
if (!kw) return;
@@ -170,12 +226,115 @@ export const useContactsStore = create<ContactsState>()(
await get().updateContact(id, { keywords: rest });
},
addKeywordToContacts: async (ids, keyword) => {
const kw = keyword.trim();
if (!kw) return;
for (const id of ids) {
await get().addContactKeyword(id, kw);
}
},
moveContactsToAddressBook: async (ids, addressBookId) => {
for (const id of ids) {
await get().updateContact(id, { addressBookIds: { [addressBookId]: true } });
}
},
createAddressBook: async (name) => {
const trimmed = name.trim();
if (!trimmed) throw new Error('Address book name is required');
const book = await apiCreateAddressBook(trimmed);
set({ addressBooks: [...get().addressBooks, book] });
return book;
},
renameAddressBook: async (id, name) => {
const trimmed = name.trim();
if (!trimmed) return;
await apiUpdateAddressBook(id, { name: trimmed });
set({
addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
});
},
deleteAddressBook: async (id) => {
await apiDeleteAddressBook(id);
set({
addressBooks: get().addressBooks.filter((b) => b.id !== id),
// Drop the deleted book id from any cached contact so the UI doesn't
// keep filtering against a book that no longer exists.
contacts: get().contacts.map((c) => {
if (!c.addressBookIds?.[id]) return c;
const { [id]: _gone, ...rest } = c.addressBookIds;
return { ...c, addressBookIds: rest };
}),
});
const category = get().selectedCategory;
if (category.type === 'addressBook' && category.addressBookId === id) {
get().setSelectedCategory({ type: 'all' });
}
},
loadTrustedSendersBook: async (createIfMissing = false) => {
if (!jmapClient.isConnected || get().trustedSendersLoading) return;
set({ trustedSendersLoading: true });
try {
const books = (await fetchAddressBooks()) ?? [];
let book = books.find((b) => b.name === TRUSTED_SENDERS_BOOK_NAME);
if (!book) {
if (!createIfMissing) {
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
return;
}
book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
}
const bookId = book.id;
const cards = await getContactsInBook(bookId);
const emails = cards.flatMap((c) =>
c.emails ? Object.values(c.emails).map((e) => e.address.toLowerCase().trim()) : [],
).filter(Boolean);
set({
trustedSendersBookId: bookId,
trustedSenderEmails: emails,
trustedSendersLoaded: true,
trustedSendersLoading: false,
});
} catch (err) {
console.warn('[contacts-store] load trusted senders failed', err);
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
}
},
addToTrustedSendersBook: async (email) => {
const normalized = email.toLowerCase().trim();
if (!normalized || get().trustedSenderEmails.includes(normalized)) return;
let bookId = get().trustedSendersBookId;
if (!bookId) {
await get().loadTrustedSendersBook(true);
bookId = get().trustedSendersBookId;
}
if (!bookId) throw new Error('Could not find or create the trusted senders address book');
await apiCreateContact({ emails: { email: { address: normalized } } }, bookId);
set({ trustedSenderEmails: [...get().trustedSenderEmails, normalized] });
},
removeFromTrustedSendersBook: async (email) => {
const normalized = email.toLowerCase().trim();
const bookId = get().trustedSendersBookId;
if (!bookId) return;
const cards = await getContactsInBook(bookId);
const match = cards.find((c) =>
c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase().trim() === normalized),
);
if (match) await apiDeleteContacts([match.id]);
set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== normalized) });
},
isTrustedAddressBookSender: (email) =>
get().trustedSenderEmails.includes(email.toLowerCase().trim()),
setSelectedCategory: (category) => {
set({ selectedCategory: category });
persistCategory(category);
@@ -187,6 +346,10 @@ export const useContactsStore = create<ContactsState>()(
selectedCategory: { type: 'all' },
loading: false,
error: null,
trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,
trustedSendersLoading: false,
}),
}),
{
+761 -35
View File
@@ -1,21 +1,50 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email, Mailbox } from '../api/types';
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,
setKeywordsForEmails,
moveEmail,
moveEmails as apiMoveEmails,
archiveEmails as apiArchiveEmails,
deleteEmail as apiDeleteEmail,
deleteEmails as apiDeleteEmails,
restoreEmailMailboxes,
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;
@@ -41,21 +70,61 @@ 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>;
refreshEmails: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>;
getEmailDetail: (id: string) => Promise<Email>;
markRead: (emailId: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
@@ -64,6 +133,11 @@ export interface EmailState {
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
archiveEmail: (emailId: string) => Promise<void>;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
// ── Batch (multi-select) actions ──────────────────────────────
archiveEmailsBatch: (emailIds: string[]) => Promise<void>;
moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise<void>;
deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise<void>;
setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise<void>;
undoLast: () => Promise<void>;
clearUndo: () => void;
searchEmails: (query: string) => Promise<Email[]>;
@@ -112,58 +186,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,
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
const limit = useSettingsStore.getState().emailsPerPage;
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails, totalEmails: total, loading: false });
} catch (err) {
console.warn('[email-store] selectMailbox failed:', err);
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
// 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;
}
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 {
@@ -174,28 +550,220 @@ 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 } = 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);
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
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 cached = await cacheStore.getEmailsInMailbox(
currentMailboxId,
Math.max(limit, 50),
);
if (
get().activeAccountId === activeAccountId &&
get().currentMailboxId === currentMailboxId &&
cached.length > 0
) {
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
return;
}
}
} catch (cacheErr) {
console.warn('[email-store] refresh cache fallback failed:', cacheErr);
}
}
set({
loading: false,
error: 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;
const mailboxChanged = 'Mailbox' in accountChanges;
const emailChanged = 'Email' in accountChanges || 'EmailDelivery' in accountChanges;
if (!mailboxChanged && !emailChanged) return;
if (mailboxChanged) {
await get().fetchMailboxes();
}
if (emailChanged && get().currentMailboxId) {
await get().refreshEmails();
}
},
@@ -217,7 +785,27 @@ export const useEmailStore = create<EmailState>()(
},
getEmailDetail: async (id) => {
return getFullEmail(id);
// Try the network first so the user sees fresh keywords/flags. If that
// fails (offline / server unreachable), fall back to the offline cache
// when the message is in it. Without the cache hit, propagate the error
// so the caller can surface it.
try {
const fresh = await getFullEmail(id);
// Opportunistically refresh the cached copy so the next offline open
// reflects the latest keywords without needing a full sync.
const cache = useOfflineCacheStore.getState();
if (cache.has(id)) {
try {
const size = JSON.stringify(fresh).length;
await cache.put(fresh, size);
} catch { /* ignore — best-effort refresh */ }
}
return fresh;
} catch (err) {
const cached = await useOfflineCacheStore.getState().get(id);
if (cached) return cached;
throw err;
}
},
markRead: async (emailId) => {
@@ -377,6 +965,133 @@ export const useEmailStore = create<EmailState>()(
}
},
// ── Batch actions ─────────────────────────────────────────────
// Each produces a single combined UndoEntry (UndoEntry.items is an array),
// so a multi-select archive/move/delete is reversed with one snackbar tap.
archiveEmailsBatch: async (emailIds) => {
const { emails, mailboxes } = get();
const archiveMailbox = mailboxes.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) return;
const targets = emails.filter(
(e) => emailIds.includes(e.id) && !e.mailboxIds?.[archiveMailbox.id],
);
if (targets.length === 0) return;
const mode = useSettingsStore.getState().archiveMode;
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
await apiArchiveEmails(
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
mode,
mailboxes,
);
const removed = new Set(targets.map((e) => e.id));
set({
emails: get().emails.filter((e) => !removed.has(e.id)),
pendingUndo: {
kind: 'archive',
label: targets.length === 1 ? 'Email archived' : `${targets.length} emails archived`,
createdAt: Date.now(),
items,
},
});
if (mode !== 'single') void get().fetchMailboxes();
},
moveEmailsToMailbox: async (emailIds, toMailboxId) => {
const { emails, currentMailboxId, mailboxes } = get();
if (!currentMailboxId || toMailboxId === currentMailboxId) return;
const targets = emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return;
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
await apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId);
const removed = new Set(targets.map((e) => e.id));
const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
emails: get().emails.filter((e) => !removed.has(e.id)),
pendingUndo: {
kind: 'move',
label: targetName
? `${targets.length === 1 ? 'Email' : `${targets.length} emails`} moved to ${targetName}`
: 'Emails moved',
createdAt: Date.now(),
items,
},
});
},
deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => {
const { emails, mailboxes } = get();
const settings = useSettingsStore.getState();
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
const inTrash = currentMailboxId === trashMailboxId;
const targets = emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return;
// Split into permanent-destroy vs move-to-trash following the same policy
// as the single delete: trash folder, global "permanent" default, or the
// skip-trash-for-junk option each force a destroy.
const toDestroy: Email[] = [];
const toTrash: Email[] = [];
for (const e of targets) {
const inJunk = !!(junkMailbox && e.mailboxIds?.[junkMailbox.id]);
const destroy =
inTrash ||
settings.deleteAction === 'permanent' ||
(settings.permanentlyDeleteJunk && inJunk);
(destroy ? toDestroy : toTrash).push(e);
}
if (toDestroy.length > 0) {
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
}
if (toTrash.length > 0) {
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
}
const removed = new Set(targets.map((e) => e.id));
set({ emails: get().emails.filter((e) => !removed.has(e.id)) });
// Only the moved-to-trash items are recoverable; destroyed ones are gone.
if (toTrash.length > 0) {
set({
pendingUndo: {
kind: 'delete',
label: toTrash.length === 1 ? 'Email moved to Trash' : `${toTrash.length} emails moved to Trash`,
createdAt: Date.now(),
items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })),
},
});
}
},
setKeywordForEmails: async (emailIds, token, on) => {
const targets = get().emails.filter((e) => emailIds.includes(e.id));
if (targets.length === 0) return;
const updates = targets.map((e) => {
const keywords = { ...e.keywords };
if (on) keywords[token] = true;
else delete keywords[token];
return { id: e.id, keywords };
});
await setKeywordsForEmails(updates);
const byId = new Map(updates.map((u) => [u.id, u.keywords]));
set({
emails: get().emails.map((e) =>
byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e,
),
});
},
undoLast: async () => {
const entry = get().pendingUndo;
if (!entry) return;
@@ -418,9 +1133,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: '',
@@ -428,16 +1147,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,
}),
},
),
+278
View File
@@ -0,0 +1,278 @@
// Persistent cache for full email bodies (and optional attachment blobs)
// powering offline mail viewing. Bodies are JSON-serialised and kept in
// 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_PREFIX = 'webmail:offline-cache:index:v2:';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
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 {
id: string;
receivedAt: string;
size: number; // approx bytes of cached payload
cachedAt: number;
}
interface CacheIndex {
entries: Record<string, OfflineCacheIndexEntry>;
}
export type SyncPhase = 'idle' | 'scanning' | 'fetching' | 'done' | 'error' | 'cancelled';
export interface SyncState {
phase: SyncPhase;
total: number; // total emails the sync expects to process
completed: number; // emails processed (skipped or fetched)
fetched: number; // emails actually downloaded (not already cached)
bytes: number; // bytes downloaded this run
message?: string; // human-readable status (errors etc.)
startedAt?: number;
finishedAt?: number;
}
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;
// Allows the sync orchestrator to abort mid-run when the user disables the
// setting or kicks off a fresh sync. Stored on the singleton because
// 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>;
put: (email: Email, approxBytes: number) => Promise<void>;
remove: (ids: string[]) => Promise<void>;
clearAll: () => Promise<void>;
// Returns cached emails whose `mailboxIds` include the given mailbox,
// sorted by `receivedAt` descending. Used by selectMailbox to seed the
// list when the network is unavailable. Loads each entry from
// AsyncStorage; the index doesn't carry the mailbox set.
getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise<Email[]>;
totalSize: () => number;
totalCount: () => number;
setSyncState: (next: Partial<SyncState>) => void;
resetSync: () => void;
requestAbort: () => void;
consumeAbort: () => boolean;
}
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,
setAccount: async (accountId) => {
const state = get();
if (state.activeAccountId === accountId) {
// Same account — make sure we've hydrated.
if (!state.hydrated) await get().hydrate();
return;
}
// 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) => {
const state = get();
if (!state.activeAccountId) return null;
if (!state.index.entries[id]) return null;
try {
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id));
if (!raw) return null;
return JSON.parse(raw) as Email;
} catch (err) {
console.warn('[offline-cache] get failed', id, err);
return null;
}
},
put: async (email, approxBytes) => {
const state = get();
const accountId = state.activeAccountId;
if (!accountId) return;
try {
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]: {
id: email.id,
receivedAt: email.receivedAt,
size: approxBytes,
cachedAt: Date.now(),
},
};
const index = { entries: nextEntries };
set({ 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(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(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(accountId, id)).catch(() => undefined)),
);
if (get().activeAccountId !== accountId) return;
const index = { entries: {} };
set({ index, sync: { ...IDLE } });
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(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;
});
const matches: Email[] = [];
for (const entry of sorted) {
if (matches.length >= limit) break;
try {
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id));
if (!raw) continue;
const email = JSON.parse(raw) as Email;
if (email.mailboxIds?.[mailboxId]) {
matches.push(email);
}
} catch {
// skip corrupt entries — they'll be replaced on next sync
}
}
return matches;
},
totalSize: () => {
let n = 0;
for (const e of Object.values(get().index.entries)) n += e.size;
return n;
},
totalCount: () => Object.keys(get().index.entries).length,
setSyncState: (next) => set({ sync: { ...get().sync, ...next } }),
resetSync: () => set({ sync: { ...IDLE }, abortRequested: false }),
requestAbort: () => set({ abortRequested: true }),
consumeAbort: () => {
const v = get().abortRequested;
if (v) set({ abortRequested: false });
return v;
},
}));
+14
View File
@@ -68,6 +68,9 @@ interface PersistedSettings {
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
plainTextMode: boolean;
// Undo-send window: every send is deferred by this many seconds (via the
// server's FUTURERELEASE support) so it can be cancelled. 0 = send instantly.
sendDelaySeconds: number;
// Reading
markAsReadDelay: number;
@@ -131,6 +134,13 @@ interface PersistedSettings {
// Plugins (UI-level enable map)
pluginEnabled: Record<string, boolean>;
// Offline mail cache: download recent message bodies in the background so
// they can be opened without network. Days windows the lookback. Attachment
// caching is intentionally not implemented yet — bodies-only is much
// smaller and covers the "open recent mail offline" UX on its own.
offlineCacheEnabled: boolean;
offlineCacheDays: number;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -154,6 +164,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
attachmentReminderEnabled: true,
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
plainTextMode: false,
sendDelaySeconds: 0,
markAsReadDelay: 0,
deleteAction: 'trash',
@@ -207,6 +218,9 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filtersExpandedView: false,
pluginEnabled: {},
offlineCacheEnabled: false,
offlineCacheDays: 7,
};
export interface SettingsState extends PersistedSettings {
+9 -3
View File
@@ -108,9 +108,15 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
if (s.installing || !s.cachedLatest?.apkAsset) return;
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
try {
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
set({ installProgress: p });
});
await downloadAndInstallApk(
{
asset: s.cachedLatest.apkAsset,
expectedSha256: s.cachedLatest.apkSha256,
},
(p) => {
set({ installProgress: p });
},
);
set({ installing: false, installProgress: null });
} catch (err) {
set({
+74
View File
@@ -0,0 +1,74 @@
// Vitest setup. Two pieces of plumbing the unit tests need:
//
// 1. AsyncStorage: the Zustand `persist` middleware used by several stores
// reaches in during `set()` calls, which on a `node` environment crashes
// inside the RN AsyncStorage shim (no `window` global). Swap in an
// in-memory implementation.
// 2. react-native: the bundled entry point is Flow-typed and rolldown can't
// parse it. Stub the few surfaces used at module-load time so any store
// that transitively imports `react-native` (via push / client-cert
// bridges) loads cleanly. Tests that actually exercise native modules
// re-mock the relevant module locally.
import { vi } from 'vitest';
const memory = new Map<string, string>();
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => memory.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
memory.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
memory.delete(key);
}),
clear: vi.fn(async () => {
memory.clear();
}),
getAllKeys: vi.fn(async () => Array.from(memory.keys())),
multiGet: vi.fn(async (keys: string[]) =>
keys.map((k) => [k, memory.get(k) ?? null] as [string, string | null]),
),
multiSet: vi.fn(async (pairs: [string, string][]) => {
for (const [k, v] of pairs) memory.set(k, v);
}),
multiRemove: vi.fn(async (keys: string[]) => {
for (const k of keys) memory.delete(k);
}),
},
}));
vi.mock('react-native', () => {
class NativeEventEmitter {
addListener() {
return { remove: () => undefined };
}
removeAllListeners() {}
}
return {
Platform: { OS: 'android', Version: 33, select: <T,>(spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios },
NativeModules: {},
NativeEventEmitter,
PermissionsAndroid: {
RESULTS: { GRANTED: 'granted', DENIED: 'denied', NEVER_ASK_AGAIN: 'never_ask_again' },
request: async () => 'granted',
},
Linking: { openURL: async () => undefined },
Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) },
};
});
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async () => undefined),
getItemAsync: vi.fn(async () => null),
deleteItemAsync: vi.fn(async () => undefined),
}));
// expo-web-browser transitively pulls in expo-modules-core, which evaluates
// RN-only globals at module load. The unit tests don't exercise the real
// browser handoff flow, so stubbing the surface is enough.
vi.mock('expo-web-browser', () => ({
openAuthSessionAsync: vi.fn(async () => ({ type: 'cancel' as const })),
maybeCompleteAuthSession: vi.fn(),
}));
+1
View File
@@ -5,5 +5,6 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/__tests__/**/*.test.ts'],
setupFiles: ['src/test-setup.ts'],
},
});