Compare commits

...
17 Commits
Author SHA1 Message Date
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
44 changed files with 3238 additions and 646 deletions
+24 -2
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';
@@ -192,6 +193,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 () => {
@@ -240,12 +253,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 +293,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.21
@@ -0,0 +1,291 @@
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 okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.Socket
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
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 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.
*/
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 cachedClient: OkHttpClient? = null
@Volatile private var cachedClientForAlias: 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
invalidateClient()
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
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.
if (alias.isNullOrBlank()) {
promise.resolve(null)
return@KeyChainAliasCallback
}
prefs.edit().putString(PREF_ALIAS, alias).apply()
cachedAlias = alias
invalidateClient()
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"
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs").toLong() else 30_000L
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
}
// 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)
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)
}
}
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
}
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)
}
} catch (e: Exception) {
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
}
}
}
// ── internals ───────────────────────────────────────────────
private fun invalidateClient() {
cachedClient = null
cachedClientForAlias = null
}
private fun buildOrReuseClient(timeoutMs: Long): OkHttpClient {
val alias = cachedAlias
val existing = cachedClient
if (existing != null && cachedClientForAlias == 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
}
/**
* 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
* 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: javax.net.ssl.SSLEngine?,
): String = alias
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
return try {
KeyChain.getCertificateChain(context, this.alias)
} catch (e: Exception) {
null
}
}
override fun getPrivateKey(alias: String?): PrivateKey? {
return try {
KeyChain.getPrivateKey(context, this.alias)
} catch (e: 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"
}
}
@@ -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()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.13",
"version": "0.1.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.13",
"version": "0.1.21",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.13",
"version": "0.1.21",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+36 -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,30 @@ 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');
}
export function getDownloadUrl(
@@ -43,8 +70,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'));
}
+61
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?: {
+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) {
+1 -1
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) => {
+36 -5
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);
+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,
},
@@ -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',
+21
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;
@@ -33,6 +52,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;
+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>
);
}
+84 -20
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
@@ -7,11 +7,42 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { FileText, Folder, HardDrive } from 'lucide-react-native';
import {
FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio,
FileArchive, FileSpreadsheet, FileCode2,
} from 'lucide-react-native';
import { getFileNodes } from '../api/files';
import type { FileNode } from '../api/types';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSettingsStore } from '../stores/settings-store';
function pickFileIcon(name: string) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return FileVideo;
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return FileAudio;
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return FileArchive;
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return FileSpreadsheet;
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return FileCode2;
return FileText;
}
function fileIconColor(name: string, c: ThemePalette): string {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return c.calendar.green;
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return c.calendar.purple;
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return c.calendar.pink;
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return c.calendar.orange;
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return c.calendar.teal;
if (['pdf'].includes(ext)) return c.error;
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return c.calendar.indigo;
return c.textMuted;
}
function isHidden(node: FileNode): boolean {
return node.name.startsWith('.');
}
function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
@@ -27,6 +58,12 @@ export default function FilesScreen() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
const sortKey = useSettingsStore((s) => s.filesDefaultSortKey);
const sortDir = useSettingsStore((s) => s.filesDefaultSortDir);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
@@ -40,6 +77,31 @@ export default function FilesScreen() {
}
}, []);
const visibleFiles = useMemo(() => {
let out = files;
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
// Directories always sort to the top regardless of sort key, then secondary
// sort by the requested column. This matches macOS Finder / web file pickers.
const dir = sortDir === 'desc' ? -1 : 1;
return [...out].sort((a, b) => {
const aDir = a.type === 'directory' ? 0 : 1;
const bDir = b.type === 'directory' ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
case 'modified': {
const am = a.updated ? new Date(a.updated).getTime() : 0;
const bm = b.updated ? new Date(b.updated).getTime() : 0;
return (am - bm) * dir;
}
case 'name':
default:
return a.name.localeCompare(b.name) * dir;
}
});
}, [files, showHiddenFiles, sortKey, sortDir]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
@@ -63,7 +125,7 @@ export default function FilesScreen() {
);
}
if (files.length === 0) {
if (visibleFiles.length === 0) {
return (
<View style={styles.container}>
<View style={styles.header}>
@@ -83,25 +145,27 @@ export default function FilesScreen() {
<Text style={styles.title}>Files</Text>
</View>
<FlatList
data={files}
data={visibleFiles}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.fileRow}>
{item.type === 'directory' ? (
<Folder size={20} color={c.primary} />
) : (
<FileText size={20} color={c.textMuted} />
)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
{item.size != null && (
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
)}
renderItem={({ item }) => {
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
const tint = item.type === 'directory'
? (coloredIcons ? c.primary : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
return (
<View style={styles.fileRow}>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
{item.size != null && (
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
)}
</View>
</View>
</View>
)}
);
}}
/>
</View>
);
+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 }),
}));
+29 -6
View File
@@ -151,7 +151,8 @@ export const useEmailStore = create<EmailState>()(
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
const { ids, total } = await queryEmails(mailboxId, { limit: 50, filter });
const limit = useSettingsStore.getState().emailsPerPage;
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails, totalEmails: total, loading: false });
} catch (err) {
@@ -167,9 +168,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) : [];
@@ -187,7 +189,8 @@ export const useEmailStore = create<EmailState>()(
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) {
@@ -335,14 +338,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',
+232 -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,152 @@ 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>;
}
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: {},
};
export interface SettingsState extends PersistedSettings {
@@ -71,6 +217,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 +239,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 +277,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 +318,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 +327,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 +346,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 +369,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' };
}
},
}));
+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;
}