Compare commits

...
36 Commits
Author SHA1 Message Date
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
Linus Rath 0a402225f1 chore: update version number to 0.1.19 2026-05-03 16:58:13 +02:00
Linus Rath 9bfeb027de refactor: simplify sharing logic by removing unnecessary shareableUri function 2026-05-03 16:58:04 +02:00
Linus Rath 9a76b22d86 chore: update version number to 0.1.18 2026-05-03 16:12:22 +02:00
Linus Rath b254573237 feat: implement dynamic email limit based on user settings and add email notifications check 2026-05-03 16:08:11 +02:00
Linus Rath 2409a51952 refactor: streamline authorization header access and improve blob upload handling 2026-05-03 16:04:01 +02:00
Linus Rath 37e1b93c48 chore: update version number to 0.1.17 2026-05-03 15:33:12 +02:00
Linus Rath a656a6b56a feat: enhance email composition and management features 2026-05-03 15:32:59 +02:00
Linus Rath 20cdc282c8 chore: update version number to 0.1.16 2026-05-03 14:41:22 +02:00
Linus Rath cec98d89db refactor: refactor settings components 2026-05-03 13:38:29 +02:00
Linus Rath 8fa8597e9f chore: update version number to 0.1.15 2026-05-02 21:12:12 +02:00
Linus Rath 57f15f6f2e feat: improve keyboard handling in ComposeScreen for better visibility of format bar 2026-05-02 21:12:03 +02:00
Linus Rath cd3fdfa217 chore: update version number to 0.1.14 2026-05-02 17:22:14 +02:00
Linus Rath f8064d4544 feat: add swipe mode settings and enhance swipeable row functionality 2026-05-02 17:22:05 +02:00
68 changed files with 5701 additions and 838 deletions
+62 -5
View File
@@ -15,6 +15,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
@@ -40,6 +41,9 @@ 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';
@@ -47,8 +51,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
@@ -75,6 +92,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<OfflineCacheBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -192,6 +210,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 () => {
@@ -205,6 +235,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.
@@ -226,11 +274,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 () => {
@@ -240,12 +288,21 @@ export default function App() {
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates.
// 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.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
await teardownPushNotifications().catch(() => undefined);
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl) return;
try {
@@ -271,7 +328,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated]);
}, [client, isAuthenticated, emailNotificationsEnabled]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
+1 -1
View File
@@ -1 +1 @@
0.1.13
0.1.29
+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"
}
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.13",
"version": "0.1.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.13",
"version": "0.1.29",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -17,6 +17,7 @@
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
@@ -5216,6 +5217,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.13",
"version": "0.1.29",
"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",
+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');
});
});
+3 -2
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -50,14 +51,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();
});
});
+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' });
});
});
+87 -9
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,
@@ -13,17 +14,20 @@ export async function uploadBlob(
encodeURIComponent(jmapClient.accountId),
);
// Read the source via the file-system API so this works for both `file://`
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
// Read via the file-system API so this works for both `file://` (image
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
// the underlying ArrayBuffer rather than the Uint8Array view because some
// RN transports stringify typed-array bodies, which silently produces an
// 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,
Authorization: (jmapClient as any).authHeader,
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes as unknown as BodyInit,
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
@@ -31,7 +35,81 @@ export async function uploadBlob(
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json();
// JMAP servers return either the direct shape `{blobId, type, size}` or
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
// Stalwart has shipped both depending on version; mirror the webmail's
// tolerant parsing so uploads don't silently lose the blobId.
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');
}
// 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(
@@ -43,8 +121,8 @@ export function getDownloadUrl(
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', jmapClient.accountId)
.replace('{blobId}', blobId)
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
}
+94 -1
View File
@@ -22,6 +22,67 @@ export async function getMailboxes(): Promise<Mailbox[]> {
return res.methodResponses[0][1].list;
}
export async function createMailbox(
data: { name: string; parentId?: string | null },
): Promise<string> {
const accountId = jmapClient.accountId;
const cid = 'new-mailbox';
const res = await jmapClient.request([
['Mailbox/set', {
accountId,
create: {
[cid]: {
name: data.name,
parentId: data.parentId ?? null,
},
},
}, '0'],
]);
const result = res.methodResponses[0][1];
if (result.created?.[cid]?.id) return result.created[cid].id as string;
const failure = result.notCreated?.[cid] as
| { type?: string; description?: string; properties?: string[] }
| undefined;
throw new Error(
failure
? `${failure.type ?? 'create failed'}${failure.description ? `: ${failure.description}` : ''}`
: 'Mailbox create returned no id',
);
}
export async function updateMailbox(
id: string,
changes: { name?: string; parentId?: string | null },
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
]);
const failure = res.methodResponses[0][1].notUpdated?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'update failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
export async function deleteMailbox(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, destroy: [id] }, '0'],
]);
const failure = res.methodResponses[0][1].notDestroyed?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'delete failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
export async function queryEmails(
mailboxId: string,
options?: {
@@ -32,10 +93,22 @@ export async function queryEmails(
},
): Promise<{ ids: string[]; total: number }> {
const accountId = jmapClient.accountId;
const inMailbox = { inMailbox: mailboxId };
const userFilter = options?.filter;
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
const filter =
!userFilter || Object.keys(userFilter).length === 0
? inMailbox
: 'operator' in userFilter
? { operator: 'AND', conditions: [inMailbox, userFilter] }
: { ...inMailbox, ...userFilter };
const 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,
@@ -74,6 +147,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(
+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,
});
}
+57 -18
View File
@@ -7,6 +7,7 @@ import type {
} from './types';
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert';
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
@@ -53,7 +54,10 @@ export class JMAPClient {
// ── Authentication ────────────────────────────────────
private get authHeader(): string {
// Public so blob upload/download helpers (which can't go through the JMAP
// request body) can fetch the same Authorization header the rest of the
// client uses. The value is recomputed each access and never cached.
get authHeader(): string {
if (!this.credentials) throw new Error('No credentials');
if (this.credentials.accessToken) {
return `Bearer ${this.credentials.accessToken}`;
@@ -114,18 +118,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> {
@@ -161,17 +185,18 @@ export class JMAPClient {
// JMAP servers often self-report localhost/container-internal hostnames in
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
// the Android emulator can't resolve the host's "localhost").
//
// Splits via plain string indexing rather than `new URL()` because RN's
// URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = this.extractOrigin(serverUrl);
const rewrite = (url: string | undefined): string | undefined => {
if (!url) return url;
try {
const parsed = new URL(url);
const server = new URL(serverUrl);
if (parsed.origin === server.origin) return url;
return server.origin + parsed.pathname + parsed.search + parsed.hash;
} catch {
return url;
}
const origin = this.extractOrigin(url);
if (!origin || !serverOrigin || origin === serverOrigin) return url;
return serverOrigin + url.slice(origin.length);
};
return {
...session,
@@ -182,6 +207,11 @@ export class JMAPClient {
};
}
private extractOrigin(url: string): string | null {
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
return m ? m[1] : null;
}
// Reset in-memory state without touching persisted credentials (used on
// account switch so the active account's stored creds remain available).
reset(): void {
@@ -205,7 +235,7 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await fetch(url, {
const response = await secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
@@ -250,7 +280,7 @@ export class JMAPClient {
methodCalls,
};
const response = await fetch(this.session.apiUrl, {
const response = await secureFetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -305,7 +335,7 @@ 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, {
const response = await secureFetch(url, {
headers: { Authorization: this.authHeader },
});
if (response.status === 401) throw new AuthenticationError('Session expired');
@@ -323,6 +353,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) {
+28 -6
View File
@@ -134,7 +134,7 @@ export function connectEventSource(
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: (jmapClient as any).authHeader },
headers: { Authorization: jmapClient.authHeader },
});
const handler = (event: any) => {
@@ -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;
}
}
+2 -1
View File
@@ -429,7 +429,7 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
CALENDARS: 'urn:ietf:params:jmap:calendars',
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
} as const;
+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,
};
}
+37 -6
View File
@@ -6,8 +6,9 @@ import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useColors, useResolvedTheme } from '../theme/colors';
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
// skipped so we don't freeze the JS thread base64-encoding megabytes.
@@ -135,6 +136,28 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const contacts = useContactsStore((s) => s.contacts);
const resolvedTheme = useResolvedTheme();
// 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(() => {
if (!trustedSendersAddressBook) return null;
const out = new Set<string>();
for (const c of contacts) {
if (!c.emails) continue;
for (const e of Object.values(c.emails)) {
if (e?.address) out.add(e.address.toLowerCase());
}
}
return out;
}, [contacts, trustedSendersAddressBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark.
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
const html = React.useMemo(() => extractHtmlBody(email), [email]);
const text = React.useMemo(() => extractTextBody(email), [email]);
@@ -145,7 +168,10 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
[html, text],
);
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
const trusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const trusted = senderEmail
? isSenderTrusted(senderEmail)
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
: false;
// One-time override: user tapped "Load images" for this email only.
const [allowOnce, setAllowOnce] = React.useState(false);
@@ -218,17 +244,22 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
html: wrapEmailHtml(rawHtml, {
blockRemoteImages: shouldBlock,
cidMap,
isDark: renderAsDark,
}),
};
}
const fallbackText = text ?? email.preview ?? '';
if (!fallbackText) {
return {
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>'),
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
isDark: renderAsDark,
}),
};
}
return { html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText)) };
}, [rawHtml, text, email.preview, shouldBlock, cidMap]);
return {
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }),
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
@@ -266,7 +297,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>
+12 -7
View File
@@ -12,6 +12,7 @@ import {
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
import { useEmailStore } from '../stores/email-store';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
@@ -223,20 +224,22 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }),
]).start();
}
}, [visible, slideX, overlayOpacity]);
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
const accountEmail = username || '';
const initials = React.useMemo(
@@ -484,7 +487,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,
},
@@ -534,7 +539,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,
+153 -36
View File
@@ -1,6 +1,6 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Dimensions,
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
@@ -8,7 +8,7 @@ import {
} from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction } from '../stores/settings-store';
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
@@ -19,14 +19,24 @@ interface SwipeableRowProps {
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean; pinned: boolean };
onAction: (action: SwipeAction) => void;
/**
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
*/
mode?: SwipeMode;
}
// Distance (px) of horizontal travel needed to commit the action on release.
// Drag less than this and the row snaps back without firing.
// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT = 240;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
@@ -55,7 +65,7 @@ function exitsRow(action: SwipeAction): boolean {
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction,
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -63,8 +73,21 @@ export function SwipeableRow({
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
const snapBack = () => {
// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = null;
setOpenSide(null);
};
const openTo = (side: 'left' | 'right') => {
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = side;
setOpenSide(side);
};
const fly = (toValue: number, action: SwipeAction) => {
@@ -85,7 +108,19 @@ export function SwipeableRow({
});
} else {
onAction(action);
snapBack();
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
};
const fireFromBandTap = (action: SwipeAction) => {
close();
if (action === 'none') return;
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
} else {
onAction(action);
}
};
@@ -95,40 +130,121 @@ export function SwipeableRow({
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, g.dx));
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
: 0;
const next = base + g.dx;
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
if (mode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openTo('left');
} else {
close();
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
snapBack();
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
snapBack();
if (mode === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
if (mode === 'reveal') {
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
// are tap targets. The row slides over them; releasing past the activation
// threshold snaps the row to the open position so the band is fully
// visible and tappable.
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<Pressable
onPress={() => fireFromBandTap(action)}
style={[
styles.bandReveal,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
);
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderRevealBand(rightAction, 'left')}
{renderRevealBand(leftAction, 'right')}
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so
a tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
}
// Instant mode: bands fill the gap behind the row as it's dragged.
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
// Band stretches to fill the gap behind the row as it's dragged. We
// animate the icon a touch to signal commit-readiness.
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({
inputRange,
@@ -138,7 +254,7 @@ export function SwipeableRow({
return (
<View
style={[
styles.band,
styles.bandInstant,
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
@@ -151,12 +267,6 @@ export function SwipeableRow({
);
};
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
// Show the matching band based on current drag direction. Both bands occupy
// the full row, so we fade the inactive one out to avoid bleed-through.
const rightBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
@@ -171,10 +281,10 @@ export function SwipeableRow({
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderBand(rightAction, 'left')}
{renderInstantBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderBand(leftAction, 'right')}
{renderInstantBand(leftAction, 'right')}
</Animated.View>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
@@ -185,15 +295,22 @@ export function SwipeableRow({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
band: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
bandInstant: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandReveal: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
@@ -15,6 +15,7 @@ import { Check, X } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { getCalendarColor } from '../../lib/calendar-utils';
interface CalendarSidebarDrawerProps {
@@ -36,19 +37,21 @@ export function CalendarSidebarDrawer({
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, {
toValue: 0,
duration: 240,
duration: openDuration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 240,
duration: openDuration,
useNativeDriver: true,
}),
]).start();
@@ -56,18 +59,18 @@ export function CalendarSidebarDrawer({
Animated.parallel([
Animated.timing(slideX, {
toValue: -Dimensions.get('window').width,
duration: 200,
duration: closeDuration,
easing: Easing.in(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 0,
duration: 200,
duration: closeDuration,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideX, overlayOpacity]);
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
@@ -188,7 +191,7 @@ function makeStyles(c: ThemePalette) {
left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
+94 -53
View File
@@ -5,6 +5,7 @@ import {
endOfMonth,
endOfWeek,
format,
getWeek,
isSameDay,
isSameMonth,
isToday,
@@ -21,7 +22,8 @@ import {
type EventDayIndex,
} from '../../lib/calendar-utils';
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const SUN_FIRST = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const MON_FIRST = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
interface MonthViewProps {
currentDate: Date;
@@ -29,6 +31,8 @@ interface MonthViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
weekStartsOn?: 0 | 1;
showWeekNumbers?: boolean;
onSelectDate: (date: Date) => void;
onLongPressDate?: (date: Date) => void;
}
@@ -39,11 +43,14 @@ function MonthViewInner({
events,
calendars,
eventsByDay,
weekStartsOn = 0,
showWeekNumbers = false,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const weekdayLabels = weekStartsOn === 1 ? MON_FIRST : SUN_FIRST;
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
@@ -51,8 +58,8 @@ function MonthViewInner({
const days = React.useMemo(() => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calStart = startOfWeek(monthStart);
const calEnd = endOfWeek(monthEnd);
const calStart = startOfWeek(monthStart, { weekStartsOn });
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
const result: Date[] = [];
let day = calStart;
while (day <= calEnd) {
@@ -60,63 +67,82 @@ function MonthViewInner({
day = addDays(day, 1);
}
return result;
}, [currentDate]);
}, [currentDate, weekStartsOn]);
// Group days into rows of 7 so we can prepend a week-number column.
const rows = React.useMemo(() => {
const out: Date[][] = [];
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
return out;
}, [days]);
const renderDay = (d: Date, key: React.Key, flexLayout = false) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS);
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
return (
<Pressable
key={key}
style={[styles.dayCell, flexLayout && styles.dayCellFlex]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.dotsRow}>
{visibleDots.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
};
return (
<View style={styles.grid}>
<View style={styles.weekdayRow}>
{WEEKDAYS.map((wd, i) => (
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
{weekdayLabels.map((wd, i) => (
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
<View style={styles.daysGrid}>
{days.map((d, i) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS);
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
return (
<Pressable
key={i}
style={styles.dayCell}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.dotsRow}>
{visibleDots.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
})}
</View>
{showWeekNumbers ? (
rows.map((row, rowIdx) => (
<View key={rowIdx} style={styles.weekRow}>
<Text style={styles.weekNumberCell}>{getWeek(row[0], { weekStartsOn })}</Text>
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
</View>
))
) : (
<View style={styles.daysGrid}>
{days.map((d, i) => renderDay(d, i))}
</View>
)}
</View>
);
}
@@ -134,7 +160,22 @@ function makeStyles(c: ThemePalette) {
color: c.textMuted,
},
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
weekRow: { flexDirection: 'row', alignItems: 'center' },
weekNumberLabel: {
width: 24,
textAlign: 'center',
...typography.small,
color: c.textMuted,
},
weekNumberCell: {
width: 24,
textAlign: 'center',
...typography.caption,
color: c.textMuted,
paddingVertical: 4,
},
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
dayCellFlex: { width: undefined, flex: 1 },
dayNumber: {
width: 36,
height: 36,
+5 -1
View File
@@ -38,6 +38,7 @@ interface WeekViewProps {
onSelectEvent?: (event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void;
weekStartsOn?: 0 | 1;
timeFormat?: '12h' | '24h';
}
function WeekViewInner({
@@ -49,6 +50,7 @@ function WeekViewInner({
onSelectEvent,
onCreateAtTime,
weekStartsOn = 0,
timeFormat = '24h',
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -224,7 +226,9 @@ function WeekViewInner({
<View key={h} style={[styles.hourLabelCell, { height: HOUR_HEIGHT }]}>
{h > 0 && (
<Text style={styles.hourLabel}>
{h.toString().padStart(2, '0')}:00
{timeFormat === '12h'
? `${((h % 12) || 12)} ${h < 12 ? 'AM' : 'PM'}`
: `${h.toString().padStart(2, '0')}:00`}
</Text>
)}
</View>
@@ -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,
},
+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' },
});
}
+50 -20
View File
@@ -1,23 +1,38 @@
import React, { useState } from 'react';
import React, { useEffect } from 'react';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import {
useSettingsStore,
type CalendarView,
type FirstDayOfWeek,
type TimeFormat,
type CalendarHoverPreview,
} from '../../stores/settings-store';
export function CalendarSettings() {
const [viewMode, setViewMode] = useState('month');
const [firstDay, setFirstDay] = useState('1');
const [timeFormat, setTimeFormat] = useState('24h');
const [showTimeInMonth, setShowTimeInMonth] = useState(true);
const [showWeekNumbers, setShowWeekNumbers] = useState(false);
const [hoverPreview, setHoverPreview] = useState('delay-500ms');
const [birthdayCal, setBirthdayCal] = useState(true);
const [tasksEnabled, setTasksEnabled] = useState(false);
const [showTasksOnCal, setShowTasksOnCal] = useState(true);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const viewMode = useSettingsStore((s) => s.calendarDefaultView);
const firstDay = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const timeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const showTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
const showWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const hoverPreview = useSettingsStore((s) => s.calendarHoverPreview);
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
return (
<SettingsSection title="Calendar">
<SettingItem label="Default view">
<Select
value={viewMode}
onChange={setViewMode}
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
options={[
{ value: 'month', label: 'Month' },
{ value: 'week', label: 'Week' },
@@ -29,8 +44,8 @@ export function CalendarSettings() {
<SettingItem label="Week starts on">
<Select
value={firstDay}
onChange={setFirstDay}
value={String(firstDay)}
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
options={[
{ value: '1', label: 'Monday' },
{ value: '0', label: 'Sunday' },
@@ -41,7 +56,7 @@ export function CalendarSettings() {
<SettingItem label="Time format">
<RadioGroup
value={timeFormat}
onChange={setTimeFormat}
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
options={[
{ value: '12h', label: '12-hour' },
{ value: '24h', label: '24-hour' },
@@ -53,14 +68,20 @@ export function CalendarSettings() {
label="Show time in month view"
description="Show start times next to events in the month grid."
>
<ToggleSwitch checked={showTimeInMonth} onChange={setShowTimeInMonth} />
<ToggleSwitch
checked={showTimeInMonth}
onChange={(v) => update('calendarShowTimeInMonth', v)}
/>
</SettingItem>
<SettingItem
label="Show week numbers"
description="Display ISO week numbers in month and week views."
>
<ToggleSwitch checked={showWeekNumbers} onChange={setShowWeekNumbers} />
<ToggleSwitch
checked={showWeekNumbers}
onChange={(v) => update('calendarShowWeekNumbers', v)}
/>
</SettingItem>
<SettingItem
@@ -69,7 +90,7 @@ export function CalendarSettings() {
>
<Select
value={hoverPreview}
onChange={setHoverPreview}
onChange={(v) => update('calendarHoverPreview', v as CalendarHoverPreview)}
options={[
{ value: 'instant', label: 'Instant' },
{ value: 'delay-500ms', label: '500ms delay' },
@@ -84,14 +105,20 @@ export function CalendarSettings() {
label="Show birthday calendar"
description="Automatically include birthdays from your contacts."
>
<ToggleSwitch checked={birthdayCal} onChange={setBirthdayCal} />
<ToggleSwitch
checked={birthdayCal}
onChange={(v) => update('showBirthdayCalendar', v)}
/>
</SettingItem>
<SettingItem
label="Enable tasks"
description="Show task management alongside events."
>
<ToggleSwitch checked={tasksEnabled} onChange={setTasksEnabled} />
<ToggleSwitch
checked={tasksEnabled}
onChange={(v) => update('enableCalendarTasks', v)}
/>
</SettingItem>
{tasksEnabled && (
@@ -99,7 +126,10 @@ export function CalendarSettings() {
label="Show tasks on calendar"
description="Overlay tasks with due dates on the calendar."
>
<ToggleSwitch checked={showTasksOnCal} onChange={setShowTasksOnCal} />
<ToggleSwitch
checked={showTasksOnCal}
onChange={(v) => update('showTasksOnCalendar', v)}
/>
</SettingItem>
)}
</SettingsSection>
+58 -39
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
@@ -6,34 +6,25 @@ import {
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
type FolderLayout = 'inline' | 'sidebar';
type ViewMode = 'list' | 'grid';
type SortKey = 'name' | 'size' | 'modified';
type SortDir = 'asc' | 'desc';
import {
useSettingsStore,
type FilesFolderLayout,
type FilesViewMode,
type FilesSortKey,
type FilesSortDir,
} from '../../stores/settings-store';
interface FilesPrefs {
folderLayout: FolderLayout;
defaultViewMode: ViewMode;
defaultSortKey: SortKey;
defaultSortDir: SortDir;
folderLayout: FilesFolderLayout;
defaultViewMode: FilesViewMode;
defaultSortKey: FilesSortKey;
defaultSortDir: FilesSortDir;
showIcons: boolean;
coloredIcons: boolean;
showThumbnails: boolean;
showHiddenFiles: boolean;
}
const DEFAULTS: FilesPrefs = {
folderLayout: 'inline',
defaultViewMode: 'list',
defaultSortKey: 'name',
defaultSortDir: 'asc',
showIcons: true,
coloredIcons: true,
showThumbnails: true,
showHiddenFiles: false,
};
interface SampleFile {
name: string;
isFolder: boolean;
@@ -132,8 +123,33 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
export function FilesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const set = useSettingsStore((s) => s.updateSetting);
const folderLayout = useSettingsStore((s) => s.filesFolderLayout);
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
const defaultSortKey = useSettingsStore((s) => s.filesDefaultSortKey);
const defaultSortDir = useSettingsStore((s) => s.filesDefaultSortDir);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
const showThumbnails = useSettingsStore((s) => s.filesShowThumbnails);
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const prefs: FilesPrefs = {
folderLayout,
defaultViewMode,
defaultSortKey,
defaultSortDir,
showIcons,
coloredIcons,
showThumbnails,
showHiddenFiles,
};
return (
<View style={styles.container}>
@@ -145,8 +161,8 @@ export function FilesSettings() {
<SettingsSection title="Display" description="How files and folders are presented.">
<SettingItem label="Folder layout" description="Choose how folders appear in the browser.">
<RadioGroup
value={prefs.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
value={folderLayout}
onChange={(v) => set('filesFolderLayout', v as FilesFolderLayout)}
options={[
{ value: 'inline', label: 'Inline' },
{ value: 'sidebar', label: 'Sidebar' },
@@ -156,8 +172,8 @@ export function FilesSettings() {
<SettingItem label="Default view" description="List or grid view.">
<RadioGroup
value={prefs.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as ViewMode })}
value={defaultViewMode}
onChange={(v) => set('filesDefaultViewMode', v as FilesViewMode)}
options={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
@@ -167,8 +183,8 @@ export function FilesSettings() {
<SettingItem label="Default sort" description="Primary sort key.">
<RadioGroup
value={prefs.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as SortKey })}
value={defaultSortKey}
onChange={(v) => set('filesDefaultSortKey', v as FilesSortKey)}
options={[
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
@@ -179,8 +195,8 @@ export function FilesSettings() {
<SettingItem label="Sort direction">
<RadioGroup
value={prefs.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as SortDir })}
value={defaultSortDir}
onChange={(v) => set('filesDefaultSortDir', v as FilesSortDir)}
options={[
{ value: 'asc', label: 'Ascending' },
{ value: 'desc', label: 'Descending' },
@@ -191,25 +207,28 @@ export function FilesSettings() {
<SettingsSection title="Icons" description="Appearance of file and folder icons.">
<SettingItem label="Show icons" description="Display icons for files and folders.">
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
<ToggleSwitch checked={showIcons} onChange={(v) => set('filesShowIcons', v)} />
</SettingItem>
<SettingItem label="Colored icons" description="Use file-type-specific c.">
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
<ToggleSwitch
checked={prefs.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!prefs.showIcons}
checked={coloredIcons}
onChange={(v) => set('filesColoredIcons', v)}
disabled={!showIcons}
/>
</SettingItem>
<SettingItem label="Show thumbnails" description="Generate previews for images.">
<ToggleSwitch checked={prefs.showThumbnails} onChange={(v) => update({ showThumbnails: v })} />
<ToggleSwitch
checked={showThumbnails}
onChange={(v) => set('filesShowThumbnails', v)}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title="Behavior">
<SettingItem label="Show hidden files" description="Include dotfiles (.name) in listings.">
<ToggleSwitch
checked={prefs.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
checked={showHiddenFiles}
onChange={(v) => set('filesShowHiddenFiles', v)}
/>
</SettingItem>
</SettingsSection>
+11 -3
View File
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
import { SettingsSection, 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';
interface Rule {
id: string;
@@ -19,11 +20,18 @@ const SAMPLE_RULES: Rule[] = [];
export function FilterSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
const update = useSettingsStore((s) => s.updateSetting);
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [expandedView, setExpandedView] = useState(false);
const [isOpaque] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const toggle = (id: string) => {
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
@@ -115,7 +123,7 @@ export function FilterSettings() {
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>Expanded view</Text>
<ToggleSwitch checked={expandedView} onChange={setExpandedView} />
<ToggleSwitch checked={expandedView} onChange={(v) => update('filtersExpandedView', v)} />
</View>
)}
</View>
+234 -48
View File
@@ -1,12 +1,25 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Plus, Pencil, Trash2, X,
} from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useEmailStore } from '../../stores/email-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import type { Mailbox } from '../../api/types';
const ROLE_LABEL: Record<string, string> = {
@@ -23,12 +36,21 @@ function getIcon(mb: Mailbox) {
return Folder;
}
type Editor =
| { kind: 'create' }
| { kind: 'rename'; mailbox: Mailbox };
export function FolderSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const [editor, setEditor] = useState<Editor | null>(null);
const [draftName, setDraftName] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (mailboxes.length === 0) void fetchMailboxes();
}, [mailboxes.length, fetchMailboxes]);
@@ -42,12 +64,98 @@ export function FolderSettings() {
const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0);
const openCreate = () => {
setEditor({ kind: 'create' });
setDraftName('');
};
const openRename = (mailbox: Mailbox) => {
if (mailbox.role) {
Alert.alert('Cannot rename', 'System folders cannot be renamed.');
return;
}
setEditor({ kind: 'rename', mailbox });
setDraftName(mailbox.name);
};
const closeEditor = () => setEditor(null);
const saveDraft = async () => {
const name = draftName.trim();
if (!name) {
Alert.alert('Name required');
return;
}
setSaving(true);
try {
if (editor?.kind === 'create') {
await createMailbox({ name });
} else if (editor?.kind === 'rename') {
await updateMailbox(editor.mailbox.id, { name });
}
closeEditor();
await fetchMailboxes();
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const confirmDelete = (mailbox: Mailbox) => {
if (mailbox.role) {
Alert.alert('Cannot delete', 'System folders cannot be removed.');
return;
}
if ((mailbox.totalEmails ?? 0) > 0) {
Alert.alert(
'Folder not empty',
`"${mailbox.name}" contains ${mailbox.totalEmails} email${mailbox.totalEmails === 1 ? '' : 's'}. Delete anyway?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
],
);
return;
}
Alert.alert(
'Delete folder',
`Permanently delete "${mailbox.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
],
);
};
const performDelete = async (mailbox: Mailbox) => {
setBusyId(mailbox.id);
try {
await deleteMailbox(mailbox.id);
await fetchMailboxes();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
} finally {
setBusyId(null);
}
};
return (
<View style={styles.container}>
<SettingsSection
title="Folders"
description={`Your mail folders. ${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread.`}
description={`${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread. Tap a folder to rename, long-press to delete.`}
>
<View style={styles.headerRow}>
<Button
variant="default"
size="sm"
onPress={openCreate}
icon={<Plus size={14} color={c.primaryForeground} />}
>
New folder
</Button>
</View>
{mailboxes.length === 0 ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={c.primary} />
@@ -56,13 +164,20 @@ export function FolderSettings() {
<View>
{sorted.map((mb) => {
const Icon = getIcon(mb);
const editable = !mb.role;
return (
<View key={mb.id} style={styles.folderRow}>
<Pressable
key={mb.id}
onPress={() => editable && openRename(mb)}
onLongPress={() => editable && confirmDelete(mb)}
disabled={!editable && busyId !== mb.id}
style={({ pressed }) => [
styles.folderRow,
editable && pressed && styles.folderRowPressed,
]}
>
<View style={styles.folderLeft}>
<Icon
size={16}
color={mb.role ? c.primary : c.mutedForeground}
/>
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
<View style={styles.rolePill}>
@@ -77,56 +192,127 @@ export function FolderSettings() {
</View>
)}
<Text style={styles.total}>{mb.totalEmails}</Text>
{editable && (
busyId === mb.id ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<Pencil size={14} color={c.textMuted} />
)
)}
</View>
</View>
</Pressable>
);
})}
</View>
)}
</SettingsSection>
<SettingsSection
title="Management"
description="Creating, renaming and deleting folders is not yet available in the mobile app. Use the web client."
>
<SettingItem label="Read-only">
<Text style={styles.caption}>Folder list shown above is live.</Text>
</SettingItem>
</SettingsSection>
<Modal visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editor?.kind === 'rename' ? `Rename "${editor.mailbox.name}"` : 'New folder'}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Folder name</Text>
<TextInput
value={draftName}
onChangeText={setDraftName}
placeholder="Receipts"
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
/>
{editor?.kind === 'rename' && (
<Pressable
onPress={() => {
closeEditor();
confirmDelete(editor.mailbox);
}}
style={styles.deleteRow}
>
<Trash2 size={14} color={c.error} />
<Text style={styles.deleteRowText}>Delete folder</Text>
</Pressable>
)}
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
<Button
variant="default"
size="sm"
onPress={() => { void saveDraft(); }}
loading={saving}
>
Save
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: c.mutedForeground },
});
container: { gap: spacing.xxxl },
headerRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingVertical: spacing.sm },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
folderRowPressed: { backgroundColor: c.muted },
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomColor: c.border,
},
modalTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
modalBody: { padding: spacing.lg, gap: spacing.md },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
paddingHorizontal: spacing.md, paddingVertical: 10,
},
deleteRow: {
flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
paddingVertical: spacing.md,
},
deleteRowText: { ...typography.body, color: c.error },
modalActions: {
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
},
});
}
+259 -62
View File
@@ -1,10 +1,51 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { Plus, Trash2, X } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import {
createIdentity,
deleteIdentity,
updateIdentity,
} from '../../api/identity';
import type { Identity } from '../../api/types';
type DraftIdentity = {
id: string;
name: string;
email: string;
textSignature: string;
mayDelete: boolean;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function toDraft(identity: Identity): DraftIdentity {
return {
id: identity.id,
name: identity.name ?? '',
email: identity.email,
textSignature: identity.textSignature ?? '',
mayDelete: identity.mayDelete,
};
}
function emptyDraft(): DraftIdentity {
return { id: '', name: '', email: '', textSignature: '', mayDelete: true };
}
export function IdentitySettings() {
const c = useColors();
@@ -14,31 +55,100 @@ export function IdentitySettings() {
const error = useSettingsStore((s) => s.error);
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
const [editing, setEditing] = useState<DraftIdentity | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
void fetchIdentities();
}, [fetchIdentities]);
const count = identities.length;
const countLabel =
count === 0 ? 'No identities' : count === 1 ? '1 identity' : `${count} identities`;
const openCreate = () => setEditing(emptyDraft());
const openEdit = (i: Identity) => setEditing(toDraft(i));
const closeEditor = () => setEditing(null);
const saveDraft = async () => {
if (!editing) return;
const name = editing.name.trim();
const email = editing.email.trim();
if (!email || !EMAIL_RE.test(email)) {
Alert.alert('Invalid email', 'Enter a valid email address for this identity.');
return;
}
setSaving(true);
try {
if (editing.id) {
// JMAP doesn't allow changing `email` on an existing identity, so we
// only PATCH the editable fields. The server will reject email
// changes; the form disables that input below to make this obvious.
await updateIdentity(editing.id, {
name,
textSignature: editing.textSignature,
});
} else {
await createIdentity({
name,
email,
textSignature: editing.textSignature || undefined,
});
}
closeEditor();
await fetchIdentities();
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const confirmDelete = (identity: Identity) => {
if (!identity.mayDelete) {
Alert.alert('Cannot delete', 'The primary identity cannot be removed.');
return;
}
Alert.alert(
'Delete identity',
`Remove "${identity.name || identity.email}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(identity.id);
try {
await deleteIdentity(identity.id);
await fetchIdentities();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
} finally {
setDeletingId(null);
}
},
},
],
);
};
return (
<SettingsSection
title="Identities"
description="Manage sender names, email addresses, and signatures."
description="Manage sender names, email addresses, and signatures. Tap a row to edit."
>
<SettingItem
label="Identities"
description="Your configured sender identities."
>
<View style={styles.row}>
{loading ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<Text style={styles.count}>{countLabel}</Text>
)}
</View>
</SettingItem>
<View style={styles.headerRow}>
<Text style={styles.count}>
{loading ? 'Loading…' : identities.length === 1 ? '1 identity' : `${identities.length} identities`}
</Text>
<Button
variant="default"
size="sm"
onPress={openCreate}
icon={<Plus size={14} color={c.primaryForeground} />}
disabled={loading}
>
New
</Button>
</View>
{error && (
<View style={styles.errorBox}>
@@ -47,63 +157,150 @@ export function IdentitySettings() {
)}
{identities.map((identity) => (
<View key={identity.id} style={styles.identityRow}>
<Pressable
key={identity.id}
onPress={() => openEdit(identity)}
style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]}
>
<View style={{ flex: 1 }}>
<Text style={styles.identityName}>{identity.name || '(no name)'}</Text>
<Text style={styles.identityEmail}>{identity.email}</Text>
</View>
{!identity.mayDelete && (
{!identity.mayDelete ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>primary</Text>
</View>
) : (
<Pressable
onPress={() => confirmDelete(identity)}
hitSlop={8}
style={styles.identityDelete}
disabled={deletingId === identity.id}
>
{deletingId === identity.id ? (
<ActivityIndicator size="small" color={c.error} />
) : (
<Trash2 size={16} color={c.error} />
)}
</Pressable>
)}
</View>
</Pressable>
))}
<SettingItem
label="Sub-addressing"
description="Use plus-addressing (you+tag@example.com) to route mail."
>
<Button variant="outline" size="sm">Learn More</Button>
</SettingItem>
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? 'Edit identity' : 'New identity'}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Display name</Text>
<TextInput
value={editing?.name ?? ''}
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
placeholder="Jane Doe"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Email address</Text>
<TextInput
value={editing?.email ?? ''}
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
placeholder="jane@example.com"
placeholderTextColor={c.textMuted}
style={[styles.input, !!editing?.id && styles.inputDisabled]}
editable={!editing?.id}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!editing?.id && (
<Text style={styles.hint}>JMAP does not allow changing an identity&apos;s email; create a new one instead.</Text>
)}
<Text style={styles.fieldLabel}>Plain-text signature</Text>
<TextInput
value={editing?.textSignature ?? ''}
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
placeholder="--&#10;Jane Doe&#10;Bulwark Mail"
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
<Button
variant="default"
size="sm"
onPress={() => { void saveDraft(); }}
loading={saving}
>
Save
</Button>
</View>
</View>
</View>
</Modal>
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
count: {
...typography.body,
color: c.text,
},
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
},
errorText: { ...typography.caption, color: c.error },
identityRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.muted,
},
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
badge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
});
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: spacing.sm,
},
count: { ...typography.body, color: c.mutedForeground },
errorBox: { padding: spacing.md, borderRadius: radius.sm, backgroundColor: c.errorBg },
errorText: { ...typography.caption, color: c.error },
identityRow: {
flexDirection: 'row', alignItems: 'center', gap: spacing.md,
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
borderRadius: radius.sm, backgroundColor: c.muted,
marginTop: spacing.xs,
},
identityRowPressed: { backgroundColor: c.surfaceHover },
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
identityDelete: { padding: 6 },
badge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomColor: c.border,
},
modalTitle: { ...typography.h3, color: c.text },
modalBody: { padding: spacing.lg, gap: spacing.sm },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary, marginTop: spacing.sm },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
paddingHorizontal: spacing.md, paddingVertical: 10,
},
inputDisabled: { opacity: 0.6 },
bodyInput: { minHeight: 100, textAlignVertical: 'top' },
hint: { ...typography.caption, color: c.mutedForeground },
modalActions: {
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
},
});
}
+22 -6
View File
@@ -1,10 +1,10 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { SettingsSection, RadioGroup } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -17,6 +17,11 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'move', label: 'Move to folder' },
];
const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'instant', label: 'Instant (swipe = action)' },
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -24,6 +29,8 @@ export function LayoutSettings() {
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -32,6 +39,19 @@ export function LayoutSettings() {
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
<Text style={styles.rowLabel}>Swipe behavior</Text>
<Text style={styles.rowDescription}>
Pick instant (swipe past the threshold to fire the action) or reveal
(swipe to expose an action band, then tap to confirm).
</Text>
<RadioGroup
value={swipeMode}
onChange={(v) => setSwipeMode(v as SwipeMode)}
options={SWIPE_MODE_OPTIONS}
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowRight size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
@@ -61,10 +81,6 @@ export function LayoutSettings() {
/>
</View>
<SettingItem
label=""
description="Swipe past the threshold and release to fire the action; release before to cancel."
/>
</SettingsSection>
);
}
@@ -13,6 +13,7 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import { useSettingsStore, type NotificationSound } from '../../stores/settings-store';
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
@@ -21,7 +22,7 @@ import {
teardownPushNotifications,
} from '../../lib/push-notifications';
const SOUNDS = [
const SOUNDS: { value: NotificationSound; label: string }[] = [
{ value: 'default', label: 'Default' },
{ value: 'chime', label: 'Chime' },
{ value: 'ping', label: 'Ping' },
@@ -40,18 +41,25 @@ export function NotificationSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const username = useAuthStore((s) => s.username);
const [sound, setSound] = useState('default');
const [emailEnabled, setEmailEnabled] = useState(true);
const [emailSound, setEmailSound] = useState(true);
const [calEnabled, setCalEnabled] = useState(true);
const [calSound, setCalSound] = useState(true);
const [invitationParsing, setInvitationParsing] = useState(true);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const sound = useSettingsStore((s) => s.notificationSoundChoice);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const emailSound = useSettingsStore((s) => s.emailNotificationSound);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const calSound = useSettingsStore((s) => s.calendarNotificationSound);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [hasSaved, setHasSaved] = useState(false);
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
const [confirmDisable, setConfirmDisable] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
useEffect(() => {
void (async () => {
const stored = await getStoredRelayBaseUrl();
@@ -190,7 +198,7 @@ export function NotificationSettings() {
/>
<Select
value={sound}
onChange={setSound}
onChange={(v) => update('notificationSoundChoice', v as NotificationSound)}
options={SOUNDS}
/>
</View>
@@ -199,22 +207,33 @@ export function NotificationSettings() {
<SettingsSection title="Email" description="Notifications for incoming email.">
<SettingItem label="Email Notifications" description="Show desktop alerts for new email.">
<ToggleSwitch checked={emailEnabled} onChange={setEmailEnabled} />
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
<SettingItem label="Email Sound" description="Play a sound when new email arrives.">
<ToggleSwitch checked={emailSound} onChange={setEmailSound} disabled={!emailEnabled} />
<ToggleSwitch
checked={emailSound}
onChange={(v) => update('emailNotificationSound', v)}
disabled={!emailEnabled}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title="Calendar" description="Notifications for events and invitations.">
<SettingItem label="Calendar Notifications" description="Show alerts for upcoming events.">
<ToggleSwitch checked={calEnabled} onChange={setCalEnabled} />
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
</SettingItem>
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts.">
<ToggleSwitch checked={calSound} onChange={setCalSound} disabled={!calEnabled} />
<ToggleSwitch
checked={calSound}
onChange={(v) => update('calendarNotificationSound', v)}
disabled={!calEnabled}
/>
</SettingItem>
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
<ToggleSwitch checked={invitationParsing} onChange={setInvitationParsing} />
<ToggleSwitch
checked={invitationParsing}
onChange={(v) => update('calendarInvitationParsingEnabled', v)}
/>
</SettingItem>
</SettingsSection>
+30 -7
View File
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
import { Upload, Puzzle, AlertTriangle, Lock, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem, 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';
interface Plugin {
id: string;
@@ -33,21 +34,43 @@ function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; f
export function PluginsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const enabledMap = useSettingsStore((s) => s.pluginEnabled);
const setPluginEnabled = useSettingsStore((s) => s.setPluginEnabled);
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const togglePlugin = (id: string) => {
setPlugins((ps) => ps.map((p) => {
if (p.id !== id) return p;
const enabled = !p.enabled;
return { ...p, enabled, status: enabled ? 'enabled' : 'disabled' };
}));
const current = plugins.find((p) => p.id === id);
if (!current) return;
const next = !current.enabled;
setPluginEnabled(id, next);
setPlugins((ps) => ps.map((p) =>
p.id === id ? { ...p, enabled: next, status: next ? 'enabled' : 'disabled' } : p,
));
};
const uninstall = (id: string) => {
setPlugins((ps) => ps.filter((p) => p.id !== id));
};
// When plugins are populated from a real source later, hydrate their enable
// state from the persisted map.
useEffect(() => {
if (plugins.length === 0) return;
setPlugins((ps) => ps.map((p) => {
const stored = enabledMap[p.id];
if (stored === undefined || stored === p.enabled) return p;
return { ...p, enabled: stored, status: stored ? 'enabled' : 'disabled' };
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabledMap, plugins.length]);
return (
<SettingsSection
title="Plugins"
+49 -52
View File
@@ -1,20 +1,20 @@
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
import { AlertTriangle, FolderSync, X } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ArchiveMode, type ExternalContentPolicy } from '../../stores/settings-store';
import {
useSettingsStore,
type ArchiveMode,
type DeleteAction,
type MailLayout,
type MailAttachmentAction,
type AttachmentPosition,
} from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
type MailLayout = 'split' | 'focus';
type DeleteAction = 'trash' | 'permanent';
type AttachmentAction = 'preview' | 'download';
type AttachmentPosition = 'beside-sender' | 'below-header';
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
function MailLayoutPreview({ value }: { value: MailLayout }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -72,35 +72,29 @@ function MailLayoutPreview({ value }: { value: MailLayout }) {
export function ReadingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const archiveMode = useSettingsStore((s) => s.archiveMode);
const setArchiveMode = useSettingsStore((s) => s.setArchiveMode);
const update = useSettingsStore((s) => s.updateSetting);
const [markAsReadDelay, setMarkAsReadDelay] = useState('0');
const [deleteAction, setDeleteAction] = useState<DeleteAction>('trash');
const [permanentlyDeleteJunk, setPermanentlyDeleteJunk] = useState(false);
const [showPreview, setShowPreview] = useState(true);
const [mailLayout, setMailLayout] = useState<MailLayout>('split');
const [disableThreading, setDisableThreading] = useState(false);
const [autoSelectReplyIdentity, setAutoSelectReplyIdentity] = useState(true);
const [plainTextMode, setPlainTextMode] = useState(false);
const [emailsPerPage, setEmailsPerPage] = useState('25');
const [mailAttachmentAction, setMailAttachmentAction] = useState<AttachmentAction>('preview');
const [attachmentPosition, setAttachmentPosition] = useState<AttachmentPosition>('beside-sender');
const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false);
const archiveMode = useSettingsStore((s) => s.archiveMode);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const deleteAction = useSettingsStore((s) => s.deleteAction);
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
const showPreview = useSettingsStore((s) => s.showPreview);
const mailLayout = useSettingsStore((s) => s.mailLayout);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const [newKeyword, setNewKeyword] = useState('');
const [reorganizing, setReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(DEFAULT_KEYWORDS);
const [newKeyword, setNewKeyword] = useState('');
const [hideInlineImageAttachments, setHideInlineImageAttachments] = useState(true);
const [trustedSendersAddressBook, setTrustedSendersAddressBook] = useState(false);
const [showTrustedList, setShowTrustedList] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
@@ -165,8 +159,8 @@ export function ReadingSettings() {
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
<SettingItem label="Mark as Read" description="When to flag an email as read.">
<Select
value={markAsReadDelay}
onChange={setMarkAsReadDelay}
value={String(markAsReadDelay)}
onChange={(v) => update('markAsReadDelay', Number(v))}
options={[
{ value: '0', label: 'Instant' },
{ value: '3000', label: 'After 3s' },
@@ -180,7 +174,7 @@ export function ReadingSettings() {
<SettingItem label="Delete Action" description="Where deleted emails go." noBorder />
<Select
value={deleteAction}
onChange={(v) => setDeleteAction(v as DeleteAction)}
onChange={(v) => update('deleteAction', v as DeleteAction)}
options={[
{ value: 'trash', label: 'Move to Trash' },
{ value: 'permanent', label: 'Permanently delete' },
@@ -199,7 +193,7 @@ export function ReadingSettings() {
<SettingItem label="Archive Mode" description="Organize archive into subfolders." noBorder />
<Select
value={archiveMode}
onChange={(v) => setArchiveMode(v as ArchiveMode)}
onChange={(v) => update('archiveMode', v as ArchiveMode)}
options={[
{ value: 'single', label: 'Single folder' },
{ value: 'year', label: 'By year' },
@@ -231,14 +225,14 @@ export function ReadingSettings() {
</View>
<SettingItem label="Permanently Delete Junk" description="Skip the trash when deleting spam.">
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={setPermanentlyDeleteJunk} />
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
</SettingItem>
<View style={styles.group}>
<SettingItem label="Mail Layout" description="Choose between split view and focus view." noBorder />
<RadioGroup
value={mailLayout}
onChange={(v) => setMailLayout(v as MailLayout)}
onChange={(v) => update('mailLayout', v as MailLayout)}
options={[
{ value: 'split', label: 'Split' },
{ value: 'focus', label: 'Focus' },
@@ -249,25 +243,25 @@ export function ReadingSettings() {
</View>
<SettingItem label="Show Preview" description="Preview text under each subject.">
<ToggleSwitch checked={showPreview} onChange={setShowPreview} />
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
<SettingItem label="Disable Thread Grouping" description="Show emails individually instead of threaded.">
<ToggleSwitch checked={disableThreading} onChange={setDisableThreading} />
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem>
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
<ToggleSwitch checked={plainTextMode} onChange={setPlainTextMode} />
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
</SettingItem>
<SettingItem label="Auto-select Reply Identity" description="Pick the best identity when replying.">
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
</SettingItem>
<SettingItem label="Attachment Reminder" description="Warn when the word 'attached' is present but no file is attached.">
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={setAttachmentReminderEnabled}
onChange={(v) => update('attachmentReminderEnabled', v)}
/>
</SettingItem>
@@ -281,7 +275,10 @@ export function ReadingSettings() {
<Text style={styles.chipText}>{kw}</Text>
<Pressable
onPress={() =>
setAttachmentReminderKeywords((list) => list.filter((k) => k !== kw))
update(
'attachmentReminderKeywords',
attachmentReminderKeywords.filter((k) => k !== kw),
)
}
hitSlop={6}
>
@@ -300,7 +297,7 @@ export function ReadingSettings() {
onSubmitEditing={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
@@ -310,7 +307,7 @@ export function ReadingSettings() {
onPress={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
@@ -323,13 +320,13 @@ export function ReadingSettings() {
)}
<SettingItem label="Hide Inline Image Attachments" description="Don't list inline images in the attachment list.">
<ToggleSwitch checked={hideInlineImageAttachments} onChange={setHideInlineImageAttachments} />
<ToggleSwitch checked={hideInlineImageAttachments} onChange={(v) => update('hideInlineImageAttachments', v)} />
</SettingItem>
<SettingItem label="Attachment Click Action" description="What happens when you tap an attachment.">
<Select
value={mailAttachmentAction}
onChange={(v) => setMailAttachmentAction(v as AttachmentAction)}
onChange={(v) => update('mailAttachmentAction', v as MailAttachmentAction)}
options={[
{ value: 'preview', label: 'Preview' },
{ value: 'download', label: 'Download' },
@@ -340,7 +337,7 @@ export function ReadingSettings() {
<SettingItem label="Attachment Position" description="Where attachments appear in messages.">
<Select
value={attachmentPosition}
onChange={(v) => setAttachmentPosition(v as AttachmentPosition)}
onChange={(v) => update('attachmentPosition', v as AttachmentPosition)}
options={[
{ value: 'beside-sender', label: 'Beside sender' },
{ value: 'below-header', label: 'Below header' },
@@ -350,8 +347,8 @@ export function ReadingSettings() {
<SettingItem label="Emails Per Page" description="How many emails to load at a time.">
<Select
value={emailsPerPage}
onChange={setEmailsPerPage}
value={String(emailsPerPage)}
onChange={(v) => update('emailsPerPage', Number(v))}
options={[
{ value: '10', label: '10' },
{ value: '25', label: '25' },
+19 -16
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import {
Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical,
@@ -7,43 +7,46 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface SidebarApp {
id: string;
name: string;
url: string;
icon: string;
openMode: 'tab' | 'inline';
showOnMobile: boolean;
}
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
export function SidebarAppsSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [keepLoaded, setKeepLoaded] = useState(false);
const [apps, setApps] = useState<SidebarApp[]>([]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const apps = useSettingsStore((s) => s.sidebarApps);
const keepLoaded = useSettingsStore((s) => s.keepAppsLoaded);
const addSidebarApp = useSettingsStore((s) => s.addSidebarApp);
const updateSidebarApp = useSettingsStore((s) => s.updateSidebarApp);
const removeSidebarApp = useSettingsStore((s) => s.removeSidebarApp);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const addApp = (data: Omit<SidebarApp, 'id'>) => {
setApps((list) => [...list, { ...data, id: `app-${Math.random().toString(36).slice(2)}` }]);
addSidebarApp(data);
setIsAdding(false);
};
const updateApp = (id: string, data: Omit<SidebarApp, 'id'>) => {
setApps((list) => list.map((a) => a.id === id ? { ...data, id } : a));
updateSidebarApp(id, data);
setEditingId(null);
};
const removeApp = (id: string) => {
setApps((list) => list.filter((a) => a.id !== id));
removeSidebarApp(id);
};
return (
<View style={styles.container}>
<SettingsSection title="Sidebar Apps" description="Embedded web apps accessible from the sidebar.">
<SettingItem label="Keep apps loaded" description="Maintain app state when switching away.">
<ToggleSwitch checked={keepLoaded} onChange={setKeepLoaded} />
<ToggleSwitch checked={keepLoaded} onChange={(v) => update('keepAppsLoaded', v)} />
</SettingItem>
</SettingsSection>
+16 -9
View File
@@ -1,13 +1,14 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
Upload, Trash2, Eye, Lock, Unlock, Download,
ShieldCheck, ShieldAlert, Users,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { SettingsSection, SettingItem, 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';
interface KeyRecord {
id: string;
@@ -34,12 +35,18 @@ const MOCK_CERTS: PublicCert[] = [];
export function SmimeSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const defaultEncrypt = useSettingsStore((s) => s.smimeDefaultEncrypt);
const rememberUnlocked = useSettingsStore((s) => s.smimeRememberUnlocked);
const autoImport = useSettingsStore((s) => s.smimeAutoImport);
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
const [bindings, setBindings] = useState<Record<string, string>>({});
const [defaultEncrypt, setDefaultEncrypt] = useState(false);
const [rememberUnlocked, setRememberUnlocked] = useState(false);
const [autoImport, setAutoImport] = useState(true);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const isExpired = (d: string) => new Date(d) < new Date();
const formatDate = (d: string) => {
@@ -167,21 +174,21 @@ export function SmimeSettings() {
label="Encrypt by default"
description="Encrypt new messages when recipient certificates are available."
>
<ToggleSwitch checked={defaultEncrypt} onChange={setDefaultEncrypt} />
<ToggleSwitch checked={defaultEncrypt} onChange={(v) => update('smimeDefaultEncrypt', v)} />
</SettingItem>
<SettingItem
label="Remember unlocked keys"
description="Keep private keys unlocked across the session."
>
<ToggleSwitch checked={rememberUnlocked} onChange={setRememberUnlocked} />
<ToggleSwitch checked={rememberUnlocked} onChange={(v) => update('smimeRememberUnlocked', v)} />
</SettingItem>
<SettingItem
label="Auto-import signer certs"
description="Save public certificates from signed messages."
>
<ToggleSwitch checked={autoImport} onChange={setAutoImport} />
<ToggleSwitch checked={autoImport} onChange={(v) => update('smimeAutoImport', v)} />
</SettingItem>
</SettingsSection>
</View>
+275 -22
View File
@@ -1,60 +1,313 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { FileText, Download, Upload } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
Alert,
Modal,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { Download, FileText, Plus, Trash2, Upload, X } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
export function TemplateSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [templates] = useState<{ id: string; name: string }[]>([]);
const templates = useTemplatesStore((s) => s.templates);
const hydrated = useTemplatesStore((s) => s.hydrated);
const hydrate = useTemplatesStore((s) => s.hydrate);
const addTemplate = useTemplatesStore((s) => s.addTemplate);
const updateTemplate = useTemplatesStore((s) => s.updateTemplate);
const deleteTemplate = useTemplatesStore((s) => s.deleteTemplate);
const exportAll = useTemplatesStore((s) => s.exportAll);
const importTemplates = useTemplatesStore((s) => s.importTemplates);
const [editing, setEditing] = useState<EmailTemplate | null>(null);
const [importVisible, setImportVisible] = useState(false);
const [importText, setImportText] = useState('');
const [draftName, setDraftName] = useState('');
const [draftSubject, setDraftSubject] = useState('');
const [draftBody, setDraftBody] = useState('');
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const openCreate = () => {
setEditing({
id: '',
name: '',
subject: '',
body: '',
category: '',
isFavorite: false,
createdAt: '',
updatedAt: '',
});
setDraftName('');
setDraftSubject('');
setDraftBody('');
};
const openEdit = (t: EmailTemplate) => {
setEditing(t);
setDraftName(t.name);
setDraftSubject(t.subject);
setDraftBody(t.body);
};
const closeEditor = () => setEditing(null);
const saveDraft = () => {
const name = draftName.trim();
if (!name) {
Alert.alert('Template name required');
return;
}
if (!editing) return;
if (editing.id) {
updateTemplate(editing.id, { name, subject: draftSubject, body: draftBody });
} else {
addTemplate({ name, subject: draftSubject, body: draftBody });
}
closeEditor();
};
const confirmDelete = (t: EmailTemplate) => {
Alert.alert(
'Delete template',
`Permanently delete "${t.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => deleteTemplate(t.id) },
],
);
};
const exportTemplatesAsShare = async () => {
const json = exportAll();
try {
await Share.share({ message: json, title: 'Templates export' });
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unable to share');
}
};
const performImport = () => {
const result = importTemplates(importText);
if (result.error) {
Alert.alert('Import failed', result.error);
return;
}
setImportText('');
setImportVisible(false);
Alert.alert('Imported', `${result.count} template${result.count === 1 ? '' : 's'} added.`);
};
return (
<View style={styles.container}>
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
<View style={styles.row}>
<SettingsSection title="Templates" description="Reusable snippets for common replies. Tap one to edit; long-press to delete.">
<View style={styles.headerRow}>
<View style={styles.countRow}>
<FileText size={16} color={c.mutedForeground} />
<Text style={styles.count}>
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
</Text>
</View>
<Button variant="outline" size="sm">Manage</Button>
<Button variant="default" size="sm" onPress={openCreate} icon={<Plus size={14} color={c.primaryForeground} />}>
New
</Button>
</View>
{templates.length === 0 ? (
<Text style={styles.emptyHint}>No templates yet. Tap "New" to add one.</Text>
) : (
<View style={styles.list}>
{templates.map((t) => (
<Pressable
key={t.id}
onPress={() => openEdit(t)}
onLongPress={() => confirmDelete(t)}
style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]}
>
<View style={styles.listRowText}>
<Text style={styles.listRowName} numberOfLines={1}>{t.name}</Text>
{!!t.subject && (
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
)}
</View>
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete}>
<Trash2 size={16} color={c.error} />
</Pressable>
</Pressable>
))}
</View>
)}
</SettingsSection>
<SettingsSection title="Export / Import" description="Back up or restore your templates.">
<SettingsSection title="Export / Import" description="Back up or restore your templates as JSON.">
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={templates.length === 0}
icon={<Download size={14} color={c.text} />}
onPress={() => { void exportTemplatesAsShare(); }}
>
Export
Share JSON
</Button>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
<Button
variant="outline"
size="sm"
icon={<Upload size={14} color={c.text} />}
onPress={() => setImportVisible(true)}
>
Import
</Button>
</View>
</SettingsSection>
{/* Edit/Create modal */}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? 'Edit template' : 'New template'}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Name</Text>
<TextInput
value={draftName}
onChangeText={setDraftName}
placeholder="Quick reply"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Subject</Text>
<TextInput
value={draftSubject}
onChangeText={setDraftSubject}
placeholder="(optional)"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Body</Text>
<TextInput
value={draftBody}
onChangeText={setDraftBody}
placeholder="Hi {recipient_name}, …"
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor}>Cancel</Button>
<Button variant="default" size="sm" onPress={saveDraft}>Save</Button>
</View>
</View>
</View>
</Modal>
{/* Import modal */}
<Modal visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Import templates</Text>
<Pressable onPress={() => setImportVisible(false)} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>JSON</Text>
<TextInput
value={importText}
onChangeText={setImportText}
placeholder='{"templates": [...]}'
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
autoCapitalize="none"
autoCorrect={false}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setImportVisible(false)}>Cancel</Button>
<Button variant="default" size="sm" onPress={performImport} disabled={!importText.trim()}>
Import
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: c.mutedForeground },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
});
container: { gap: spacing.xxxl },
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: c.mutedForeground },
emptyHint: { ...typography.caption, color: c.mutedForeground, paddingVertical: spacing.md },
list: { gap: spacing.sm },
listRow: {
flexDirection: 'row', alignItems: 'center',
borderWidth: 1, borderColor: c.border, borderRadius: radius.md,
padding: spacing.md, gap: spacing.md,
},
listRowPressed: { backgroundColor: c.surface },
listRowText: { flex: 1 },
listRowName: { ...typography.bodyMedium, color: c.text },
listRowSubject: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
listRowDelete: { padding: 4 },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md, flexWrap: 'wrap' },
modalOverlay: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomColor: c.border,
},
modalTitle: { ...typography.h3, color: c.text },
modalBody: { padding: spacing.lg, gap: spacing.sm },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary, marginTop: spacing.sm },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
paddingHorizontal: spacing.md, paddingVertical: 10,
},
bodyInput: { minHeight: 160, textAlignVertical: 'top' },
modalActions: {
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
padding: spacing.lg,
borderTopWidth: 1, borderTopColor: c.border,
},
});
}
+12 -4
View File
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
import { Upload, Check, Palette } from 'lucide-react-native';
import { SettingsSection, SettingItem } 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';
interface Theme {
id: string | null;
@@ -23,8 +24,15 @@ const BUILT_IN: Theme[] = [
export function ThemesSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const active = useSettingsStore((s) => s.activeThemeId);
const update = useSettingsStore((s) => s.updateSetting);
const [themes] = useState<Theme[]>(BUILT_IN);
const [active, setActive] = useState<string | null>(null);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
return (
<SettingsSection
@@ -39,7 +47,7 @@ export function ThemesSettings() {
return (
<Pressable
key={theme.id ?? 'default'}
onPress={() => setActive(theme.id)}
onPress={() => update('activeThemeId', theme.id)}
style={[styles.card, isActive && styles.cardActive]}
>
<View style={styles.preview}>
+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}`);
}
}
+69 -23
View File
@@ -1,15 +1,13 @@
import { Platform } from 'react-native';
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';
function authHeader(): string {
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
return jmapClient.authHeader;
}
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
@@ -19,13 +17,43 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
return `attachment.${fallbackExt}`;
}
// On Android, sharing a `file://` URI from the cache directory throws a
// FileUriExposedException on modern OS versions; the system requires a
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
// via `file.contentUri`, which is the only field iOS doesn't expose.
function shareableUri(file: { uri: string; contentUri?: string }): string {
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
return file.uri;
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// 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(
@@ -37,14 +65,35 @@ 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');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
}
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
// so on both platforms we land the file in the document directory and hand it
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
// uses cache + share, which on most viewers opens directly without prompting.
export async function downloadAttachment(
blobId: string,
name: string | undefined,
type: string | undefined,
): Promise<void> {
const filename = safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await downloadInto(url, dest, Paths.document);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
@@ -52,7 +101,7 @@ export async function shareAttachment(
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();
}
@@ -65,14 +114,11 @@ 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');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
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.
+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`;
}
+23
View File
@@ -11,6 +11,25 @@ import {
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';
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
}
async function emailNotificationsAllowed(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return true; // first launch: default to on
const parsed = JSON.parse(raw) as PushPersistedSettings;
return parsed.emailNotificationsEnabled !== false;
} catch {
return true;
}
}
interface ShowNotificationOptions {
notificationId: string;
@@ -22,6 +41,7 @@ interface ShowNotificationOptions {
emailId: string;
threadId: string;
subject?: string;
accountId: string;
}
interface BulwarkFcmNative {
@@ -33,6 +53,8 @@ interface BulwarkFcmNative {
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
try {
if (!(await emailNotificationsAllowed())) return;
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
@@ -79,6 +101,7 @@ export async function pushBackgroundTask(_data: unknown): Promise<void> {
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
accountId,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
+4
View File
@@ -327,6 +327,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();
}
+36 -12
View File
@@ -35,6 +35,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { useCalendarStore } from '../stores/calendar-store';
import { useSettingsStore } from '../stores/settings-store';
import { MonthView } from '../components/calendar/MonthView';
import { WeekView } from '../components/calendar/WeekView';
import { AgendaView } from '../components/calendar/AgendaView';
@@ -62,15 +63,19 @@ type PendingAction =
const AGENDA_DAYS = 30;
const RANGE_BUFFER_DAYS = 14;
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
function rangeForView(
viewMode: ViewMode,
currentDate: Date,
weekStartsOn: 0 | 1,
): { after: Date; before: Date } {
if (viewMode === 'month') {
const start = startOfWeek(startOfMonth(currentDate));
const end = endOfWeek(endOfMonth(currentDate));
const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
const end = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
const start = startOfWeek(currentDate, { weekStartsOn });
const end = endOfWeek(currentDate, { weekStartsOn });
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
return {
@@ -79,11 +84,15 @@ function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; bef
};
}
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
function headerTitle(
viewMode: ViewMode,
currentDate: Date,
weekStartsOn: 0 | 1,
): string {
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
const start = startOfWeek(currentDate, { weekStartsOn });
const end = endOfWeek(currentDate, { weekStartsOn });
if (start.getMonth() === end.getMonth()) {
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
}
@@ -95,9 +104,20 @@ function headerTitle(viewMode: ViewMode, currentDate: Date): string {
export default function CalendarScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
// Day view falls back to Agenda on mobile (we don't have a dedicated day
// grid yet, but the agenda's 30-day view already serves the same purpose).
const initialViewMode: ViewMode =
calendarDefaultView === 'week' ? 'week'
: calendarDefaultView === 'day' || calendarDefaultView === 'agenda' ? 'agenda'
: 'month';
const [viewMode, setViewMode] = React.useState<ViewMode>(initialViewMode);
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
@@ -144,9 +164,9 @@ export default function CalendarScreen() {
}, [hydrate, fetchCalendarsAction]);
React.useEffect(() => {
const { after, before } = rangeForView(viewMode, currentDate);
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
void ensureRange(after.toISOString(), before.toISOString());
}, [viewMode, currentDate, ensureRange]);
}, [viewMode, currentDate, ensureRange, calendarFirstDayOfWeek]);
const goPrev = React.useCallback(() => {
setCurrentDate((d) => {
@@ -267,7 +287,7 @@ export default function CalendarScreen() {
<Menu size={20} color={c.text} />
</Pressable>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate, calendarFirstDayOfWeek)}</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
</Text>
@@ -321,6 +341,8 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
showWeekNumbers={calendarShowWeekNumbers}
onSelectDate={handleSelectDate}
onLongPressDate={openCreate}
/>
@@ -331,6 +353,8 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
onSelectDate={handleSelectDate}
onSelectEvent={setDetailEvent}
onCreateAtTime={openCreate}
+122 -12
View File
@@ -1,9 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal,
Keyboard, Dimensions, Platform, ActivityIndicator, Alert, Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
@@ -24,6 +24,7 @@ import RichTextEditor, {
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { isGroup } from '../lib/contact-utils';
import {
getContactDisplayName,
@@ -209,6 +210,39 @@ export default function ComposeScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const insets = useSafeAreaInsets();
// Track the visible keyboard obstruction so the format bar stays above it.
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
// measured from the top of the gesture-nav bar rather than from the true
// screen bottom, so we derive obstruction height from `Dimensions.screen`
// instead of trusting `endCoordinates.height` directly. The same trick
// works on iOS for the QuickType / autofill / dictation strip.
const [kbObstruction, setKbObstruction] = React.useState(0);
React.useEffect(() => {
const recompute = (endY: number) => {
const screenH = Dimensions.get('screen').height;
setKbObstruction(Math.max(0, screenH - endY));
};
const subs =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
}),
]
: [
Keyboard.addListener('keyboardDidShow', (e) => {
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
}),
Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)),
];
return () => {
for (const s of subs) s.remove();
};
}, []);
// When the keyboard is up it covers the bottom safe area, so we don't
// need to add it on top — pad by whichever is larger.
const bottomPad = Math.max(kbObstruction, insets.bottom);
const replyTo = route.params?.replyTo;
const mode = route.params?.mode ?? 'compose';
const prefillTo = route.params?.prefillTo;
@@ -221,6 +255,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [identities, setIdentities] = React.useState<Identity[]>([]);
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
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 initialTo = React.useMemo<Recipient[]>(() => {
if (!replyTo) {
@@ -348,7 +388,40 @@ export default function ComposeScreen({ route, navigation }: Props) {
return () => { cancelled = true; };
}, []);
const primaryIdentity = identities[0];
// Choose the identity once we know both the loaded identities and the reply
// context. With auto-select on, prefer the identity that received the
// original message (matches webmail's reply-identity behaviour).
React.useEffect(() => {
if (selectedIdentityId || identities.length === 0) return;
if (autoSelectReplyIdentity && replyTo) {
const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])];
const candidateAddrs = new Set(
candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[],
);
const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase()));
setSelectedIdentityId((matched ?? identities[0]).id);
} else {
setSelectedIdentityId(identities[0].id);
}
}, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]);
const primaryIdentity =
identities.find((i) => i.id === selectedIdentityId) ?? identities[0];
const openIdentityPicker = () => {
if (identities.length <= 1) return;
Alert.alert(
t('email_composer.from', 'From'),
undefined,
[
...identities.map((i) => ({
text: i.name ? `${i.name} <${i.email}>` : i.email,
onPress: () => setSelectedIdentityId(i.id),
})),
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const },
],
);
};
const commitTyped = () => {
const extraTo = parseRecipients(toInput);
@@ -631,8 +704,42 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
// ── Send ─────────────────────────────────────────────────────────────
// Catch the classic "I forgot the attachment" footgun. Only file attachments
// count - inline images don't satisfy the user's intent of attaching a file.
// Returns true if it's safe to proceed, false if the user cancelled.
const passesAttachmentReminder = async (): Promise<boolean> => {
if (!attachmentReminderEnabled) return true;
const hasFileAttachment = attachments.some(
(a) => !a.inline && a.blobId && !a.error,
);
if (hasFileAttachment) return true;
const haystack = `${subject}\n${htmlToPlainText(bodyHtml)}`.toLowerCase();
const matchedKeyword = attachmentReminderKeywords.find((kw) => {
const k = kw.trim().toLowerCase();
if (!k) return false;
// Word-boundary match so 'attach' doesn't fire on 'detached'.
return new RegExp(`\\b${k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(haystack);
});
if (!matchedKeyword) return true;
return new Promise<boolean>((resolve) => {
Alert.alert(
t('email_composer.attachment_reminder_title', 'Forgot an attachment?'),
t(
'email_composer.attachment_reminder_body',
'Your message mentions "{keyword}" but no file is attached. Send anyway?',
).replace('{keyword}', matchedKeyword),
[
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: t('email_composer.send', 'Send'), onPress: () => resolve(true) },
],
);
});
};
const onSend = async () => {
if (!canSend || !primaryIdentity || !sentMailbox) return;
if (!(await passesAttachmentReminder())) return;
setSending(true);
try {
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
@@ -676,7 +783,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
: undefined,
subject,
htmlBody: finalHtml,
// Plain-text mode: skip the HTML part entirely so receiving clients
// render the text/plain alternative without any formatting.
htmlBody: plainTextMode ? undefined : finalHtml,
textBody: finalText,
attachments: outgoing.length ? outgoing : undefined,
inReplyTo: replyTo?.inReplyTo,
@@ -703,7 +812,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
: 'email_composer.new_message';
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn}>
<X size={22} color={c.text} />
@@ -741,10 +850,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</View>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
style={styles.flex}
keyboardShouldPersistTaps="handled"
@@ -752,12 +858,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
<View style={styles.fieldContent}>
<Pressable
onPress={openIdentityPicker}
disabled={identities.length <= 1}
style={styles.fieldContent}
>
<Text style={styles.fromText} numberOfLines={1}>
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
</Text>
<ChevronDown size={14} color={c.textMuted} />
</View>
</Pressable>
</View>
<View style={styles.fieldRow}>
@@ -922,7 +1032,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<ToolbarButton onPress={() => editorRef.current?.exec('redo')}
icon={<Redo2 size={18} color={c.textSecondary} />} />
</ScrollView>
</KeyboardAvoidingView>
</View>
<Modal
visible={linkPromptVisible}
+65 -16
View File
@@ -8,6 +8,7 @@ import {
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useTypography, useDensity } from '../theme/dynamic';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
@@ -56,12 +57,16 @@ function formatRelativeTime(date: Date): string {
const EmailRow = React.memo(function EmailRow({
item,
threadCount,
showPreview,
onPress,
onLongPress,
selected,
selectionMode,
}: {
item: Email;
threadCount: number;
showPreview: boolean;
onPress: (id: string) => void;
onLongPress: (id: string) => void;
selected: boolean;
@@ -69,6 +74,8 @@ const EmailRow = React.memo(function EmailRow({
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dyn = useTypography();
const density = useDensity();
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -81,6 +88,7 @@ const EmailRow = React.memo(function EmailRow({
<Pressable
style={({ pressed }) => [
styles.emailRow,
{ paddingVertical: density.rowPaddingVertical },
pressed && styles.emailRowPressed,
selected && styles.emailRowSelected,
]}
@@ -97,14 +105,16 @@ const EmailRow = React.memo(function EmailRow({
)}
</View>
)}
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
{density.showAvatar && (
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
)}
{/* Content */}
<View style={styles.emailContent}>
{/* Row 1: Sender + indicators + time */}
<View style={styles.emailHeaderRow}>
<View style={styles.senderRow}>
<Text style={[styles.emailFrom, unread && styles.textUnread]} numberOfLines={1}>
<Text style={[styles.emailFrom, dyn.bodyMedium, unread && styles.textUnread]} numberOfLines={1}>
{senderName}
</Text>
{starred && (
@@ -115,21 +125,28 @@ const EmailRow = React.memo(function EmailRow({
)}
</View>
<View style={styles.timeAndTag}>
<Text style={styles.emailDate}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
{threadCount > 1 && (
<View style={styles.threadBadge}>
<Text style={styles.threadBadgeText}>{threadCount}</Text>
</View>
)}
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
</View>
</View>
{/* Row 2: Subject */}
<View style={styles.subjectRow}>
<Text style={[styles.emailSubject, unread && styles.textBold]} numberOfLines={1}>
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
</View>
{/* Row 3: Preview */}
<Text style={styles.emailPreview} numberOfLines={2}>
{item.preview}
</Text>
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
{showPreview && density.showPreview && (
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
{item.preview}
</Text>
)}
</View>
</Pressable>
);
@@ -177,8 +194,37 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const showPreview = useSettingsStore((s) => s.showPreview);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const networkOnline = useNetworkStore((s) => s.online);
// When threading is on, collapse same-thread emails so the list shows the
// most recent message per thread with a count badge. Disabling threading
// falls back to the flat list (every message is its own row).
const visibleEmails = React.useMemo(() => {
if (disableThreading) return emails;
const seen = new Set<string>();
const out: Email[] = [];
for (const e of emails) {
const key = e.threadId || e.id;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}, [emails, disableThreading]);
const threadCounts = React.useMemo(() => {
if (disableThreading) return new Map<string, number>();
const counts = new Map<string, number>();
for (const e of emails) {
const key = e.threadId || e.id;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}, [emails, disableThreading]);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
[mailboxes],
@@ -199,7 +245,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
const allSelected =
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(e.id));
// Refs so row press handlers stay referentially stable across renders.
// FlatList rows then skip re-render when the parent re-renders for unrelated
@@ -274,11 +320,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
mode={swipeMode}
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
onAction={(action) => handleSwipeAction(item.id, action)}
>
<EmailRow
item={item}
threadCount={threadCounts.get(item.threadId || item.id) ?? 1}
showPreview={showPreview}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
@@ -286,7 +335,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
</SwipeableRow>
),
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
@@ -295,11 +344,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const toggleSelectAllVisible = React.useCallback(() => {
setSelectedIds((prev) => {
const allCurrent = emails.length > 0 && emails.every((e) => prev.has(e.id));
const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(e.id));
if (allCurrent) return new Set();
return new Set(emails.map((e) => e.id));
return new Set(visibleEmails.map((e) => e.id));
});
}, [emails]);
}, [visibleEmails]);
// Clear selection when mailbox changes
React.useEffect(() => {
@@ -307,8 +356,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [currentMailboxId]);
const selectedEmails = React.useMemo(
() => emails.filter((e) => selectedIds.has(e.id)),
[emails, selectedIds],
() => visibleEmails.filter((e) => selectedIds.has(e.id)),
[visibleEmails, selectedIds],
);
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
@@ -619,7 +668,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</View>
) : (
<FlatList
data={emails}
data={visibleEmails}
keyExtractor={emailKeyExtractor}
renderItem={renderEmailRow}
ItemSeparatorComponent={EmailRowSeparator}
+102 -62
View File
@@ -17,8 +17,9 @@ import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment } from '../lib/email-export';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Email, Mailbox } from '../api/types';
@@ -84,20 +85,87 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const onPressAttachment = React.useCallback(
async (blobId: string, name: string | undefined, type: string | undefined) => {
if (downloadingBlobId) return;
setDownloadingBlobId(blobId);
try {
await shareAttachment(blobId, name, type);
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type);
} else {
await shareAttachment(blobId, name, type);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
setDownloadingBlobId(null);
}
},
[downloadingBlobId],
[downloadingBlobId, mailAttachmentAction],
);
const renderAttachments = () => {
const all = email?.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
@@ -105,6 +173,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
setLoading(true);
setError(null);
void (async () => {
@@ -113,7 +182,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (cancelled) return;
setEmail(fetched);
if (fetched && !fetched.keywords?.$seen) {
void markRead(emailId);
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(emailId);
}, markAsReadDelay);
} else {
void markRead(emailId);
}
}
} catch (e) {
if (!cancelled) {
@@ -123,8 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [emailId, getEmailDetail, markRead]);
return () => {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [emailId, getEmailDetail, markRead, markAsReadDelay]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -350,59 +428,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips */}
{email.attachments && email.attachments.length > 0 && (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{(attachmentsExpanded
? email.attachments
: email.attachments.slice(0, 3)
).map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{email.attachments.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded
? 'Show less'
: `Show all (${email.attachments.length})`}
</Text>
</Pressable>
)}
</View>
)}
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Body */}
<View style={styles.bodyBlock}>
@@ -736,10 +766,16 @@ function ToolbarButton({
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const showLabels = useSettingsStore((s) => s.showToolbarLabels);
return (
<Pressable onPress={onPress} style={styles.toolbarAction} hitSlop={6}>
<Pressable
onPress={onPress}
style={styles.toolbarAction}
hitSlop={6}
accessibilityLabel={label}
>
{icon}
<Text style={styles.toolbarActionLabel}>{label}</Text>
{showLabels && <Text style={styles.toolbarActionLabel}>{label}</Text>}
</Pressable>
);
}
@@ -749,16 +785,20 @@ function BottomBarButton({
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const showLabels = useSettingsStore((s) => s.showToolbarLabels);
return (
<Pressable
onPress={disabled ? undefined : onPress}
style={[styles.bottomBarBtn, disabled && styles.bottomBarBtnDisabled]}
hitSlop={4}
accessibilityLabel={label}
>
{icon}
<Text style={[styles.bottomBarLabel, disabled && styles.bottomBarLabelDisabled]}>
{label}
</Text>
{showLabels && (
<Text style={[styles.bottomBarLabel, disabled && styles.bottomBarLabelDisabled]}>
{label}
</Text>
)}
</Pressable>
);
}
File diff suppressed because it is too large Load Diff
+4 -1
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;
@@ -156,7 +159,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
{/* 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>
+14 -7
View File
@@ -210,14 +210,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>
@@ -310,6 +316,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 },
+57 -20
View File
@@ -1,42 +1,62 @@
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),
}));
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 +103,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 +117,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', () => {
+95 -7
View File
@@ -1,5 +1,5 @@
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';
@@ -39,6 +39,7 @@ export interface AuthState {
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
restoreSession: () => Promise<boolean>;
retrySession: () => Promise<boolean>;
clearError: () => void;
}
@@ -286,15 +287,56 @@ 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;
try {
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
// No stored credentials (or corrupt) — genuine logout.
accountStore.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);
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 +351,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 }),
}));
+124 -10
View File
@@ -1,7 +1,8 @@
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,
queryEmails,
@@ -16,6 +17,7 @@ import {
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
import { useSettingsStore } from './settings-store';
import { useOfflineCacheStore } from './offline-cache-store';
export interface EmailFilters {
from?: string;
@@ -56,6 +58,7 @@ export interface EmailState {
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>;
@@ -149,14 +152,43 @@ export const useEmailStore = create<EmailState>()(
filters: {},
pendingUndo: null,
});
// Seed from the offline cache immediately so changing folders shows
// something even before the network responds — and is the fallback
// when offline. The network result will replace this once it arrives.
const cacheStore = useOfflineCacheStore.getState();
if (!cacheStore.hydrated) await cacheStore.hydrate();
const limit = useSettingsStore.getState().emailsPerPage;
let cached: Email[] = [];
if (cacheStore.totalCount() > 0) {
try {
cached = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
if (cached.length > 0 && get().currentMailboxId === mailboxId) {
set({ emails: cached, totalEmails: cached.length });
}
} catch (err) {
console.warn('[email-store] cache seed failed:', err);
}
}
try {
const filter = buildJmapFilter(mailboxId, '', {});
const { ids, total } = await queryEmails(mailboxId, { limit: 50, filter });
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
// Race guard: don't overwrite if the user already moved to a different
// mailbox while this request was in flight.
if (get().currentMailboxId !== mailboxId) return;
set({ emails, totalEmails: total, loading: false });
} catch (err) {
console.warn('[email-store] selectMailbox failed:', err);
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
if (get().currentMailboxId !== mailboxId) return;
// Keep whatever the cache seeded with; only surface the error when the
// cache is empty. With cached emails visible the OfflineBanner already
// tells the user the data is stale.
set({
loading: false,
error: cached.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'),
});
}
},
@@ -167,9 +199,10 @@ export const useEmailStore = create<EmailState>()(
set({ loading: true });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const { ids } = await queryEmails(currentMailboxId, {
position: emails.length,
limit: 50,
limit,
filter,
});
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
@@ -180,22 +213,63 @@ export const useEmailStore = create<EmailState>()(
},
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters } = get();
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
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.
set({ loading: true, error: null });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
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 });
} catch (err) {
console.warn('[email-store] refreshEmails failed:', err);
// If the list was empty before the refresh attempt, fall back to the
// offline cache so the user still sees something. Otherwise keep what
// they had visible and let the OfflineBanner explain.
if (existing.length === 0) {
try {
const cacheStore = useOfflineCacheStore.getState();
if (!cacheStore.hydrated) await cacheStore.hydrate();
if (cacheStore.totalCount() > 0) {
const limit = useSettingsStore.getState().emailsPerPage;
const cached = await cacheStore.getEmailsInMailbox(
currentMailboxId,
Math.max(limit, 50),
);
if (get().currentMailboxId === currentMailboxId && cached.length > 0) {
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: err instanceof Error ? err.message : 'Failed to load emails' });
}
},
handleStateChange: async (change) => {
if (!jmapClient.currentSession) 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();
}
},
setSearchQuery: (query) => {
set({ searchQuery: query });
void get().refreshEmails();
@@ -214,7 +288,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) => {
@@ -335,14 +429,34 @@ export const useEmailStore = create<EmailState>()(
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const isPermanent = currentMailboxId === trashMailboxId;
const settings = useSettingsStore.getState();
const { mailboxes } = get();
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
const inJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const inTrash = currentMailboxId === trashMailboxId;
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
// Resolve effective destination:
// - already in trash → must destroy (no further folder to move to)
// - in junk and the user opted to skip the trash for junk → destroy
// - the user set 'permanent' as the global default → destroy
// - otherwise → move to trash and offer undo
const destroy =
inTrash ||
settings.deleteAction === 'permanent' ||
(settings.permanentlyDeleteJunk && inJunk);
if (destroy) {
// Use the trash mailbox as the "current" so apiDeleteEmail takes the
// destroy branch even when the source folder isn't trash.
await apiDeleteEmail(emailId, trashMailboxId, trashMailboxId);
} else {
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
}
set({ emails: get().emails.filter((e) => e.id !== emailId) });
// Permanent destroy can't be undone - skip the snackbar so we don't
// promise an undo we can't deliver.
if (email && original && !isPermanent) {
if (email && original && !destroy) {
set({
pendingUndo: {
kind: 'delete',
+207
View File
@@ -0,0 +1,207 @@
// 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.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email } from '../api/types';
const INDEX_KEY = 'webmail:offline-cache:index:v1';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v1:';
function entryKey(id: string): string {
return `${ENTRY_KEY_PREFIX}${id}`;
}
export interface OfflineCacheIndexEntry {
id: string;
receivedAt: string;
size: number; // approx bytes of cached payload
cachedAt: number;
}
interface CacheIndex {
// Per-account is keyed at the storage layer (one app session = one account
// active at a time). Cross-account separation is left to a future change.
entries: Record<string, OfflineCacheIndexEntry>;
}
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 };
interface OfflineCacheState {
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;
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(index: CacheIndex): void {
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
console.warn('[offline-cache] persist index failed', err);
});
}
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
index: { entries: {} },
hydrated: false,
sync: { ...IDLE },
abortRequested: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(INDEX_KEY);
if (raw) {
const parsed = JSON.parse(raw) as CacheIndex;
if (parsed && typeof parsed === 'object' && parsed.entries) {
set({ index: parsed, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[offline-cache] hydrate failed', err);
}
set({ hydrated: true });
},
has: (id) => Boolean(get().index.entries[id]),
get: async (id) => {
if (!get().index.entries[id]) return null;
try {
const raw = await AsyncStorage.getItem(entryKey(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) => {
try {
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
} catch (err) {
console.warn('[offline-cache] put failed', email.id, err);
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(index);
},
remove: async (ids) => {
if (ids.length === 0) return;
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
);
const next = { ...get().index.entries };
for (const id of ids) delete next[id];
const index = { entries: next };
set({ index });
persistIndex(index);
},
clearAll: async () => {
const ids = Object.keys(get().index.entries);
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
);
const index = { entries: {} };
set({ index, sync: { ...IDLE } });
persistIndex(index);
},
getEmailsInMailbox: async (mailboxId, limit = 200) => {
// Sort the index by receivedAt descending first so we don't have to read
// entries we won't return — the index already carries receivedAt.
const sorted = Object.values(get().index.entries).sort((a, b) => {
const 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(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;
},
}));
+242 -40
View File
@@ -7,9 +7,10 @@ export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
export type FontSize = 'small' | 'medium' | 'large';
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
const STORAGE_KEY = 'webmail:settings:v1';
export type DeleteAction = 'trash' | 'permanent';
export type MailLayout = 'split' | 'focus';
export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type SwipeAction =
| 'none'
| 'archive'
@@ -19,13 +20,40 @@ export type SwipeAction =
| 'star'
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
export type ArchiveMode = 'single' | 'year' | 'month';
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
export type FirstDayOfWeek = 0 | 1;
export type TimeFormat = '12h' | '24h';
export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off';
export type FilesFolderLayout = 'inline' | 'sidebar';
export type FilesViewMode = 'list' | 'grid';
export type FilesSortKey = 'name' | 'size' | 'modified';
export type FilesSortDir = 'asc' | 'desc';
export type NotificationSound = 'default' | 'chime' | 'ping' | 'pop' | 'none';
const STORAGE_KEY = 'webmail:settings:v1';
export interface SidebarApp {
id: string;
name: string;
url: string;
icon: string;
openMode: 'tab' | 'inline';
showOnMobile: boolean;
}
interface PersistedSettings {
// Privacy & content
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
trustedSendersAddressBook: boolean;
senderFavicons: boolean;
hideInlineImageAttachments: boolean;
// Contacts
groupContactsByLetter: boolean;
// Appearance
theme: ThemeMode;
fontSize: FontSize;
@@ -33,34 +61,162 @@ interface PersistedSettings {
showToolbarLabels: boolean;
animationsEnabled: boolean;
emailAlwaysLightMode: boolean;
activeThemeId: string | null;
// Composing
autoSelectReplyIdentity: boolean;
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
plainTextMode: boolean;
// Reading
markAsReadDelay: number;
deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean;
showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number;
disableThreading: boolean;
mailAttachmentAction: MailAttachmentAction;
attachmentPosition: AttachmentPosition;
// Layout / list interactions
swipeLeftAction: SwipeAction;
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Archive
archiveMode: ArchiveMode;
// Calendar
calendarDefaultView: CalendarView;
calendarFirstDayOfWeek: FirstDayOfWeek;
calendarTimeFormat: TimeFormat;
calendarShowTimeInMonth: boolean;
calendarShowWeekNumbers: boolean;
calendarHoverPreview: CalendarHoverPreview;
showBirthdayCalendar: boolean;
enableCalendarTasks: boolean;
showTasksOnCalendar: boolean;
// Files
filesFolderLayout: FilesFolderLayout;
filesDefaultViewMode: FilesViewMode;
filesDefaultSortKey: FilesSortKey;
filesDefaultSortDir: FilesSortDir;
filesShowIcons: boolean;
filesColoredIcons: boolean;
filesShowThumbnails: boolean;
filesShowHiddenFiles: boolean;
// Notifications
notificationSoundChoice: NotificationSound;
emailNotificationsEnabled: boolean;
emailNotificationSound: boolean;
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
calendarInvitationParsingEnabled: boolean;
// Sidebar apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
// S/MIME defaults (key/cert lists are server-managed; only UI-level prefs persist)
smimeDefaultEncrypt: boolean;
smimeRememberUnlocked: boolean;
smimeAutoImport: boolean;
// Filters UI state
filtersExpandedView: boolean;
// 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 = {
externalContentPolicy: 'ask',
trustedSenders: [],
trustedSendersAddressBook: false,
senderFavicons: true,
hideInlineImageAttachments: true,
groupContactsByLetter: true,
theme: 'system',
fontSize: 'medium',
density: 'regular',
showToolbarLabels: true,
animationsEnabled: true,
emailAlwaysLightMode: false,
activeThemeId: null,
autoSelectReplyIdentity: true,
attachmentReminderEnabled: true,
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
plainTextMode: false,
markAsReadDelay: 0,
deleteAction: 'trash',
permanentlyDeleteJunk: false,
showPreview: true,
mailLayout: 'split',
emailsPerPage: 25,
disableThreading: false,
mailAttachmentAction: 'preview',
attachmentPosition: 'beside-sender',
swipeLeftAction: 'archive',
swipeRightAction: 'read',
swipeMode: 'instant',
archiveMode: 'single',
calendarDefaultView: 'month',
calendarFirstDayOfWeek: 1,
calendarTimeFormat: '24h',
calendarShowTimeInMonth: true,
calendarShowWeekNumbers: false,
calendarHoverPreview: 'delay-500ms',
showBirthdayCalendar: true,
enableCalendarTasks: false,
showTasksOnCalendar: true,
filesFolderLayout: 'inline',
filesDefaultViewMode: 'list',
filesDefaultSortKey: 'name',
filesDefaultSortDir: 'asc',
filesShowIcons: true,
filesColoredIcons: true,
filesShowThumbnails: true,
filesShowHiddenFiles: false,
notificationSoundChoice: 'default',
emailNotificationsEnabled: true,
emailNotificationSound: true,
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
calendarInvitationParsingEnabled: true,
sidebarApps: [],
keepAppsLoaded: false,
smimeDefaultEncrypt: false,
smimeRememberUnlocked: false,
smimeAutoImport: true,
filtersExpandedView: false,
pluginEnabled: {},
offlineCacheEnabled: false,
offlineCacheDays: 7,
};
export interface SettingsState extends PersistedSettings {
@@ -71,6 +227,14 @@ export interface SettingsState extends PersistedSettings {
fetchIdentities: () => Promise<void>;
hydrate: () => Promise<void>;
// Generic setter — preferred for new code.
updateSetting: <K extends keyof PersistedSettings>(
key: K,
value: PersistedSettings[K],
) => void;
// Legacy named setters — preserved so existing call sites keep working.
setExternalContentPolicy: (policy: ExternalContentPolicy) => void;
setSenderFavicons: (enabled: boolean) => void;
setGroupContactsByLetter: (enabled: boolean) => void;
@@ -85,32 +249,36 @@ export interface SettingsState extends PersistedSettings {
setAttachmentReminderKeywords: (keywords: string[]) => void;
setSwipeLeftAction: (action: SwipeAction) => void;
setSwipeRightAction: (action: SwipeAction) => void;
setSwipeMode: (mode: SwipeMode) => void;
setArchiveMode: (mode: ArchiveMode) => void;
// Trusted senders
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
// Sidebar apps
addSidebarApp: (app: Omit<SidebarApp, 'id'>) => void;
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
removeSidebarApp: (id: string) => void;
reorderSidebarApps: (apps: SidebarApp[]) => void;
// Plugins
setPluginEnabled: (id: string, enabled: boolean) => void;
reset: () => void;
}
const PERSIST_KEYS: (keyof PersistedSettings)[] = Object.keys(
DEFAULT_PERSISTED,
) as (keyof PersistedSettings)[];
function snapshot(state: SettingsState): PersistedSettings {
return {
externalContentPolicy: state.externalContentPolicy,
trustedSenders: state.trustedSenders,
senderFavicons: state.senderFavicons,
groupContactsByLetter: state.groupContactsByLetter,
theme: state.theme,
fontSize: state.fontSize,
density: state.density,
showToolbarLabels: state.showToolbarLabels,
animationsEnabled: state.animationsEnabled,
emailAlwaysLightMode: state.emailAlwaysLightMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
swipeLeftAction: state.swipeLeftAction,
swipeRightAction: state.swipeRightAction,
archiveMode: state.archiveMode,
};
const out: Record<string, unknown> = {};
for (const k of PERSIST_KEYS) {
out[k] = state[k];
}
return out as unknown as PersistedSettings;
}
function persist(state: PersistedSettings): void {
@@ -119,6 +287,24 @@ function persist(state: PersistedSettings): void {
});
}
function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSettings {
const out: Record<string, unknown> = { ...DEFAULT_PERSISTED };
for (const k of PERSIST_KEYS) {
const v = parsed[k];
if (v === undefined || v === null) continue;
const def = DEFAULT_PERSISTED[k];
// Type-tolerant merge: only adopt when the basic shape matches the default.
if (Array.isArray(def)) {
if (Array.isArray(v)) out[k] = v;
} else if (typeof def === 'object') {
if (typeof v === 'object' && !Array.isArray(v)) out[k] = v;
} else if (typeof def === typeof v) {
out[k] = v;
}
}
return out as unknown as PersistedSettings;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
...DEFAULT_PERSISTED,
identities: [],
@@ -142,25 +328,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Partial<PersistedSettings>;
set({
externalContentPolicy: parsed.externalContentPolicy ?? DEFAULT_PERSISTED.externalContentPolicy,
trustedSenders: Array.isArray(parsed.trustedSenders) ? parsed.trustedSenders : [],
senderFavicons: typeof parsed.senderFavicons === 'boolean' ? parsed.senderFavicons : DEFAULT_PERSISTED.senderFavicons,
groupContactsByLetter: typeof parsed.groupContactsByLetter === 'boolean' ? parsed.groupContactsByLetter : DEFAULT_PERSISTED.groupContactsByLetter,
theme: parsed.theme ?? DEFAULT_PERSISTED.theme,
fontSize: parsed.fontSize ?? DEFAULT_PERSISTED.fontSize,
density: parsed.density ?? DEFAULT_PERSISTED.density,
showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels,
animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled,
emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode,
autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity,
attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled,
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode,
hydrated: true,
});
set({ ...mergeWithDefaults(parsed), hydrated: true });
return;
}
} catch (err) {
@@ -169,6 +337,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
set({ hydrated: true });
},
updateSetting: (key, value) => {
set({ [key]: value } as Partial<SettingsState>);
persist(snapshot(get()));
},
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); },
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); },
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },
@@ -183,6 +356,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
addTrustedSender: (email) => {
@@ -205,6 +379,34 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
return get().trustedSenders.includes(normalized);
},
addSidebarApp: (app) => {
const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });
persist(snapshot(get()));
},
updateSidebarApp: (id, updates) => {
set({
sidebarApps: get().sidebarApps.map((a) => (a.id === id ? { ...a, ...updates } : a)),
});
persist(snapshot(get()));
},
removeSidebarApp: (id) => {
set({ sidebarApps: get().sidebarApps.filter((a) => a.id !== id) });
persist(snapshot(get()));
},
reorderSidebarApps: (apps) => {
set({ sidebarApps: apps });
persist(snapshot(get()));
},
setPluginEnabled: (id, enabled) => {
set({ pluginEnabled: { ...get().pluginEnabled, [id]: enabled } });
persist(snapshot(get()));
},
reset: () => set({
identities: [],
loading: false,
+138
View File
@@ -0,0 +1,138 @@
// Mobile-side templates store. Mirrors the webmail's `template-store` but
// keeps the surface minimal — categories, defaults, placeholders, and the
// favourite/recent tracking are not yet exposed in the mobile UI. We persist
// the same shape so an export from one platform imports cleanly into the
// other.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface EmailTemplate {
id: string;
name: string;
subject: string;
body: string;
category: string;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
const STORAGE_KEY = 'webmail:templates:v1';
interface TemplatesState {
templates: EmailTemplate[];
hydrated: boolean;
hydrate: () => Promise<void>;
addTemplate: (data: Pick<EmailTemplate, 'name' | 'subject' | 'body'> & Partial<Pick<EmailTemplate, 'category' | 'isFavorite'>>) => EmailTemplate;
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
deleteTemplate: (id: string) => void;
exportAll: () => string;
importTemplates: (json: string) => { count: number; error?: string };
}
function persist(state: EmailTemplate[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ templates: state })).catch((err) => {
console.warn('[templates-store] persist failed', err);
});
}
function genId(): string {
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export const useTemplatesStore = create<TemplatesState>((set, get) => ({
templates: [],
hydrated: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as { templates?: EmailTemplate[] };
if (Array.isArray(parsed.templates)) {
set({ templates: parsed.templates, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[templates-store] hydrate failed', err);
}
set({ hydrated: true });
},
addTemplate: (data) => {
const now = new Date().toISOString();
const template: EmailTemplate = {
id: genId(),
name: data.name,
subject: data.subject,
body: data.body,
category: data.category ?? '',
isFavorite: data.isFavorite ?? false,
createdAt: now,
updatedAt: now,
};
const next = [...get().templates, template];
set({ templates: next });
persist(next);
return template;
},
updateTemplate: (id, updates) => {
const next = get().templates.map((t) =>
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t,
);
set({ templates: next });
persist(next);
},
deleteTemplate: (id) => {
const next = get().templates.filter((t) => t.id !== id);
set({ templates: next });
persist(next);
},
exportAll: () => {
return JSON.stringify({ version: 1, templates: get().templates }, null, 2);
},
importTemplates: (json) => {
try {
const parsed = JSON.parse(json) as { templates?: unknown };
if (!Array.isArray(parsed.templates)) {
return { count: 0, error: 'Invalid template file: expected `templates` array' };
}
const incoming: EmailTemplate[] = [];
for (const raw of parsed.templates) {
if (!raw || typeof raw !== 'object') continue;
const t = raw as Partial<EmailTemplate>;
if (typeof t.name !== 'string' || typeof t.body !== 'string') continue;
const now = new Date().toISOString();
incoming.push({
id: typeof t.id === 'string' ? t.id : genId(),
name: t.name,
subject: typeof t.subject === 'string' ? t.subject : '',
body: t.body,
category: typeof t.category === 'string' ? t.category : '',
isFavorite: !!t.isFavorite,
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
});
}
const existing = get().templates;
const existingIds = new Set(existing.map((t) => t.id));
const merged = [
...existing,
...incoming.map((t) => (existingIds.has(t.id) ? { ...t, id: genId() } : t)),
];
set({ templates: merged });
persist(merged);
return { count: incoming.length };
} catch (err) {
return { count: 0, error: err instanceof Error ? err.message : 'Parse failed' };
}
},
}));
+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({
+66
View File
@@ -0,0 +1,66 @@
// 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),
}));
+98
View File
@@ -0,0 +1,98 @@
// Dynamic theme hooks that read settings the user can change at runtime.
// The static tokens in `./tokens` cover the baseline; these hooks return
// scaled / preference-driven variants so screens can react to settings
// without a relaunch.
import { useMemo } from 'react';
import { useSettingsStore } from '../stores/settings-store';
import { typography as baseTypography, spacing as baseSpacing } from './tokens';
export type FontScale = 'small' | 'medium' | 'large';
export type DensityKind = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
const FONT_SCALE: Record<FontScale, number> = {
small: 0.92,
medium: 1,
large: 1.12,
};
// How tall an email-list-style row should be. Mirrors the webmail
// `--density-item-py` token, halved for the per-side padding.
const ROW_PADY: Record<DensityKind, number> = {
'extra-compact': 4,
compact: 8,
regular: 12,
comfortable: 16,
};
// Vertical gap between rows (used for the FlatList separator strength too).
const ROW_GAP: Record<DensityKind, number> = {
'extra-compact': 2,
compact: 6,
regular: 10,
comfortable: 14,
};
function scaleStyle(
style: { fontSize: number; lineHeight: number; fontWeight: '400' | '500' | '600' | '700' },
factor: number,
) {
return {
fontSize: Math.round(style.fontSize * factor),
lineHeight: Math.round(style.lineHeight * factor),
fontWeight: style.fontWeight,
};
}
export function useTypography() {
const fontSize = useSettingsStore((s) => s.fontSize);
return useMemo(() => {
const f = FONT_SCALE[fontSize] ?? 1;
if (f === 1) return baseTypography;
return {
h1: scaleStyle(baseTypography.h1, f),
h2: scaleStyle(baseTypography.h2, f),
h3: scaleStyle(baseTypography.h3, f),
body: scaleStyle(baseTypography.body, f),
bodyMedium: scaleStyle(baseTypography.bodyMedium, f),
bodySemibold: scaleStyle(baseTypography.bodySemibold, f),
bodyBold: scaleStyle(baseTypography.bodyBold, f),
base: scaleStyle(baseTypography.base, f),
baseMedium: scaleStyle(baseTypography.baseMedium, f),
caption: scaleStyle(baseTypography.caption, f),
captionMedium: scaleStyle(baseTypography.captionMedium, f),
small: scaleStyle(baseTypography.small, f),
tabLabel: scaleStyle(baseTypography.tabLabel, f),
};
}, [fontSize]);
}
export function useDensity() {
const density = useSettingsStore((s) => s.density);
return useMemo(
() => ({
kind: density,
rowPaddingVertical: ROW_PADY[density] ?? ROW_PADY.regular,
rowGap: ROW_GAP[density] ?? ROW_GAP.regular,
// Compact modes hide secondary content so rows breathe less. The
// webmail uses extra-compact to drop preview lines entirely.
showPreview: density !== 'extra-compact' && density !== 'compact',
showAvatar: density !== 'extra-compact',
verticalSpacing: Math.max(2, baseSpacing.sm - (4 - ROW_PADY[density] / 4)),
}),
[density],
);
}
// Returns whether the user has opted into in-app animations. When false,
// callers should pass duration=0 to `Animated.timing` so transitions snap.
export function useShouldAnimate() {
return useSettingsStore((s) => s.animationsEnabled);
}
// Convenience: returns `requested` when animations are on, otherwise 0.
// Use as `Animated.timing(v, { duration: useAnimDuration(240), ... })`.
export function useAnimDuration(requested: number): number {
const enabled = useShouldAnimate();
return enabled ? requested : 0;
}
+1
View File
@@ -5,5 +5,6 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/__tests__/**/*.test.ts'],
setupFiles: ['src/test-setup.ts'],
},
});