mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2911b8e7ac | ||
|
|
3d4779a689 | ||
|
|
641fde0505 | ||
|
|
6d5195d9fa |
@@ -41,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';
|
||||
|
||||
@@ -76,6 +79,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -218,6 +222,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.
|
||||
|
||||
@@ -11,16 +11,17 @@ 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 okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
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 java.util.concurrent.TimeUnit
|
||||
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
|
||||
@@ -36,10 +37,11 @@ import kotlin.concurrent.thread
|
||||
* 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 OkHttp that injects the picked
|
||||
* cert into the TLS handshake. JS-side `fetch` cannot present client
|
||||
* certs because RN's bundled OkHttpClient doesn't expose its KeyManager
|
||||
* hook to userland; this method is the workaround.
|
||||
* 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) {
|
||||
@@ -48,8 +50,8 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
|
||||
@Volatile private var cachedClient: OkHttpClient? = null
|
||||
@Volatile private var cachedClientForAlias: String? = null
|
||||
@Volatile private var cachedFactory: SSLSocketFactory? = null
|
||||
@Volatile private var cachedFactoryForAlias: String? = null
|
||||
|
||||
override fun getName(): String = MODULE_NAME
|
||||
|
||||
@@ -64,32 +66,34 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
fun clearAlias(promise: Promise) {
|
||||
prefs.edit().remove(PREF_ALIAS).apply()
|
||||
cachedAlias = null
|
||||
invalidateClient()
|
||||
invalidateFactory()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*
|
||||
* @param host optional host hint shown by the picker
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
val activity = currentActivity
|
||||
// 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. We persist + resolve directly.
|
||||
// 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
|
||||
invalidateClient()
|
||||
invalidateFactory()
|
||||
promise.resolve(alias)
|
||||
}
|
||||
try {
|
||||
@@ -125,65 +129,70 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
promise.reject("bad_args", "url is required")
|
||||
return
|
||||
}
|
||||
val method = request.getString("method") ?: "GET"
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs").toLong() else 30_000L
|
||||
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 contentType = headersMap
|
||||
?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
var ct: String? = null
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
if (k.equals("Content-Type", ignoreCase = true)) {
|
||||
ct = h.getString(k)
|
||||
break
|
||||
}
|
||||
}
|
||||
ct
|
||||
}
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
// Run on a worker thread so we don't block the JS thread on socket IO.
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val client = buildOrReuseClient(timeoutMs)
|
||||
val builder = Request.Builder().url(url)
|
||||
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) builder.addHeader(k, v)
|
||||
if (v != null) conn.setRequestProperty(k, v)
|
||||
}
|
||||
}
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
val body = when {
|
||||
bodyBytes != null -> bodyBytes.toRequestBody(contentType?.toMediaTypeOrNull())
|
||||
methodRequiresBody(method) -> ByteArray(0).toRequestBody(contentType?.toMediaTypeOrNull())
|
||||
else -> null
|
||||
conn.connect()
|
||||
if (bodyBytes != null) {
|
||||
conn.outputStream.use { out -> out.write(bodyBytes) }
|
||||
}
|
||||
builder.method(method.uppercase(), body)
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", response.code)
|
||||
out.putString("statusText", response.message)
|
||||
val headers = Arguments.createMap()
|
||||
response.headers.forEach { (name, value) ->
|
||||
// OkHttp can return multiple values per header; we
|
||||
// join with ", " which is the standard wire format
|
||||
// for repeated header values.
|
||||
if (headers.hasKey(name)) {
|
||||
val existing = headers.getString(name)
|
||||
headers.putString(name, "$existing, $value")
|
||||
} else {
|
||||
headers.putString(name, value)
|
||||
}
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
val responseBytes = response.body?.bytes() ?: ByteArray(0)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
promise.resolve(out)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -192,49 +201,35 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateClient() {
|
||||
cachedClient = null
|
||||
cachedClientForAlias = null
|
||||
private fun invalidateFactory() {
|
||||
cachedFactory = null
|
||||
cachedFactoryForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseClient(timeoutMs: Long): OkHttpClient {
|
||||
val alias = cachedAlias
|
||||
val existing = cachedClient
|
||||
if (existing != null && cachedClientForAlias == alias) return existing
|
||||
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 builder = OkHttpClient.Builder()
|
||||
.callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (alias != null) {
|
||||
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)
|
||||
builder.sslSocketFactory(sslContext.socketFactory, tm)
|
||||
}
|
||||
|
||||
val client = builder.build()
|
||||
cachedClient = client
|
||||
cachedClientForAlias = alias
|
||||
return client
|
||||
}
|
||||
|
||||
private fun methodRequiresBody(method: String): Boolean = when (method.uppercase()) {
|
||||
"POST", "PUT", "PATCH", "DELETE" -> true
|
||||
else -> false
|
||||
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 on the OkHttp dispatcher thread, which is fine since the
|
||||
* them lazily during the TLS handshake, which is fine since the
|
||||
* fetch already runs off-main-thread.
|
||||
*/
|
||||
private class KeyChainKeyManager(
|
||||
@@ -251,13 +246,13 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: javax.net.ssl.SSLEngine?,
|
||||
engine: SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -265,7 +260,7 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.23",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.23",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -135,6 +135,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(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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 { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-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 sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
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]}>
|
||||
<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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -16,6 +16,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;
|
||||
@@ -149,15 +150,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 limit = useSettingsStore.getState().emailsPerPage;
|
||||
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'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -182,7 +211,7 @@ 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.
|
||||
@@ -195,6 +224,28 @@ export const useEmailStore = create<EmailState>()(
|
||||
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' });
|
||||
}
|
||||
},
|
||||
@@ -217,7 +268,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) => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -131,6 +131,14 @@ interface PersistedSettings {
|
||||
|
||||
// Plugins (UI-level enable map)
|
||||
pluginEnabled: Record<string, boolean>;
|
||||
|
||||
// Offline mail cache: download recent message bodies in the background so
|
||||
// they can be opened without network. Days windows the lookback; the
|
||||
// attachments toggle controls whether file blobs are cached too (off by
|
||||
// default — bodies-only is much smaller).
|
||||
offlineCacheEnabled: boolean;
|
||||
offlineCacheDays: number;
|
||||
offlineCacheIncludeAttachments: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
@@ -207,6 +215,10 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
filtersExpandedView: false,
|
||||
|
||||
pluginEnabled: {},
|
||||
|
||||
offlineCacheEnabled: false,
|
||||
offlineCacheDays: 7,
|
||||
offlineCacheIncludeAttachments: false,
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
|
||||
Reference in New Issue
Block a user