mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16f04e818 | ||
|
|
d616943c25 | ||
|
|
cf4e3955bf | ||
|
|
bf20c0af58 | ||
|
|
67f7f83621 | ||
|
|
086d398058 | ||
|
|
373a1a60d8 | ||
|
|
f71f219187 | ||
|
|
77b7be03d8 | ||
|
|
9dd9ce8105 | ||
|
|
dd64931be8 | ||
|
|
ac234f288e | ||
|
|
81749dd89c | ||
|
|
beef086d4c | ||
|
|
cfd67398de | ||
|
|
2b15d2dc50 | ||
|
|
9bf2ecdf27 | ||
|
|
67db8a2ac3 | ||
|
|
6846d859fd | ||
|
|
3bdca0133e | ||
|
|
1858d08dd0 | ||
|
|
427d163dc0 | ||
|
|
7e34ba42ef | ||
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 | ||
|
|
455f26e90a | ||
|
|
e9b93bc6e5 | ||
|
|
c21661ac7a | ||
|
|
00be851611 | ||
|
|
71ff8930a2 | ||
|
|
29b06a3a4f | ||
|
|
2911b8e7ac | ||
|
|
3d4779a689 | ||
|
|
641fde0505 | ||
|
|
6d5195d9fa | ||
|
|
40f9da244f | ||
|
|
c7ea4d757c | ||
|
|
1d89cb38cf | ||
|
|
abbbff2afa | ||
|
|
0a402225f1 | ||
|
|
9bfeb027de | ||
|
|
9a76b22d86 | ||
|
|
b254573237 | ||
|
|
2409a51952 | ||
|
|
37e1b93c48 | ||
|
|
a656a6b56a | ||
|
|
20cdc282c8 | ||
|
|
cec98d89db | ||
|
|
8fa8597e9f | ||
|
|
57f15f6f2e | ||
|
|
cd3fdfa217 | ||
|
|
f8064d4544 |
@@ -15,6 +15,7 @@ import {
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
@@ -30,16 +31,22 @@ import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ScheduledScreen from './src/screens/ScheduledScreen';
|
||||
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
|
||||
import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
@@ -47,8 +54,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||
if (!navigationRef.isReady()) return;
|
||||
|
||||
// The notification carries the account it was generated for. If the user
|
||||
// has since switched to a different account (or had a different one active
|
||||
// when the notification arrived), opening EmailThread under the active
|
||||
// account would fetch the email from the wrong server and fail.
|
||||
const auth = useAuthStore.getState();
|
||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
||||
if (!account) return; // account was logged out — nothing safe to open.
|
||||
await auth.switchAccount(payload.accountId);
|
||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
||||
}
|
||||
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
@@ -71,10 +91,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const disabledTabStyle = { opacity: 0.4 } as const;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -131,6 +156,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasCalendar) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -138,6 +170,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasContacts) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -145,6 +184,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasFiles) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
@@ -192,6 +238,18 @@ export default function App() {
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && !useAuthStore.getState().session) {
|
||||
void useAuthStore.getState().retrySession();
|
||||
}
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -205,6 +263,24 @@ export default function App() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Offline mail cache: hydrate the cache index on launch, and kick off a
|
||||
// background sync once we have a live JMAP session and the user has the
|
||||
// feature enabled. Re-runs whenever the user changes the days window.
|
||||
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
// Slight delay so cold start doesn't compete with the inbox load.
|
||||
const t = setTimeout(() => {
|
||||
void runOfflineSync({ days: offlineCacheDays });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
@@ -226,11 +302,11 @@ export default function App() {
|
||||
void (async () => {
|
||||
const initial = await getInitialNotificationTap();
|
||||
if (cancelled || !initial) return;
|
||||
navigateToNotificationTap(initial);
|
||||
await navigateToNotificationTap(initial);
|
||||
})();
|
||||
|
||||
const unsubscribe = addNotificationTapListener((payload) => {
|
||||
navigateToNotificationTap(payload);
|
||||
void navigateToNotificationTap(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -240,12 +316,27 @@ 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 THIS account's subscription so
|
||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||
// setups intact.
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
let cancelled = false;
|
||||
const doSetup = async () => {
|
||||
if (!emailNotificationsEnabled) {
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl) return;
|
||||
try {
|
||||
@@ -271,7 +362,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
@@ -360,6 +451,8 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
||||
<Stack.Screen
|
||||
name="AddAccount"
|
||||
options={{
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.KeyChain
|
||||
import android.security.KeyChainAliasCallback
|
||||
import android.util.Base64
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Bridges Android's KeyChain client-certificate machinery into JS so the
|
||||
* mobile app can authenticate to a reverse proxy that asks for mTLS.
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
|
||||
* pick a cert installed in Android's system credential store. The
|
||||
* selected alias is persisted in SharedPreferences so the choice
|
||||
* survives app restarts.
|
||||
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
|
||||
* injects the picked cert into the TLS handshake. JS-side `fetch`
|
||||
* can't present a client cert because RN's bundled OkHttp client
|
||||
* doesn't expose its KeyManager hook to userland; this method is
|
||||
* the workaround.
|
||||
*/
|
||||
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
|
||||
@Volatile private var cachedFactory: SSLSocketFactory? = null
|
||||
@Volatile private var cachedFactoryForAlias: String? = null
|
||||
|
||||
override fun getName(): String = MODULE_NAME
|
||||
|
||||
// ── alias management ────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getAlias(promise: Promise) {
|
||||
promise.resolve(cachedAlias)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearAlias(promise: Promise) {
|
||||
prefs.edit().remove(PREF_ALIAS).apply()
|
||||
cachedAlias = null
|
||||
invalidateFactory()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
|
||||
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
|
||||
// reference). The explicit Java-style getter on the application
|
||||
// context is the supported call site.
|
||||
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
|
||||
if (activity == null) {
|
||||
promise.reject("no_activity", "No current activity to host the cert picker")
|
||||
return
|
||||
}
|
||||
val callback = KeyChainAliasCallback { alias ->
|
||||
// Callback runs off the main thread; persist + resolve directly.
|
||||
if (alias.isNullOrBlank()) {
|
||||
promise.resolve(null)
|
||||
return@KeyChainAliasCallback
|
||||
}
|
||||
prefs.edit().putString(PREF_ALIAS, alias).apply()
|
||||
cachedAlias = alias
|
||||
invalidateFactory()
|
||||
promise.resolve(alias)
|
||||
}
|
||||
try {
|
||||
KeyChain.choosePrivateKeyAlias(
|
||||
activity,
|
||||
callback,
|
||||
/* keyTypes */ arrayOf("RSA", "EC"),
|
||||
/* issuers */ null,
|
||||
/* host */ host,
|
||||
/* port */ -1,
|
||||
/* alias */ cachedAlias,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("pick_failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── secure fetch ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform an HTTP request that presents the persisted client cert (if
|
||||
* any) during the TLS handshake.
|
||||
*
|
||||
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
|
||||
*
|
||||
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
|
||||
* reconstruct a standard `Response`-like object.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun fetchSecure(request: ReadableMap, promise: Promise) {
|
||||
val url = request.getString("url")
|
||||
if (url.isNullOrBlank()) {
|
||||
promise.reject("bad_args", "url is required")
|
||||
return
|
||||
}
|
||||
val method = (request.getString("method") ?: "GET").uppercase()
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
|
||||
val headersMap = request.getMap("headers")
|
||||
val bodyBase64 = request.getString("bodyBase64")
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = timeoutMs
|
||||
readTimeout = timeoutMs
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
if (bodyBytes != null) {
|
||||
doOutput = true
|
||||
setFixedLengthStreamingMode(bodyBytes.size)
|
||||
}
|
||||
}
|
||||
if (conn is HttpsURLConnection && cachedAlias != null) {
|
||||
conn.sslSocketFactory = buildOrReuseSocketFactory()
|
||||
}
|
||||
headersMap?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
val v = h.getString(k)
|
||||
if (v != null) conn.setRequestProperty(k, v)
|
||||
}
|
||||
}
|
||||
conn.connect()
|
||||
if (bodyBytes != null) {
|
||||
conn.outputStream.use { out -> out.write(bodyBytes) }
|
||||
}
|
||||
|
||||
val status = conn.responseCode
|
||||
val statusText = conn.responseMessage ?: ""
|
||||
val responseStream = try {
|
||||
conn.inputStream
|
||||
} catch (_: Exception) {
|
||||
conn.errorStream
|
||||
}
|
||||
val responseBytes = if (responseStream != null) {
|
||||
val buf = ByteArrayOutputStream()
|
||||
responseStream.copyTo(buf)
|
||||
responseStream.close()
|
||||
buf.toByteArray()
|
||||
} else {
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", status)
|
||||
out.putString("statusText", statusText)
|
||||
val headers = Arguments.createMap()
|
||||
// headerFields is a `Map<String?, List<String>>`; the null
|
||||
// key is the HTTP status line, which we don't need to copy.
|
||||
for ((name, values) in conn.headerFields) {
|
||||
if (name == null || values == null) continue
|
||||
headers.putString(name, values.joinToString(", "))
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
conn.disconnect()
|
||||
promise.resolve(out)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateFactory() {
|
||||
cachedFactory = null
|
||||
cachedFactoryForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
|
||||
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
|
||||
val existing = cachedFactory
|
||||
if (existing != null && cachedFactoryForAlias == alias) return existing
|
||||
|
||||
val ctx = reactApplicationContext
|
||||
val km = KeyChainKeyManager(ctx, alias)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(null as java.security.KeyStore?)
|
||||
val trustManagers = tmf.trustManagers
|
||||
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
|
||||
?: throw IllegalStateException("No X509TrustManager available")
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
|
||||
val factory = sslContext.socketFactory
|
||||
cachedFactory = factory
|
||||
cachedFactoryForAlias = alias
|
||||
return factory
|
||||
}
|
||||
|
||||
/**
|
||||
* X509ExtendedKeyManager that always returns the same KeyChain alias.
|
||||
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
|
||||
* them lazily during the TLS handshake, which is fine since the
|
||||
* fetch already runs off-main-thread.
|
||||
*/
|
||||
private class KeyChainKeyManager(
|
||||
private val context: Context,
|
||||
private val alias: String,
|
||||
) : X509ExtendedKeyManager() {
|
||||
|
||||
override fun chooseClientAlias(
|
||||
keyTypes: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String = alias
|
||||
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
emptyArray()
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String? = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MODULE_NAME = "BulwarkClientCert"
|
||||
private const val PREFS_NAME = "bulwark_client_cert"
|
||||
private const val PREF_ALIAS = "alias"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
@@ -64,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val emailId = options.getString("emailId")
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -72,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject,
|
||||
emailId, threadId, subject, accountId,
|
||||
)
|
||||
promise.resolve(null)
|
||||
}
|
||||
@@ -93,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
emailId: String?,
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
@@ -100,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
@@ -148,7 +152,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
conn.readTimeout = 3000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("User-Agent", "bulwark-mobile")
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
|
||||
// server can't OOM the headless task with a multi-megabyte image.
|
||||
// We also sample down to ~256x256 since it'll only render at 192px.
|
||||
val stream = conn.inputStream
|
||||
val bytes = stream.use { input ->
|
||||
val limit = MAX_FAVICON_BYTES
|
||||
val buf = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val n = input.read(chunk)
|
||||
if (n <= 0) break
|
||||
total += n
|
||||
if (total > limit) return@use null
|
||||
buf.write(chunk, 0, n)
|
||||
}
|
||||
buf.toByteArray()
|
||||
} ?: return null
|
||||
val opts = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
var sample = 1
|
||||
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
|
||||
sample *= 2
|
||||
}
|
||||
val decodeOpts = BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -167,6 +200,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
fun removeListeners(count: Int) {}
|
||||
|
||||
companion object {
|
||||
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
|
||||
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
|
||||
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(BulwarkFcmModule(reactContext))
|
||||
listOf(
|
||||
BulwarkFcmModule(reactContext),
|
||||
BulwarkClientCertModule(reactContext),
|
||||
)
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
|
||||
@@ -17,17 +17,36 @@ object NotificationTapStore {
|
||||
return value
|
||||
}
|
||||
|
||||
// The activity is exported (required by the LAUNCHER intent filter), so
|
||||
// any third-party app on the device can craft a starting intent that
|
||||
// includes these extras. Reject anything that doesn't look like the IDs
|
||||
// our notification builder writes — preventing other apps from
|
||||
// navigating us to a forged email/thread.
|
||||
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
|
||||
// accountId is `username@host` (see generateAccountId), so it needs `.`,
|
||||
// `@`, `+` etc. — cap length and restrict charset to what that helper can
|
||||
// emit. accountId is optional: notifications created before this field
|
||||
// existed still navigate (using the active account as a fallback).
|
||||
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
|
||||
|
||||
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||
val extras = intent?.extras ?: return null
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
|
||||
val subject = extras.getString(EXTRA_SUBJECT)
|
||||
val payload = TapPayload(emailId, threadId, subject)
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
?: return null
|
||||
// Subject is human-readable text and may legitimately contain anything;
|
||||
// cap its length so a hostile launcher can't ship a 1MB string into
|
||||
// the navigation payload.
|
||||
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
|
||||
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -35,15 +54,18 @@ object NotificationTapStore {
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
|
||||
Generated
+23
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.39",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -24,6 +25,7 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -5216,6 +5218,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
@@ -5408,6 +5419,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
|
||||
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.39",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bump": "node scripts/bump-version.mjs",
|
||||
"sync-locales": "node scripts/sync-locales.mjs"
|
||||
},
|
||||
@@ -22,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -29,6 +31,7 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
describe('queryEvents', () => {
|
||||
it('should query events within date range', async () => {
|
||||
it('should send a CalendarEvent/query without server-side filter', async () => {
|
||||
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
|
||||
// filter client-side. Asserting on the absence of `filter` keeps that
|
||||
// contract pinned.
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||
});
|
||||
@@ -50,12 +54,9 @@ describe('calendar operations', () => {
|
||||
expect(result).toEqual(['ev1', 'ev2']);
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({
|
||||
inCalendars: ['cal-1'],
|
||||
after: '2026-03-01T00:00:00Z',
|
||||
before: '2026-03-31T23:59:59Z',
|
||||
types: ['Event'],
|
||||
});
|
||||
expect(call[0]).toBe('CalendarEvent/query');
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContacts,
|
||||
createAddressBook,
|
||||
updateAddressBook,
|
||||
deleteAddressBook,
|
||||
} from '../contacts';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
@@ -50,14 +54,14 @@ describe('contacts operations', () => {
|
||||
expect(result).toEqual(['c1', 'c2']);
|
||||
});
|
||||
|
||||
it('should use default empty filter', async () => {
|
||||
it('should omit filter when none provided', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryContacts();
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({});
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,4 +120,58 @@ describe('contacts operations', () => {
|
||||
expect(call[1].destroy).toEqual(['c1', 'c2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAddressBook', () => {
|
||||
it('creates a book and returns the created entity', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']],
|
||||
});
|
||||
|
||||
const book = await createAddressBook('Work');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } });
|
||||
expect(book).toEqual({ id: 'ab-9', name: 'Work' });
|
||||
});
|
||||
|
||||
it('throws when the server refuses to create', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']],
|
||||
});
|
||||
await expect(createAddressBook('X')).rejects.toThrow('nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAddressBook', () => {
|
||||
it('forwards only settable properties', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAddressBook', () => {
|
||||
it('destroys the book by id', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
|
||||
});
|
||||
|
||||
await deleteAddressBook('ab-1');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].destroy).toEqual(['ab-1']);
|
||||
});
|
||||
|
||||
it('throws when destroy fails', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']],
|
||||
});
|
||||
await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,24 @@ describe('email operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
|
||||
});
|
||||
|
||||
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
|
||||
});
|
||||
|
||||
const userFilter = {
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
|
||||
};
|
||||
await queryEmails('mb-1', { filter: userFilter });
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, userFilter],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmails', () => {
|
||||
@@ -197,7 +215,7 @@ describe('email operations', () => {
|
||||
|
||||
expect(result).toEqual(['e1']);
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ text: 'test query' });
|
||||
expect(call[1].filter).toEqual({ text: 'test* query*' });
|
||||
});
|
||||
|
||||
it('should include mailbox filter if specified', async () => {
|
||||
@@ -208,7 +226,7 @@ describe('email operations', () => {
|
||||
await searchEmails('query', 'mb-1');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
|
||||
expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+87
-9
@@ -1,5 +1,6 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
|
||||
export async function uploadBlob(
|
||||
uri: string,
|
||||
@@ -13,17 +14,20 @@ export async function uploadBlob(
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
// Read the source via the file-system API so this works for both `file://`
|
||||
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
|
||||
// Read via the file-system API so this works for both `file://` (image
|
||||
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
|
||||
// the underlying ArrayBuffer rather than the Uint8Array view because some
|
||||
// RN transports stringify typed-array bodies, which silently produces an
|
||||
// empty/garbage upload.
|
||||
const bytes = await new File(uri).bytes();
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
Authorization: (jmapClient as any).authHeader,
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: bytes as unknown as BodyInit,
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -31,7 +35,81 @@ export async function uploadBlob(
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
// JMAP servers return either the direct shape `{blobId, type, size}` or
|
||||
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
|
||||
// Stalwart has shipped both depending on version; mirror the webmail's
|
||||
// tolerant parsing so uploads don't silently lose the blobId.
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
|
||||
// `new File(uri).bytes()`, which is overkill when we already have the bytes
|
||||
// (e.g. the empty-blob trick used to create directories on Stalwart).
|
||||
export async function uploadBytes(
|
||||
bytes: Uint8Array,
|
||||
type: string,
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
export function getDownloadUrl(
|
||||
@@ -43,8 +121,8 @@ export function getDownloadUrl(
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
return session.downloadUrl
|
||||
.replace('{accountId}', jmapClient.accountId)
|
||||
.replace('{blobId}', blobId)
|
||||
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name ?? 'download'))
|
||||
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
|
||||
}
|
||||
|
||||
+139
-9
@@ -8,8 +8,11 @@ const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'recurrenceRules',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRules',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'links', 'created', 'updated',
|
||||
];
|
||||
|
||||
@@ -69,38 +72,165 @@ export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
|
||||
return all;
|
||||
}
|
||||
|
||||
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
||||
// / reply / cancellation emails to participants. We pass it whenever an event
|
||||
// has participants so creating/updating/deleting a meeting notifies attendees.
|
||||
function setArgs(
|
||||
accountId: string,
|
||||
payload: Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = { accountId, ...payload };
|
||||
if (sendSchedulingMessages !== undefined) {
|
||||
args.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function createEvent(
|
||||
event: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', {
|
||||
accountId,
|
||||
[['CalendarEvent/set', setArgs(accountId, {
|
||||
create: {
|
||||
'new-event': { ...event, calendarIds: { [calendarId]: true } },
|
||||
},
|
||||
}, '0']],
|
||||
}, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-event'];
|
||||
const result = methodResult<{
|
||||
created?: Record<string, CalendarEvent>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-event'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-event'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create event');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Batch-create many events in one CalendarEvent/set. Returns the number created.
|
||||
export async function batchCreateEvents(
|
||||
events: Partial<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
});
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, create }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ created?: Record<string, unknown> }>(res);
|
||||
return result.created ? Object.keys(result.created).length : 0;
|
||||
}
|
||||
|
||||
export async function updateEvent(
|
||||
id: string,
|
||||
changes: Partial<CalendarEvent>,
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update event');
|
||||
}
|
||||
|
||||
export async function deleteEvents(
|
||||
ids: string[],
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
|
||||
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEvents(ids: string[]): Promise<void> {
|
||||
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
||||
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
|
||||
export async function rsvpEvent(
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Escape per RFC 6901: ~ → ~0, / → ~1.
|
||||
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patch: Record<string, unknown> = {
|
||||
[`participants/${escaped}/participationStatus`]: status,
|
||||
};
|
||||
if (replyTo) patch.replyTo = replyTo;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[eventId];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
|
||||
}
|
||||
|
||||
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
|
||||
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
|
||||
notParsable?: string[];
|
||||
notFound?: string[];
|
||||
}>(res);
|
||||
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
|
||||
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (!parsed) return [];
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
|
||||
export async function createCalendar(
|
||||
name: string,
|
||||
color?: string,
|
||||
): Promise<Calendar> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', {
|
||||
accountId,
|
||||
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, Calendar>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-cal'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-cal'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create calendar');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
|
||||
// Stalwart otherwise refuses to delete a non-empty calendar.
|
||||
export async function deleteCalendar(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
|
||||
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAddressBook(name: string): Promise<AddressBook> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, AddressBook>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-book'];
|
||||
if (created) return { ...created, name };
|
||||
const err = result.notCreated?.['new-book'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create address book');
|
||||
}
|
||||
|
||||
export async function updateAddressBook(
|
||||
id: string,
|
||||
updates: Partial<AddressBook>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Only forward server-settable properties.
|
||||
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (description !== undefined) patch.description = description;
|
||||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||
if (isDefault !== undefined) patch.isDefault = isDefault;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
|
||||
}
|
||||
|
||||
export async function deleteAddressBook(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
|
||||
}
|
||||
|
||||
/** Fetch all contact cards that live in a specific address book. */
|
||||
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
|
||||
const ids = await queryContacts({ inAddressBook: addressBookId });
|
||||
return getContacts(ids);
|
||||
}
|
||||
|
||||
+461
-7
@@ -22,6 +22,132 @@ export async function getMailboxes(): Promise<Mailbox[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
// Variant that also returns the JMAP `state` token so callers can later issue
|
||||
// Mailbox/changes(sinceState=…) to fetch only what changed.
|
||||
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
}
|
||||
|
||||
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
|
||||
if (ids.length === 0) return { list: [], state: '' };
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId, ids }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
}
|
||||
|
||||
export interface MailboxChangesResult {
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
// Returns null when the server can't compute the diff (typically
|
||||
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
|
||||
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/changes', { accountId, sinceState }, '0']],
|
||||
);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldState: body.oldState as string,
|
||||
newState: body.newState as string,
|
||||
hasMoreChanges: Boolean(body.hasMoreChanges),
|
||||
created: (body.created as string[]) ?? [],
|
||||
updated: (body.updated as string[]) ?? [],
|
||||
destroyed: (body.destroyed as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMailbox(
|
||||
data: { name: string; parentId?: string | null },
|
||||
): Promise<string> {
|
||||
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}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMailboxQueryFilter(
|
||||
mailboxId: string,
|
||||
userFilter: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const inMailbox = { inMailbox: mailboxId };
|
||||
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
|
||||
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
|
||||
// produces a hybrid object that servers reduce to the FilterCondition,
|
||||
// silently dropping the operator's conditions (e.g. the "unread" toggle).
|
||||
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
|
||||
if ('operator' in userFilter) {
|
||||
return { operator: 'AND', conditions: [inMailbox, userFilter] };
|
||||
}
|
||||
return { ...inMailbox, ...userFilter };
|
||||
}
|
||||
|
||||
export async function queryEmails(
|
||||
mailboxId: string,
|
||||
options?: {
|
||||
@@ -30,21 +156,69 @@ export async function queryEmails(
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ ids: string[]; total: number }> {
|
||||
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
filter: { inMailbox: mailboxId, ...options?.filter },
|
||||
filter,
|
||||
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
|
||||
position: options?.position ?? 0,
|
||||
limit: options?.limit ?? 50,
|
||||
calculateTotal: true,
|
||||
}, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return {
|
||||
ids: res.methodResponses[0][1].ids,
|
||||
total: res.methodResponses[0][1].total,
|
||||
ids: body.ids,
|
||||
total: body.total,
|
||||
queryState: body.queryState as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EmailQueryChangesResult {
|
||||
oldQueryState: string;
|
||||
newQueryState: string;
|
||||
total: number;
|
||||
removed: string[];
|
||||
added: Array<{ id: string; index: number }>;
|
||||
}
|
||||
|
||||
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
|
||||
// query. Returns null when the server replies with `cannotCalculateChanges`
|
||||
// (or any other error) — caller should fall back to a fresh Email/query.
|
||||
export async function getEmailQueryChanges(
|
||||
mailboxId: string,
|
||||
sinceQueryState: string,
|
||||
options?: {
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
upToId?: string;
|
||||
maxChanges?: number;
|
||||
},
|
||||
): Promise<EmailQueryChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const args: Record<string, unknown> = {
|
||||
accountId,
|
||||
filter,
|
||||
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
|
||||
sinceQueryState,
|
||||
calculateTotal: true,
|
||||
};
|
||||
if (options?.upToId) args.upToId = options.upToId;
|
||||
if (options?.maxChanges) args.maxChanges = options.maxChanges;
|
||||
|
||||
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldQueryState: body.oldQueryState as string,
|
||||
newQueryState: body.newQueryState as string,
|
||||
total: (body.total as number) ?? 0,
|
||||
removed: (body.removed as string[]) ?? [],
|
||||
added: (body.added as Array<{ id: string; index: number }>) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +230,59 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
// Returns the Email/get response with the JMAP `state` token. Used by the
|
||||
// store so we can later issue Email/changes(sinceState=…) for incremental
|
||||
// updates instead of re-fetching the full list.
|
||||
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
|
||||
if (ids.length === 0) {
|
||||
// Email/get with an empty id list still returns a state token; useful for
|
||||
// priming the store after an empty mailbox query.
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: [], state: body.state as string };
|
||||
}
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Email[], state: body.state as string };
|
||||
}
|
||||
|
||||
export interface EmailChangesResult {
|
||||
oldState: string;
|
||||
newState: string;
|
||||
hasMoreChanges: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
// Returns null when the server replies with `cannotCalculateChanges` or any
|
||||
// other error response; caller should treat that as "rebuild from scratch".
|
||||
export async function getEmailChanges(
|
||||
sinceState: string,
|
||||
maxChanges?: number,
|
||||
): Promise<EmailChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId, sinceState };
|
||||
if (maxChanges) args.maxChanges = maxChanges;
|
||||
const res = await jmapClient.request([['Email/changes', args, '0']]);
|
||||
const [name, body] = res.methodResponses[0];
|
||||
if (name === 'error') return null;
|
||||
return {
|
||||
oldState: body.oldState as string,
|
||||
newState: body.newState as string,
|
||||
hasMoreChanges: Boolean(body.hasMoreChanges),
|
||||
created: (body.created as string[]) ?? [],
|
||||
updated: (body.updated as string[]) ?? [],
|
||||
destroyed: (body.destroyed as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFullEmail(id: string): Promise<Email> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
@@ -74,6 +301,26 @@ export async function getFullEmail(id: string): Promise<Email> {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Batch variant for offline sync. JMAP servers cap how many objects can be
|
||||
// returned in a single Email/get; the caller should chunk to that ceiling
|
||||
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
|
||||
export async function getFullEmails(ids: string[]): Promise<Email[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids,
|
||||
properties: EMAIL_FULL_PROPERTIES,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 512000,
|
||||
}, '0'],
|
||||
]);
|
||||
return res.methodResponses[0][1].list as Email[];
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string): Promise<Thread> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
@@ -111,6 +358,50 @@ export async function moveEmail(
|
||||
]);
|
||||
}
|
||||
|
||||
// Move several emails from one mailbox to another in a single Email/set.
|
||||
export async function moveEmails(
|
||||
ids: string[],
|
||||
fromMailboxId: string,
|
||||
toMailboxId: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const id of ids) {
|
||||
update[id] = {
|
||||
[`mailboxIds/${fromMailboxId}`]: null,
|
||||
[`mailboxIds/${toMailboxId}`]: true,
|
||||
};
|
||||
}
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
}
|
||||
|
||||
// Batch delete: destroy outright when already in trash, otherwise move to trash.
|
||||
export async function deleteEmails(
|
||||
ids: string[],
|
||||
trashMailboxId: string,
|
||||
currentMailboxId: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
if (currentMailboxId === trashMailboxId) {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
} else {
|
||||
await moveEmails(ids, currentMailboxId, trashMailboxId);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply keyword maps to several emails in one round-trip.
|
||||
export async function setKeywordsForEmails(
|
||||
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, { keywords: Record<string, boolean> }> = {};
|
||||
for (const u of updates) update[u.id] = { keywords: u.keywords };
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
}
|
||||
|
||||
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
|
||||
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
|
||||
// the entire map, so we don't need to compute a diff against the current state.
|
||||
@@ -311,6 +602,15 @@ export interface OutgoingAttachment {
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
|
||||
scheduled: boolean;
|
||||
/** ISO timestamp the server resolved for a deferred send, when known. */
|
||||
sendAt?: string;
|
||||
emailId?: string;
|
||||
emailSubmissionId?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
@@ -326,7 +626,11 @@ export async function sendEmail(
|
||||
},
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
): Promise<void> {
|
||||
// When > 0 the message is held for this many seconds before delivery via the
|
||||
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
|
||||
// scheduling and the global send-delay (undo-send) window.
|
||||
holdForSeconds?: number,
|
||||
): Promise<SendEmailResult> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: email.from,
|
||||
@@ -368,14 +672,164 @@ export async function sendEmail(
|
||||
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
|
||||
}
|
||||
|
||||
await jmapClient.request(
|
||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||
// server derive mailFrom from the Identity, dropping our parameter).
|
||||
if (holdForSeconds && holdForSeconds > 0) {
|
||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||
.map((r) => r.email.trim())
|
||||
.filter(Boolean)
|
||||
.map((address) => ({ email: address }));
|
||||
submissionCreate.envelope = {
|
||||
mailFrom: {
|
||||
email: email.from[0]?.email,
|
||||
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
|
||||
},
|
||||
rcptTo,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
create: { 'sub-1': { emailId: '#draft', identityId } },
|
||||
create: { 'sub-1': submissionCreate },
|
||||
}, '1'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let sendAt: string | undefined;
|
||||
for (const [methodName, result] of res.methodResponses) {
|
||||
if (methodName.endsWith('/error')) {
|
||||
throw new Error((result as { description?: string }).description ?? 'Send failed');
|
||||
}
|
||||
if (methodName === 'Email/set') {
|
||||
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.draft;
|
||||
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message');
|
||||
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
|
||||
}
|
||||
if (methodName === 'EmailSubmission/set') {
|
||||
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
|
||||
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
|
||||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
|
||||
emailSubmissionId = created?.id;
|
||||
sendAt = created?.sendAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheduled: !!(holdForSeconds && holdForSeconds > 0),
|
||||
sendAt,
|
||||
emailId,
|
||||
emailSubmissionId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
emailSubmissionId: string;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
threadId?: string;
|
||||
sendAt: string;
|
||||
undoStatus?: string;
|
||||
subject?: string;
|
||||
to?: EmailAddress[];
|
||||
from?: EmailAddress[];
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// List pending (not-yet-delivered, not cancelled) scheduled submissions whose
|
||||
// send time is still in the future, joined with a light Email/get so the UI
|
||||
// can show subject/recipients. Empty when the server lacks FUTURERELEASE.
|
||||
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
|
||||
if (!jmapClient.hasDelayedSend()) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const now = Date.now();
|
||||
|
||||
const queryRes = await jmapClient.request(
|
||||
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const [queryName, queryBody] = queryRes.methodResponses[0];
|
||||
if (queryName.endsWith('/error')) return [];
|
||||
const ids = (queryBody.ids as string[]) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const subRes = await jmapClient.request(
|
||||
[['EmailSubmission/get', {
|
||||
accountId,
|
||||
ids,
|
||||
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'],
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const submissions = ((subRes.methodResponses[0][1].list as Array<{
|
||||
id: string;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
threadId?: string;
|
||||
sendAt?: string;
|
||||
undoStatus?: string;
|
||||
}>) ?? []).filter((s) => {
|
||||
if (s.undoStatus !== 'pending' || !s.sendAt) return false;
|
||||
const t = new Date(s.sendAt).getTime();
|
||||
return Number.isFinite(t) && t > now;
|
||||
});
|
||||
if (submissions.length === 0) return [];
|
||||
|
||||
const emailIds = Array.from(new Set(submissions.map((s) => s.emailId)));
|
||||
const emailRes = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: emailIds,
|
||||
properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'],
|
||||
}, '0'],
|
||||
]);
|
||||
const emailById = new Map(
|
||||
((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]),
|
||||
);
|
||||
|
||||
return submissions
|
||||
.map((s): ScheduledEmail | null => {
|
||||
const e = emailById.get(s.emailId);
|
||||
if (!s.sendAt) return null;
|
||||
return {
|
||||
emailSubmissionId: s.id,
|
||||
emailId: s.emailId,
|
||||
identityId: s.identityId,
|
||||
threadId: s.threadId ?? e?.threadId,
|
||||
sendAt: s.sendAt,
|
||||
undoStatus: s.undoStatus,
|
||||
subject: e?.subject,
|
||||
to: e?.to,
|
||||
from: e?.from,
|
||||
preview: e?.preview,
|
||||
};
|
||||
})
|
||||
.filter((s): s is ScheduledEmail => s !== null)
|
||||
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
||||
}
|
||||
|
||||
// Cancel a pending scheduled send. The held message copy stays in Sent; only
|
||||
// delivery is stopped (matches the webmail behaviour).
|
||||
export async function cancelScheduledSend(emailSubmissionId: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['EmailSubmission/set', {
|
||||
accountId,
|
||||
update: { [emailSubmissionId]: { undoStatus: 'canceled' } },
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
const failure = res.methodResponses[0][1].notUpdated?.[emailSubmissionId] as
|
||||
| { type?: string; description?: string }
|
||||
| undefined;
|
||||
if (failure) {
|
||||
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
|
||||
}
|
||||
}
|
||||
|
||||
+157
-8
@@ -1,19 +1,68 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
const t = node.type;
|
||||
if (!t) return false;
|
||||
return DIRECTORY_TYPES.has(t) || t.includes('directory');
|
||||
}
|
||||
|
||||
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
|
||||
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
|
||||
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
|
||||
// e.g. a file inside the `Documents` folder is stored with
|
||||
// `name = "Documents∕report.pdf"`. The display name shown to the user
|
||||
// strips the prefix.
|
||||
export const PATH_SEP = '∕';
|
||||
|
||||
// Path is a list of folder display names from root. [] is root,
|
||||
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
|
||||
// `sub` folder under Documents.
|
||||
export function pathToPrefix(path: readonly string[]): string {
|
||||
if (path.length === 0) return '';
|
||||
return path.join(PATH_SEP) + PATH_SEP;
|
||||
}
|
||||
|
||||
// True when `node` is a direct child (not a descendant) of `prefix`.
|
||||
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
|
||||
if (!node.name) return false;
|
||||
if (prefix === '') return !node.name.includes(PATH_SEP);
|
||||
if (!node.name.startsWith(prefix)) return false;
|
||||
const rest = node.name.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes(PATH_SEP);
|
||||
}
|
||||
|
||||
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
|
||||
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter and some builds
|
||||
// omit `type` from the default property set. We fetch the whole tree and
|
||||
// filter client-side; properties are listed explicitly so `isDirectory()`
|
||||
// always has a value to look at.
|
||||
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
|
||||
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (parentId !== undefined) filter.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, filter }, '0']],
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
@@ -27,13 +76,113 @@ export async function createFileNode(
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
displayName: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
// Stalwart's FileNode/set rejects directory creates without a blobId
|
||||
// ("Missing blob id"), so we upload an empty blob first and reference it.
|
||||
// Mirrors the webmail's createFileDirectory.
|
||||
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
// Rename a node and, if it's a folder, cascade the rename to every descendant
|
||||
// so the encoded path stays consistent. The webmail does this same dance.
|
||||
export async function renameFileNode(
|
||||
node: FileNode,
|
||||
newDisplayName: string,
|
||||
currentPath: readonly string[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const prefix = pathToPrefix(currentPath);
|
||||
const oldServerName = node.name;
|
||||
const newServerName = prefix + newDisplayName;
|
||||
if (oldServerName === newServerName) return;
|
||||
|
||||
await updateFileNode(node.id, { name: newServerName });
|
||||
|
||||
if (isDirectory(node)) {
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id) continue;
|
||||
if (n.name.startsWith(oldFolderPrefix)) {
|
||||
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
|
||||
// references, so we collect descendants by name-prefix and destroy them all
|
||||
// in one call. Without this, deleting a folder leaves its files orphaned and
|
||||
// still discoverable at the old encoded path.
|
||||
export async function deleteWithDescendants(
|
||||
targets: readonly FileNode[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targets) {
|
||||
ids.add(node.id);
|
||||
if (isDirectory(node)) {
|
||||
const folderPrefix = node.name + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteFileNodes(Array.from(ids));
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
displayName: string,
|
||||
mimeType: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({
|
||||
name: fullName,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
}
|
||||
|
||||
+259
-32
@@ -7,6 +7,12 @@ import type {
|
||||
} from './types';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
// in-flight requests don't race the expiry window.
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
@@ -21,6 +27,13 @@ export interface StoredCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken?: string;
|
||||
// OAuth-only — present when credentials came in via the webmail handoff
|
||||
// OAuth path rather than password auth. The token endpoint and client id
|
||||
// are needed for refresh; without them the access token would just expire.
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
tokenEndpoint?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export class JMAPClient {
|
||||
@@ -53,7 +66,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}`;
|
||||
@@ -89,6 +105,113 @@ export class JMAPClient {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||
// Stalwart and handed us a complete token bundle. We persist the bundle
|
||||
// so subsequent launches can refresh without prompting the user again.
|
||||
async connectWithOAuth(
|
||||
serverUrl: string,
|
||||
tokens: OAuthTokens,
|
||||
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||
this.credentials = {
|
||||
serverUrl: baseUrl,
|
||||
username: '',
|
||||
password: '',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
|
||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
|
||||
// The JMAP session document carries the authenticated user's identifier
|
||||
// — use it as the username so per-account storage keys are stable across
|
||||
// restarts and OAuth re-logins.
|
||||
const username = this.session.username || tokens.accessToken.slice(0, 8);
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
|
||||
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
|
||||
if (!this.credentials) return;
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: next.accessToken,
|
||||
// Some IdPs rotate refresh tokens; others reuse. Fall back to the
|
||||
// existing one when the response omits it.
|
||||
refreshToken: next.refreshToken ?? this.credentials.refreshToken,
|
||||
expiresAt: next.expiresAt,
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
const accountId = generateAccountId(
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): OAuthTokens | null {
|
||||
if (
|
||||
!this.credentials?.accessToken ||
|
||||
!this.credentials.refreshToken ||
|
||||
!this.credentials.tokenEndpoint ||
|
||||
!this.credentials.clientId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken: this.credentials.accessToken,
|
||||
refreshToken: this.credentials.refreshToken,
|
||||
expiresAt: this.credentials.expiresAt,
|
||||
tokenEndpoint: this.credentials.tokenEndpoint,
|
||||
clientId: this.credentials.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
// Proactive refresh: when the access token is about to expire, swap it for
|
||||
// a fresh one. Quiet no-op for password credentials.
|
||||
private async ensureFreshToken(): Promise<void> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return;
|
||||
if (tokens.expiresAt == null) return;
|
||||
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
} catch {
|
||||
// Surface as AuthenticationError on the next 401; refresh may be
|
||||
// temporarily failing (network) and the reactive retry path catches it.
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive refresh after a 401. Returns true if a fresh token was obtained
|
||||
// so the caller can retry the original request.
|
||||
private async forceRefreshToken(): Promise<boolean> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return false;
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
// should use loadAccount(accountId) driven by the account registry.
|
||||
async restoreSession(): Promise<boolean> {
|
||||
@@ -114,18 +237,38 @@ export class JMAPClient {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return false;
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
creds = JSON.parse(stored);
|
||||
} catch {
|
||||
// Corrupt entry — credentials can't be used. Caller should evict.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Errors past this point propagate so callers can distinguish
|
||||
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
||||
// avoid clearing credentials when the server is just unreachable.
|
||||
this.credentials = creds;
|
||||
try {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
this.credentials = creds;
|
||||
this.session = this.rewriteSessionUrls(
|
||||
await this.fetchSession(creds.serverUrl),
|
||||
creds.serverUrl,
|
||||
);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Don't leave a half-populated client behind; the caller needs to know
|
||||
// the session is unavailable. Credentials stay in memory so a retry
|
||||
// after the network comes back doesn't need a re-login.
|
||||
this.session = null;
|
||||
this._accountId = null;
|
||||
if (err instanceof AuthenticationError) throw err;
|
||||
// Anything else is treated as transport-level. Wrapping rather than
|
||||
// rethrowing the raw fetch error gives callers a single check.
|
||||
throw new NetworkError(
|
||||
err instanceof Error ? err.message : 'Server unreachable',
|
||||
);
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
}
|
||||
|
||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||
@@ -161,17 +304,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 +326,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,12 +354,18 @@ export class JMAPClient {
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
const url = `${baseUrl}/.well-known/jmap`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Invalid credentials');
|
||||
@@ -250,14 +405,21 @@ export class JMAPClient {
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const response = await fetch(this.session.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () =>
|
||||
secureFetch(this.session!.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new AuthenticationError('Session expired');
|
||||
@@ -289,6 +451,59 @@ export class JMAPClient {
|
||||
return core?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
// ── Scheduled send (FUTURERELEASE) ────────────────────
|
||||
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
|
||||
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
|
||||
// lets us defer delivery via the SMTP HOLDFOR parameter. Mirrors the webmail
|
||||
// implementation so behaviour stays in sync across platforms.
|
||||
|
||||
private get submissionCapability():
|
||||
| { maxDelayedSend?: number; submissionExtensions?: unknown }
|
||||
| undefined {
|
||||
return this.session?.capabilities?.[CAPABILITIES.SUBMISSION] as
|
||||
| { maxDelayedSend?: number; submissionExtensions?: unknown }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
getMaxDelayedSend(): number {
|
||||
const max = this.submissionCapability?.maxDelayedSend;
|
||||
return typeof max === 'number' ? max : 0;
|
||||
}
|
||||
|
||||
hasDelayedSend(): boolean {
|
||||
const cap = this.submissionCapability;
|
||||
if (!cap) return false;
|
||||
const ext = cap.submissionExtensions;
|
||||
// submissionExtensions is a map of extension name → params. FUTURERELEASE
|
||||
// (RFC 4865) is the SMTP extension that backs deferred delivery.
|
||||
const hasFutureRelease =
|
||||
!!ext &&
|
||||
typeof ext === 'object' &&
|
||||
Object.keys(ext as Record<string, unknown>).some(
|
||||
(k) => k.toUpperCase() === 'FUTURERELEASE',
|
||||
);
|
||||
return hasFutureRelease && this.getMaxDelayedSend() > 0;
|
||||
}
|
||||
|
||||
// ── Stored credentials (per-account) ──────────────────
|
||||
// Exposed so the unified-inbox aggregator can read another account's
|
||||
// credentials without disturbing this client's live session, and persist a
|
||||
// refreshed OAuth token back to secure storage.
|
||||
|
||||
async getStoredCredentials(accountId: string): Promise<StoredCredentials | null> {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return JSON.parse(stored) as StoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
|
||||
}
|
||||
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
@@ -305,9 +520,12 @@ export class JMAPClient {
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
});
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
|
||||
let response = await doFetch();
|
||||
if (response.status === 401 && (await this.forceRefreshToken())) {
|
||||
response = await doFetch();
|
||||
}
|
||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
||||
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
@@ -323,6 +541,15 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Server unreachable / transient transport failure. Callers should keep
|
||||
// stored credentials and surface an offline state rather than logging out.
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
|
||||
+28
-6
@@ -134,7 +134,7 @@ export function connectEventSource(
|
||||
: require('react-native-sse').default;
|
||||
|
||||
const es = new EventSourceImpl(url, {
|
||||
headers: { Authorization: (jmapClient as any).authHeader },
|
||||
headers: { Authorization: jmapClient.authHeader },
|
||||
});
|
||||
|
||||
const handler = (event: any) => {
|
||||
@@ -201,13 +201,35 @@ export function startPolling(
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
export interface StartPushOptions {
|
||||
onStateChange: StateChangeHandler;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start real-time updates, preferring SSE with polling fallback.
|
||||
* Start real-time updates, preferring SSE with polling fallback. The `_client`
|
||||
* argument is unused today (the underlying event source uses the singleton
|
||||
* `jmapClient`) but is part of the call signature so the host App can pass
|
||||
* the active client reference and trigger React re-runs on account switch.
|
||||
*/
|
||||
export function startPushUpdates(
|
||||
onStateChange: StateChangeHandler,
|
||||
_client: unknown,
|
||||
opts: StartPushOptions,
|
||||
): () => void {
|
||||
const cleanup = connectEventSource(onStateChange);
|
||||
if (cleanup) return cleanup;
|
||||
return startPolling(onStateChange);
|
||||
const onError = opts.onError;
|
||||
const safeOnStateChange: StateChangeHandler = (change) => {
|
||||
try {
|
||||
opts.onStateChange(change);
|
||||
} catch (err) {
|
||||
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
};
|
||||
try {
|
||||
const cleanup = connectEventSource(safeOnStateChange);
|
||||
if (cleanup) return cleanup;
|
||||
return startPolling(safeOnStateChange);
|
||||
} catch (err) {
|
||||
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+74
-4
@@ -9,6 +9,10 @@ export interface JMAPSession {
|
||||
accounts: Record<string, JMAPAccountInfo>;
|
||||
capabilities: Record<string, unknown>;
|
||||
state: string;
|
||||
// RFC 8620 §2 — authenticated user identifier. Optional because not all
|
||||
// servers populate it; OAuth login uses it as the account label when
|
||||
// there's no password username to fall back on.
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface JMAPAccountInfo {
|
||||
@@ -284,6 +288,13 @@ export interface ContactCryptoKey {
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactDirectory {
|
||||
uri: string;
|
||||
kind?: 'directory' | 'entry' | string;
|
||||
mediaType?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactPronouns {
|
||||
pronouns: string;
|
||||
pref?: number;
|
||||
@@ -318,12 +329,15 @@ export interface ContactCard {
|
||||
relatedTo?: Record<string, ContactRelation>;
|
||||
links?: Record<string, ContactLink>;
|
||||
cryptoKeys?: Record<string, ContactCryptoKey>;
|
||||
directories?: Record<string, ContactDirectory>;
|
||||
keywords?: Record<string, boolean>;
|
||||
members?: Record<string, boolean>;
|
||||
speakToAs?: ContactSpeakToAs;
|
||||
calendarUri?: string;
|
||||
schedulingUri?: string;
|
||||
freeBusyUri?: string;
|
||||
source?: string;
|
||||
prodId?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
@@ -352,13 +366,39 @@ export interface AddressBookWithCount extends AddressBook {
|
||||
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
|
||||
|
||||
export interface Participant {
|
||||
'@type'?: 'Participant';
|
||||
name?: string;
|
||||
email?: string;
|
||||
sendTo?: Record<string, string>;
|
||||
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
|
||||
calendarAddress?: string;
|
||||
kind?: string;
|
||||
roles?: Record<string, boolean>;
|
||||
participationStatus?: string;
|
||||
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
|
||||
participationComment?: string;
|
||||
scheduleStatus?: string[];
|
||||
expectReply?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
|
||||
export interface EventLocation {
|
||||
'@type'?: 'Location';
|
||||
name?: string;
|
||||
description?: string;
|
||||
locationTypes?: Record<string, boolean>;
|
||||
relativeTo?: string;
|
||||
timeZone?: string;
|
||||
coordinates?: string;
|
||||
}
|
||||
|
||||
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
|
||||
export interface VirtualLocation {
|
||||
'@type'?: 'VirtualLocation';
|
||||
name?: string;
|
||||
description?: string;
|
||||
uri: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RecurrenceRule {
|
||||
@@ -399,27 +439,56 @@ export interface CalendarEvent {
|
||||
status?: string;
|
||||
freeBusyStatus?: string;
|
||||
participants?: Record<string, Participant>;
|
||||
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
|
||||
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
|
||||
replyTo?: Record<string, string>;
|
||||
organizerCalendarAddress?: string;
|
||||
sequence?: number;
|
||||
recurrenceRules?: RecurrenceRule[];
|
||||
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
|
||||
excludedRecurrenceRules?: RecurrenceRule[];
|
||||
recurrenceId?: string;
|
||||
originalId?: string;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, Alert>;
|
||||
locations?: Record<string, EventLocation>;
|
||||
virtualLocations?: Record<string, VirtualLocation>;
|
||||
links?: Record<string, { href: string; rel?: string }>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
progress?: string;
|
||||
// Task-only (RFC 8984 JSTask) fields.
|
||||
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
|
||||
due?: string;
|
||||
priority?: number;
|
||||
percentComplete?: number;
|
||||
color?: string;
|
||||
// Client-only: account the event/occurrence belongs to (for shared/virtual).
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
mayReadFreeBusy?: boolean;
|
||||
mayReadItems?: boolean;
|
||||
mayWriteAll?: boolean;
|
||||
mayWriteOwn?: boolean;
|
||||
mayUpdatePrivate?: boolean;
|
||||
mayRSVP?: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete?: boolean;
|
||||
// Legacy short flag still read by the sidebar drawer.
|
||||
mayWrite?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string;
|
||||
isVisible?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: Record<string, boolean>;
|
||||
myRights?: CalendarRights;
|
||||
}
|
||||
|
||||
// ─── Files (FileNode) ───────────────────────────────────
|
||||
@@ -429,7 +498,7 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
@@ -465,4 +534,5 @@ export const CAPABILITIES = {
|
||||
CALENDARS: 'urn:ietf:params:jmap:calendars',
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
|
||||
import { jmapClient, type StoredCredentials } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
|
||||
// Aggregated inbox across every logged-in account. Because the JMAP client is
|
||||
// a single-account singleton (one live session at a time), we fetch each
|
||||
// account's inbox "detached" here — reading its stored credentials directly and
|
||||
// issuing requests against its own API URL — so the active account's live
|
||||
// session is never disturbed.
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment',
|
||||
];
|
||||
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
|
||||
export interface UnifiedEmail extends Email {
|
||||
/** Registry account id this message belongs to (not the JMAP account id). */
|
||||
sourceAccountId: string;
|
||||
}
|
||||
|
||||
export interface UnifiedInboxResult {
|
||||
emails: UnifiedEmail[];
|
||||
/** accountId → error message for accounts that could not be fetched. */
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
function authHeaderFor(creds: StoredCredentials): string {
|
||||
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
|
||||
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
|
||||
}
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at
|
||||
// the origin we actually connected to (servers often self-report unreachable
|
||||
// container-internal hosts).
|
||||
function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
|
||||
const serverOrigin = originOf(serverUrl);
|
||||
const apiOrigin = originOf(session.apiUrl);
|
||||
if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl;
|
||||
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
|
||||
}
|
||||
|
||||
function resolveJmapAccountId(session: JMAPSession): string | null {
|
||||
return (
|
||||
session.primaryAccounts?.[CAPABILITIES.MAIL] ||
|
||||
session.primaryAccounts?.[CAPABILITIES.CORE] ||
|
||||
Object.keys(session.accounts ?? {})[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
|
||||
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
|
||||
async function ensureFreshCredentials(
|
||||
accountId: string,
|
||||
creds: StoredCredentials,
|
||||
): Promise<StoredCredentials> {
|
||||
if (
|
||||
!creds.accessToken ||
|
||||
!creds.refreshToken ||
|
||||
!creds.tokenEndpoint ||
|
||||
!creds.clientId ||
|
||||
creds.expiresAt == null
|
||||
) {
|
||||
return creds;
|
||||
}
|
||||
if (creds.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return creds;
|
||||
try {
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: creds.accessToken,
|
||||
refreshToken: creds.refreshToken,
|
||||
expiresAt: creds.expiresAt,
|
||||
tokenEndpoint: creds.tokenEndpoint,
|
||||
clientId: creds.clientId,
|
||||
};
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
const updated: StoredCredentials = {
|
||||
...creds,
|
||||
accessToken: next.accessToken,
|
||||
refreshToken: next.refreshToken ?? creds.refreshToken,
|
||||
expiresAt: next.expiresAt,
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
return updated;
|
||||
} catch {
|
||||
// Fall back to the existing (possibly expired) token; the request will
|
||||
// surface a 401 which we report as an error for this account.
|
||||
return creds;
|
||||
}
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JMAPMethodCall[],
|
||||
using: string[],
|
||||
): Promise<Array<[string, Record<string, any>, string]>> {
|
||||
const response = await secureFetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
if (response.status === 401) throw new Error('Session expired');
|
||||
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
|
||||
const body = await response.json();
|
||||
return body.methodResponses ?? [];
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
limit: number,
|
||||
): Promise<UnifiedEmail[]> {
|
||||
let creds = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!creds) throw new Error('No stored credentials');
|
||||
creds = await ensureFreshCredentials(accountId, creds);
|
||||
|
||||
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
|
||||
const authHeader = authHeaderFor(creds);
|
||||
|
||||
// Session discovery.
|
||||
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader, Accept: 'application/json' },
|
||||
});
|
||||
if (sessionRes.status === 401) throw new Error('Session expired');
|
||||
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
|
||||
const session = (await sessionRes.json()) as JMAPSession;
|
||||
const apiUrl = rewriteApiUrl(session, baseUrl);
|
||||
const jmapAccountId = resolveJmapAccountId(session);
|
||||
if (!jmapAccountId) throw new Error('No mail account in session');
|
||||
|
||||
// Find the inbox mailbox.
|
||||
const mailboxResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? [];
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
|
||||
if (!inbox) return [];
|
||||
|
||||
// Query + fetch the most recent messages.
|
||||
const queryResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/query', {
|
||||
accountId: jmapAccountId,
|
||||
filter: { inMailbox: inbox.id },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const getResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const list = (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
return list.map((e) => ({ ...e, sourceAccountId: accountId }));
|
||||
}
|
||||
|
||||
export async function fetchUnifiedInbox(
|
||||
accountIds: string[],
|
||||
perAccountLimit = 25,
|
||||
): Promise<UnifiedInboxResult> {
|
||||
const errors: Record<string, string> = {};
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
try {
|
||||
return await fetchInboxForAccount(id, perAccountLimit);
|
||||
} catch (err) {
|
||||
errors[id] = err instanceof Error ? err.message : 'Failed to load';
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const emails = settled
|
||||
.flat()
|
||||
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
return { emails, errors };
|
||||
}
|
||||
@@ -13,6 +13,45 @@ export interface LatestRelease {
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
apkAsset: ReleaseAsset | null;
|
||||
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
|
||||
// asset or from the release body. Null when the release didn't publish one;
|
||||
// the installer falls back to relying solely on Android's package signing
|
||||
// check in that case (still strong, but less defense-in-depth).
|
||||
apkSha256: string | null;
|
||||
}
|
||||
|
||||
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
|
||||
|
||||
function extractSha256(body: string, apkName: string | undefined): string | null {
|
||||
if (!body) return null;
|
||||
// Prefer a line that explicitly references the APK so we don't pick up the
|
||||
// hash of an unrelated asset.
|
||||
const lines = body.split(/\r?\n/);
|
||||
if (apkName) {
|
||||
for (const line of lines) {
|
||||
if (line.includes(apkName)) {
|
||||
const m = SHA256_RE.exec(line);
|
||||
if (m) return m[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: a single SHA256 anywhere in the body — common when the release
|
||||
// notes just list one checksum.
|
||||
const m = SHA256_RE.exec(body);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
|
||||
if (!asset) return null;
|
||||
try {
|
||||
const res = await fetch(asset.browser_download_url);
|
||||
if (!res.ok) return null;
|
||||
const text = await res.text();
|
||||
const m = SHA256_RE.exec(text);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
||||
const sha256Asset = apkAsset
|
||||
? json.assets.find(
|
||||
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
|
||||
)
|
||||
: undefined;
|
||||
const apkSha256 =
|
||||
(await fetchCompanionSha256(sha256Asset)) ??
|
||||
extractSha256(json.body || '', apkAsset?.name);
|
||||
return {
|
||||
tag: json.tag_name,
|
||||
name: json.name || json.tag_name,
|
||||
@@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
||||
publishedAt: json.published_at,
|
||||
body: json.body || '',
|
||||
apkAsset,
|
||||
apkSha256,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,39 @@ 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 trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||||
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
|
||||
const resolvedTheme = useResolvedTheme();
|
||||
|
||||
// Passively load the dedicated "Trusted Senders" address book once so its
|
||||
// entries (synced across devices) feed the external-content trust check. We
|
||||
// do not create the book here — it is created lazily when a sender is trusted.
|
||||
React.useEffect(() => {
|
||||
if (!trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
// Index the address book once per render so the trusted-sender check below
|
||||
// is O(1) per email. Only used when the matching setting is on.
|
||||
const addressBookEmails = React.useMemo(() => {
|
||||
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 +179,11 @@ 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)
|
||||
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|
||||
|| (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 +256,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);
|
||||
@@ -239,7 +282,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect, and persist to the
|
||||
// dedicated "Trusted Senders" address book so the trust syncs across
|
||||
// devices (matches the webmail behavior).
|
||||
addTrustedSender(senderEmail);
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
setAllowOnce(true);
|
||||
}
|
||||
};
|
||||
@@ -266,7 +313,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
<View style={[styles.webContainer, { height }]}>
|
||||
<WebView
|
||||
ref={webviewRef}
|
||||
originWhitelist={['about:*']}
|
||||
originWhitelist={['about:blank']}
|
||||
source={source}
|
||||
javaScriptEnabled
|
||||
injectedJavaScript={HEIGHT_REPORTER}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Live banner that mirrors the UpdateBanner's footprint, surfaced while the
|
||||
// offline mail sync is running. Hidden once the sync settles to idle/done.
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { formatBytes } from '../lib/offline-sync';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
// When UpdateBanner is stacked above us it already absorbs the status-bar
|
||||
// inset, so we only add it when we're the topmost banner.
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
const updateBannerVisible =
|
||||
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
|
||||
const topInset = updateBannerVisible ? 0 : insets.top;
|
||||
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
|
||||
// permanent fixture; the Settings screen still shows the cache stats.
|
||||
React.useEffect(() => {
|
||||
if (sync.phase === 'done' || sync.phase === 'cancelled') {
|
||||
const t = setTimeout(() => resetSync(), 4000);
|
||||
setHideTimer(t);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
return undefined;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sync.phase, resetSync]);
|
||||
|
||||
if (sync.phase === 'idle') return null;
|
||||
|
||||
const pct =
|
||||
sync.total > 0
|
||||
? Math.min(100, Math.round((sync.completed / sync.total) * 100))
|
||||
: sync.phase === 'done' ? 100 : 0;
|
||||
|
||||
let title = 'Offline sync';
|
||||
let subtitle = '';
|
||||
switch (sync.phase) {
|
||||
case 'scanning':
|
||||
title = 'Syncing offline mail';
|
||||
subtitle = 'Scanning recent messages…';
|
||||
break;
|
||||
case 'fetching':
|
||||
title = 'Syncing offline mail';
|
||||
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
|
||||
break;
|
||||
case 'done':
|
||||
title = 'Offline mail ready';
|
||||
subtitle = sync.fetched > 0
|
||||
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}`
|
||||
: 'Already up to date';
|
||||
break;
|
||||
case 'cancelled':
|
||||
title = 'Sync cancelled';
|
||||
subtitle = `${sync.completed}/${sync.total} processed`;
|
||||
break;
|
||||
case 'error':
|
||||
title = 'Offline sync failed';
|
||||
subtitle = sync.message ?? 'Unable to download';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const showCancel = sync.phase === 'scanning' || sync.phase === 'fetching';
|
||||
const showDismiss = sync.phase === 'done' || sync.phase === 'cancelled' || sync.phase === 'error';
|
||||
const isError = sync.phase === 'error';
|
||||
|
||||
return (
|
||||
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
|
||||
<CloudDownload size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{!!subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
{(sync.phase === 'fetching' || sync.phase === 'scanning') && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${pct}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{showCancel && (
|
||||
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{showDismiss && (
|
||||
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}>
|
||||
<X size={14} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
bannerError: { backgroundColor: c.error },
|
||||
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85, marginTop: 2 },
|
||||
progressTrack: {
|
||||
marginTop: 6,
|
||||
height: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: c.primaryForeground,
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
cancelText: { ...typography.captionMedium, color: c.primaryForeground },
|
||||
dismiss: { padding: 4 },
|
||||
});
|
||||
}
|
||||
@@ -263,20 +263,30 @@ function buildEditorHtml(opts: {
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient.
|
||||
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
|
||||
var trimmed = (url || '').trim();
|
||||
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, url); } catch (e) {}
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === url) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : url;
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = url.replace(/"/g, '"');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
@@ -409,7 +419,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
<View style={[styles.container, { height }]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['about:*']}
|
||||
originWhitelist={['about:blank']}
|
||||
source={{ html }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
@@ -433,9 +443,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
style={styles.webview}
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
// Allow only the initial about:blank doc + data URIs we emit ourselves.
|
||||
// Allow only the initial about:blank doc + data URIs we emit
|
||||
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
|
||||
// scheme so a paste-injected anchor can't navigate the editor.
|
||||
const url = request.url;
|
||||
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
|
||||
return !url || url === 'about:blank' || url.startsWith('data:');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||
Clock, Layers,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
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 +225,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(
|
||||
@@ -424,6 +428,24 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
)}
|
||||
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
|
||||
{/* Quick views */}
|
||||
{accounts.length > 1 && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
|
||||
onPress={() => { onClose(); navigation.navigate('UnifiedInbox'); }}
|
||||
>
|
||||
<Layers size={16} color={c.textSecondary} />
|
||||
<Text style={styles.quickRowLabel}>All inboxes</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
|
||||
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
|
||||
>
|
||||
<Clock size={16} color={c.textSecondary} />
|
||||
<Text style={styles.quickRowLabel}>Scheduled</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Folders section */}
|
||||
<Pressable style={styles.sectionHeader} onPress={toggleFoldersSection}>
|
||||
{foldersExpanded ? (
|
||||
@@ -484,7 +506,9 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
||||
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
|
||||
// dark-mode value, which left light mode unreadable (dark bg + dark text).
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
@@ -534,7 +558,7 @@ function makeStyles(c: ThemePalette) {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: '#1f1f1f',
|
||||
backgroundColor: c.popover,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
@@ -662,6 +686,17 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
sectionSettings: { padding: 4 },
|
||||
|
||||
quickRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
minHeight: 44,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: 'transparent',
|
||||
},
|
||||
quickRowLabel: { ...typography.body, color: c.text },
|
||||
|
||||
empty: {
|
||||
...typography.body,
|
||||
color: c.textMuted,
|
||||
|
||||
+153
-36
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Tag } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
interface TagSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
keywords: KeywordDef[];
|
||||
/** The currently-selected emails, used to show which tags are already set. */
|
||||
selectedEmails: Email[];
|
||||
/** Toggle a keyword token on/off across the whole selection. */
|
||||
onToggle: (token: string, on: boolean) => void;
|
||||
}
|
||||
|
||||
export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
// A tag counts as "applied" only when every selected email carries it; in
|
||||
// that case tapping removes it, otherwise tapping adds it to all.
|
||||
const allHaveToken = React.useCallback(
|
||||
(token: string) =>
|
||||
selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.[token]),
|
||||
[selectedEmails],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text>
|
||||
) : (
|
||||
keywords.map((kw) => {
|
||||
const token = keywordToken(kw.id);
|
||||
const applied = allHaveToken(token);
|
||||
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
|
||||
return (
|
||||
<Pressable
|
||||
key={kw.id}
|
||||
onPress={() => onToggle(token, !applied)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Tag size={16} color={dot} fill={dot} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
|
||||
{applied && <Check size={16} color={c.primary} />}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
empty: { ...typography.body, color: c.textMuted, padding: spacing.lg, textAlign: 'center' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
});
|
||||
}
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Check, X } from 'lucide-react-native';
|
||||
import { Check, X, Upload, Rss } from 'lucide-react-native';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
@@ -23,6 +24,8 @@ interface CalendarSidebarDrawerProps {
|
||||
hiddenCalendarIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onClose: () => void;
|
||||
onImport?: () => void;
|
||||
onManageSubscriptions?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
@@ -31,24 +34,28 @@ export function CalendarSidebarDrawer({
|
||||
hiddenCalendarIds,
|
||||
onToggle,
|
||||
onClose,
|
||||
onImport,
|
||||
onManageSubscriptions,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
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 +63,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]);
|
||||
|
||||
@@ -119,6 +126,29 @@ export function CalendarSidebarDrawer({
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(onImport || onManageSubscriptions) && (
|
||||
<View style={styles.actionsSection}>
|
||||
{onManageSubscriptions && (
|
||||
<Pressable
|
||||
onPress={onManageSubscriptions}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Rss size={18} color={c.textSecondary} />
|
||||
<Text style={styles.actionText}>Subscriptions</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{onImport && (
|
||||
<Pressable
|
||||
onPress={onImport}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Upload size={18} color={c.textSecondary} />
|
||||
<Text style={styles.actionText}>Import from file</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
@@ -188,7 +218,7 @@ function makeStyles(c: ThemePalette) {
|
||||
left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
@@ -248,5 +278,21 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
rowName: { flex: 1, ...typography.body, color: c.text },
|
||||
rowNameMuted: { color: c.textMuted },
|
||||
|
||||
actionsSection: {
|
||||
marginTop: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 44,
|
||||
},
|
||||
actionText: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -15,13 +16,16 @@ import {
|
||||
AlignLeft,
|
||||
Bell,
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
HelpCircle,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Repeat,
|
||||
Trash2,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import { format } from 'date-fns';
|
||||
@@ -33,8 +37,16 @@ import {
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
findParticipantByEmail,
|
||||
isOrganizerParticipant,
|
||||
} from '../../lib/calendar-invitation';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
@@ -42,6 +54,7 @@ interface EventDetailSheetProps {
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
onDuplicate?: (event: CalendarEvent) => void;
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatRange(event: CalendarEvent): string {
|
||||
@@ -84,22 +97,6 @@ function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
return label;
|
||||
}
|
||||
|
||||
function alertLabel(event: CalendarEvent): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first) return null;
|
||||
const offset = first.trigger?.offset;
|
||||
if (!offset) return null;
|
||||
if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event';
|
||||
const min = offset.match(/-?PT(\d+)M$/);
|
||||
if (min) return `${min[1]} minutes before`;
|
||||
const hr = offset.match(/-?PT(\d+)H$/);
|
||||
if (hr) return `${hr[1]} hours before`;
|
||||
const day = offset.match(/-?P(\d+)D/);
|
||||
if (day) return `${day[1]} days before`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
@@ -107,8 +104,11 @@ export function EventDetailSheet({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onRsvp,
|
||||
}: EventDetailSheetProps) {
|
||||
const c = useColors();
|
||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -158,8 +158,26 @@ export function EventDetailSheet({
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const recurrence = recurrenceLabel(event);
|
||||
const alert = alertLabel(event);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = event.participants ? Object.values(event.participants) : [];
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
|
||||
// Can the signed-in user RSVP? Only when they appear as a non-organizer
|
||||
// participant and the caller wired an onRsvp handler.
|
||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
||||
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
|
||||
const myStatus = me?.participant.participationStatus;
|
||||
|
||||
const doRsvp = async (status: RsvpStatus) => {
|
||||
if (!onRsvp || !me || rsvpBusy) return;
|
||||
setRsvpBusy(true);
|
||||
try {
|
||||
await onRsvp(event, me.id, status);
|
||||
} finally {
|
||||
setRsvpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -198,6 +216,22 @@ export function EventDetailSheet({
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
|
||||
{location ? (
|
||||
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
|
||||
) : null}
|
||||
{videoUri ? (
|
||||
<View style={styles.detailRow}>
|
||||
<View style={styles.detailIcon}>
|
||||
<Video size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.joinBtn}
|
||||
onPress={() => { void Linking.openURL(videoUri); }}
|
||||
>
|
||||
<Text style={styles.joinBtnText}>Join video call</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{event.description ? (
|
||||
<DetailRow
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
@@ -211,10 +245,10 @@ export function EventDetailSheet({
|
||||
text={recurrence}
|
||||
/>
|
||||
)}
|
||||
{alert && (
|
||||
{reminders.length > 0 && (
|
||||
<DetailRow
|
||||
icon={<Bell size={16} color={c.textMuted} />}
|
||||
text={alert}
|
||||
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
|
||||
/>
|
||||
)}
|
||||
{calendar && (
|
||||
@@ -224,6 +258,38 @@ export function EventDetailSheet({
|
||||
/>
|
||||
)}
|
||||
|
||||
{canRsvp && (
|
||||
<View style={styles.rsvpBlock}>
|
||||
<Text style={styles.rsvpPrompt}>Going?</Text>
|
||||
<View style={styles.rsvpButtons}>
|
||||
<RsvpButton
|
||||
label="Yes"
|
||||
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
|
||||
active={myStatus === 'accepted'}
|
||||
activeColor={c.success}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('accepted')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="Maybe"
|
||||
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
|
||||
active={myStatus === 'tentative'}
|
||||
activeColor={c.warning}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('tentative')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="No"
|
||||
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
active={myStatus === 'declined'}
|
||||
activeColor={c.error}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('declined')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{participants.length > 0 && (
|
||||
<View style={styles.participantsBlock}>
|
||||
<View style={styles.participantsHeader}>
|
||||
@@ -334,6 +400,39 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function RsvpButton({
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
activeColor,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
activeColor: string;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.rsvpBtn,
|
||||
active && { backgroundColor: activeColor, borderColor: activeColor },
|
||||
disabled && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(c: ThemePalette, status?: string): string {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
@@ -417,6 +516,33 @@ function makeStyles(c: ThemePalette) {
|
||||
detailIcon: { width: 16, paddingTop: 2 },
|
||||
detailText: { flex: 1, ...typography.body, color: c.text },
|
||||
|
||||
joinBtn: {
|
||||
flex: 1,
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
||||
|
||||
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
|
||||
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
|
||||
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
||||
rsvpBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
rsvpBtnText: { ...typography.caption, color: c.text },
|
||||
|
||||
participantsBlock: {
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
|
||||
@@ -17,13 +17,22 @@ import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
MapPin,
|
||||
Video,
|
||||
Repeat,
|
||||
Bell,
|
||||
Plus,
|
||||
Users,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
} from 'lucide-react-native';
|
||||
import { addHours, addMinutes, format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
Participant,
|
||||
EventLocation,
|
||||
VirtualLocation,
|
||||
} from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
@@ -33,11 +42,17 @@ import {
|
||||
getEventStartDate,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
type Reminder,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
|
||||
|
||||
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'none', label: 'Does not repeat' },
|
||||
@@ -47,16 +62,6 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'yearly', label: 'Yearly' },
|
||||
];
|
||||
|
||||
const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [
|
||||
{ value: 'none', label: 'No reminder' },
|
||||
{ value: 'at_time', label: 'At time of event' },
|
||||
{ value: '5', label: '5 minutes before' },
|
||||
{ value: '15', label: '15 minutes before' },
|
||||
{ value: '30', label: '30 minutes before' },
|
||||
{ value: '60', label: '1 hour before' },
|
||||
{ value: '1440', label: '1 day before' },
|
||||
];
|
||||
|
||||
interface EventModalProps {
|
||||
visible: boolean;
|
||||
event?: CalendarEvent | null;
|
||||
@@ -101,39 +106,30 @@ function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOp
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function detectAlert(event: CalendarEvent | null | undefined): AlertOption {
|
||||
const alerts = event?.alerts;
|
||||
if (!alerts) return 'none';
|
||||
const first = Object.values(alerts)[0];
|
||||
if (!first) return 'none';
|
||||
const offset = first.trigger?.offset;
|
||||
if (!offset) return 'none';
|
||||
if (offset === 'PT0S' || offset === '-PT0S') return 'at_time';
|
||||
const min = offset.match(/-?PT(\d+)M$/);
|
||||
if (min) {
|
||||
const v = min[1];
|
||||
if (v === '5' || v === '15' || v === '30') return v as AlertOption;
|
||||
}
|
||||
const hr = offset.match(/-?PT(\d+)H$/);
|
||||
if (hr && hr[1] === '1') return '60';
|
||||
const day = offset.match(/-?P(\d+)D/);
|
||||
if (day && day[1] === '1') return '1440';
|
||||
return 'none';
|
||||
function detectLocation(event: CalendarEvent | null | undefined): string {
|
||||
const locations = event?.locations;
|
||||
if (!locations) return '';
|
||||
const first = Object.values(locations)[0];
|
||||
return first?.name || '';
|
||||
}
|
||||
|
||||
function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined {
|
||||
if (opt === 'none') return undefined;
|
||||
let offset = '-PT0M';
|
||||
if (opt === 'at_time') offset = 'PT0S';
|
||||
else if (opt === '60') offset = '-PT1H';
|
||||
else if (opt === '1440') offset = '-P1D';
|
||||
else offset = `-PT${opt}M`;
|
||||
return {
|
||||
'a-1': {
|
||||
trigger: { '@type': 'OffsetTrigger', offset },
|
||||
action: 'display',
|
||||
},
|
||||
};
|
||||
function detectVideoUrl(event: CalendarEvent | null | undefined): string {
|
||||
const v = event?.virtualLocations;
|
||||
if (!v) return '';
|
||||
const first = Object.values(v)[0];
|
||||
return first?.uri || '';
|
||||
}
|
||||
|
||||
function buildLocations(name: string): Record<string, EventLocation> | undefined {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return { 'loc-1': { '@type': 'Location', name: trimmed } };
|
||||
}
|
||||
|
||||
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | undefined {
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } };
|
||||
}
|
||||
|
||||
function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined {
|
||||
@@ -163,9 +159,11 @@ export function EventModal({
|
||||
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
|
||||
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
|
||||
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
||||
const [alert, setAlert] = React.useState<AlertOption>('none');
|
||||
const [reminders, setReminders] = React.useState<Reminder[]>([]);
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [videoUrl, setVideoUrl] = React.useState('');
|
||||
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
|
||||
const [alertOpen, setAlertOpen] = React.useState(false);
|
||||
const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false);
|
||||
const [calendarOpen, setCalendarOpen] = React.useState(false);
|
||||
const [showStartDate, setShowStartDate] = React.useState(false);
|
||||
const [showStartTime, setShowStartTime] = React.useState(false);
|
||||
@@ -184,7 +182,9 @@ export function EventModal({
|
||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||
setParticipants(event.participants || {});
|
||||
setRecurrence(detectRecurrence(event));
|
||||
setAlert(detectAlert(event));
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setLocation(detectLocation(event));
|
||||
setVideoUrl(detectVideoUrl(event));
|
||||
} else {
|
||||
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
|
||||
setTitle('');
|
||||
@@ -195,7 +195,9 @@ export function EventModal({
|
||||
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
|
||||
setParticipants({});
|
||||
setRecurrence('none');
|
||||
setAlert('none');
|
||||
setReminders([]);
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
|
||||
|
||||
@@ -240,7 +242,10 @@ export function EventModal({
|
||||
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
|
||||
participants: Object.keys(participants).length > 0 ? participants : undefined,
|
||||
recurrenceRules: recurrenceFromOption(recurrence),
|
||||
alerts: alertFromOption(alert),
|
||||
alerts: remindersToAlerts(reminders),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: buildLocations(location),
|
||||
virtualLocations: buildVirtualLocations(videoUrl),
|
||||
};
|
||||
await onSave(data, calendarId);
|
||||
onClose();
|
||||
@@ -255,7 +260,21 @@ export function EventModal({
|
||||
|
||||
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
||||
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
|
||||
const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.label || 'None';
|
||||
|
||||
const addReminder = (minutesBefore: number) => {
|
||||
setReminderPickerOpen(false);
|
||||
setReminders((prev) =>
|
||||
prev.some((r) => r.minutesBefore === minutesBefore)
|
||||
? prev
|
||||
: [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore),
|
||||
);
|
||||
};
|
||||
const removeReminder = (minutesBefore: number) => {
|
||||
setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore));
|
||||
};
|
||||
const availablePresets = REMINDER_PRESETS.filter(
|
||||
(p) => !reminders.some((r) => r.minutesBefore === p),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -423,42 +442,80 @@ export function EventModal({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => setAlertOpen((v) => !v)}
|
||||
>
|
||||
<Text style={styles.fieldText}>{alertLabel}</Text>
|
||||
</Pressable>
|
||||
{alertOpen && (
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
|
||||
{reminders.length > 0 && (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{reminders.map((r) => (
|
||||
<View key={r.minutesBefore} style={styles.reminderRow}>
|
||||
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
|
||||
<Pressable
|
||||
onPress={() => removeReminder(r.minutesBefore)}
|
||||
hitSlop={8}
|
||||
style={styles.reminderRemove}
|
||||
>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{availablePresets.length > 0 && (
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => setReminderPickerOpen((v) => !v)}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addRowText}>
|
||||
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{reminderPickerOpen && (
|
||||
<View style={styles.popover}>
|
||||
{ALERT_OPTIONS.map((opt) => (
|
||||
{availablePresets.map((preset) => (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
style={[
|
||||
styles.popoverRow,
|
||||
opt.value === alert && styles.popoverRowActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setAlert(opt.value);
|
||||
setAlertOpen(false);
|
||||
}}
|
||||
key={preset}
|
||||
style={styles.popoverRow}
|
||||
onPress={() => addReminder(preset)}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="Place or address"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
|
||||
<TextInput
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={<MapPin size={16} color={c.textMuted} />}
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
label="Description"
|
||||
>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Notes, location, link…"
|
||||
placeholder="Notes, agenda…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
@@ -629,6 +686,26 @@ function makeStyles(c: ThemePalette) {
|
||||
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
|
||||
|
||||
reminderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
addRowText: { ...typography.body, color: c.primary },
|
||||
|
||||
popover: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { uploadBlob } from '../../api/blob';
|
||||
import { parseCalendarBlob } from '../../api/calendar';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||
type DocumentPickerModule = typeof import('expo-document-picker');
|
||||
let documentPickerModule: DocumentPickerModule | null = null;
|
||||
function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
if (documentPickerModule) return documentPickerModule;
|
||||
try {
|
||||
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
|
||||
return documentPickerModule;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
calendars: Calendar[];
|
||||
/** Imports the selected events into the chosen calendar; returns count. */
|
||||
onImport: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
onImported?: (count: number) => void;
|
||||
}
|
||||
|
||||
function eventDateLabel(event: Partial<CalendarEvent>): string | null {
|
||||
if (!event.start) return null;
|
||||
const d = parseISO(event.start);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm');
|
||||
}
|
||||
|
||||
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const writable = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
const [parsed, setParsed] = React.useState<Partial<CalendarEvent>[]>([]);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(new Set());
|
||||
const [calendarId, setCalendarId] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<number | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const resetState = React.useCallback(() => {
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
resetState();
|
||||
if (writable[0]) setCalendarId(writable[0].id);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, slideY, overlayOpacity, resetState]);
|
||||
|
||||
const pickFile = async () => {
|
||||
const picker = loadDocumentPicker();
|
||||
if (!picker) {
|
||||
Alert.alert(
|
||||
'Import unavailable',
|
||||
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let res;
|
||||
try {
|
||||
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not open the file picker');
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.assets.length === 0) return;
|
||||
const asset = res.assets[0];
|
||||
if (asset.size && asset.size > 5 * 1024 * 1024) {
|
||||
setError('That file is too large (max 5 MB).');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Upload the .ics so the server can parse it (CalendarEvent/parse).
|
||||
const { blobId } = await uploadBlob(asset.uri, 'text/calendar');
|
||||
const events = await parseCalendarBlob(blobId);
|
||||
const usable = events.filter((e) => !!e.start || e['@type'] === 'Task');
|
||||
if (usable.length === 0) {
|
||||
setError('No events were found in that file.');
|
||||
return;
|
||||
}
|
||||
setParsed(usable);
|
||||
setSelected(new Set(usable.map((_, i) => i)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
|
||||
const selectNone = () => setSelected(new Set());
|
||||
|
||||
const doImport = async () => {
|
||||
if (!calendarId) {
|
||||
Alert.alert('No calendar', 'Create a calendar before importing events.');
|
||||
return;
|
||||
}
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const count = await onImport(toImport, calendarId);
|
||||
setResult(count);
|
||||
onImported?.(count);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Import calendar</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{result !== null ? (
|
||||
<View style={styles.center}>
|
||||
<View style={styles.successBadge}>
|
||||
<Check size={26} color={c.primaryForeground} />
|
||||
</View>
|
||||
<Text style={styles.successText}>
|
||||
Imported {result} event{result === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
||||
</View>
|
||||
) : parsed.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={c.primary} />
|
||||
) : (
|
||||
<Upload size={32} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text>
|
||||
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text>
|
||||
</Pressable>
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{writable.length > 1 && (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
|
||||
{writable.map((cal) => (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => setCalendarId(cal.id)}
|
||||
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
|
||||
>
|
||||
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
|
||||
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.toolbarCount}>
|
||||
{parsed.length} found · {selected.size} selected
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
||||
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable>
|
||||
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{parsed.map((event, idx) => {
|
||||
const isSelected = selected.has(idx);
|
||||
const date = eventDateLabel(event);
|
||||
return (
|
||||
<Pressable
|
||||
key={idx}
|
||||
onPress={() => toggle(idx)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
{isSelected && <Check size={13} color={c.primaryForeground} />}
|
||||
</View>
|
||||
<Clock size={16} color={c.textMuted} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text>
|
||||
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
|
||||
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
|
||||
<Button
|
||||
onPress={() => { void doImport(); }}
|
||||
disabled={importing || selected.size === 0}
|
||||
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{importing ? 'Importing…' : `Import ${selected.size}`}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '85%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
|
||||
dropZone: {
|
||||
width: '100%',
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: radius.lg,
|
||||
paddingVertical: spacing.xxxl,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dropTitle: { ...typography.bodyMedium, color: c.text },
|
||||
dropHint: { ...typography.caption, color: c.textMuted },
|
||||
successBadge: {
|
||||
width: 56, height: 56, borderRadius: radius.full,
|
||||
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
successText: { ...typography.bodyMedium, color: c.text },
|
||||
calChips: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.sm },
|
||||
calChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
calSwatch: { width: 10, height: 10, borderRadius: 5 },
|
||||
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
toolbarCount: { ...typography.caption, color: c.textMuted },
|
||||
link: { ...typography.captionMedium, color: c.primary },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 48,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: radius.xs,
|
||||
borderWidth: 1, borderColor: c.border,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowDate: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
|
||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
|
||||
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
|
||||
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
|
||||
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setName('');
|
||||
setUrl('');
|
||||
setColor(CALENDAR_COLOR_PALETTE[0]);
|
||||
setFormError(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (!n || !u) {
|
||||
setFormError('Enter a name and a feed URL.');
|
||||
return;
|
||||
}
|
||||
if (!/^(https?|webcal):\/\//i.test(u)) {
|
||||
setFormError('URL must start with https://, http:// or webcal://');
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
const sub = await addSubscription({ name: n, url: u, color });
|
||||
if (sub.lastError) {
|
||||
setFormError(sub.lastError);
|
||||
} else {
|
||||
setName('');
|
||||
setUrl('');
|
||||
}
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Rss size={20} color={c.text} />
|
||||
<Text style={styles.title}>Calendar subscriptions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
|
||||
<Text style={styles.sectionLabel}>Add a feed</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Name (e.g. Holidays)"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
/>
|
||||
<TextInput
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://… or webcal://…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.colorRow}>
|
||||
{CALENDAR_COLOR_PALETTE.map((col) => (
|
||||
<Pressable
|
||||
key={col}
|
||||
onPress={() => setColor(col)}
|
||||
style={[
|
||||
styles.colorDot,
|
||||
{ backgroundColor: col },
|
||||
color === col && styles.colorDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{!!formError && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{formError}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => { void handleAdd(); }}
|
||||
disabled={adding}
|
||||
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{adding ? 'Subscribing…' : 'Subscribe'}
|
||||
</Button>
|
||||
|
||||
{subscriptions.length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
|
||||
{subscriptions.map((sub) => {
|
||||
const busy = !!syncing[sub.id];
|
||||
return (
|
||||
<View key={sub.id} style={styles.subRow}>
|
||||
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
|
||||
<Text style={styles.subMeta} numberOfLines={1}>
|
||||
{sub.lastError
|
||||
? sub.lastError
|
||||
: sub.lastSyncAt
|
||||
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
|
||||
: 'Not synced yet'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => { void syncSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<RefreshCw size={16} color={c.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void removeSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '88%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { flex: 1, ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
scroll: { padding: spacing.lg, gap: spacing.sm },
|
||||
sectionLabel: { ...typography.bodySemibold, color: c.text, marginBottom: spacing.xs },
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
|
||||
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
|
||||
colorDotActive: { borderColor: c.text },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
subSwatch: { width: 14, height: 14, borderRadius: 7 },
|
||||
subName: { ...typography.body, color: c.text },
|
||||
subMeta: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
subBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
Check,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
interface TasksSheetProps {
|
||||
visible: boolean;
|
||||
tasks: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onClose: () => void;
|
||||
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
|
||||
onToggle: (id: string) => Promise<void> | void;
|
||||
onDelete: (id: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function isCompleted(task: CalendarEvent): boolean {
|
||||
return task.progress === 'completed';
|
||||
}
|
||||
|
||||
function dueLabel(task: CalendarEvent): string | null {
|
||||
if (!task.due) return null;
|
||||
const d = parseISO(task.due);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return format(d, 'EEE, MMM d');
|
||||
}
|
||||
|
||||
// Tasks first by completion (open first), then by due date (soonest first,
|
||||
// undated last), then title.
|
||||
function compareTasks(a: CalendarEvent, b: CalendarEvent): number {
|
||||
const ac = isCompleted(a);
|
||||
const bc = isCompleted(b);
|
||||
if (ac !== bc) return ac ? 1 : -1;
|
||||
const ad = a.due ? parseISO(a.due).getTime() : Infinity;
|
||||
const bd = b.due ? parseISO(b.due).getTime() : Infinity;
|
||||
if (ad !== bd) return ad - bd;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
}
|
||||
|
||||
export function TasksSheet({
|
||||
visible,
|
||||
tasks,
|
||||
calendars,
|
||||
onClose,
|
||||
onCreate,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: TasksSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({
|
||||
slideY,
|
||||
closedY: Dimensions.get('window').height,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const writableCalendars = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [calendarId, setCalendarId] = React.useState('');
|
||||
const [due, setDue] = React.useState<Date | null>(null);
|
||||
const [showDuePicker, setShowDuePicker] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible && !calendarId && writableCalendars[0]) {
|
||||
setCalendarId(writableCalendars[0].id);
|
||||
}
|
||||
}, [visible, calendarId, writableCalendars]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: Dimensions.get('window').height, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const t = title.trim();
|
||||
if (!t || !calendarId || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const task: Partial<CalendarEvent> = {
|
||||
title: t,
|
||||
progress: 'needs-action',
|
||||
};
|
||||
if (due) {
|
||||
task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
}
|
||||
await onCreate(task, calendarId);
|
||||
setTitle('');
|
||||
setDue(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<ListChecks size={20} color={c.text} />
|
||||
<Text style={styles.headerTitle}>Tasks</Text>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<X size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.addRow}>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Add a task…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.addInput}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void handleAdd(); }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => setShowDuePicker(true)}
|
||||
style={[styles.dueChip, due && styles.dueChipActive]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
|
||||
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void handleAdd(); }}
|
||||
disabled={!title.trim() || !calendarId || saving}
|
||||
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
|
||||
>
|
||||
<Plus size={18} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{writableCalendars.length > 1 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.calChips}
|
||||
>
|
||||
{writableCalendars.map((cal) => (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => setCalendarId(cal.id)}
|
||||
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
|
||||
>
|
||||
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
|
||||
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
||||
{sorted.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<ListChecks size={32} color={c.surfaceActive} />
|
||||
<Text style={styles.emptyText}>No tasks yet</Text>
|
||||
</View>
|
||||
) : (
|
||||
sorted.map((task) => {
|
||||
const completed = isCompleted(task);
|
||||
const due = dueLabel(task);
|
||||
return (
|
||||
<View key={task.id} style={styles.taskRow}>
|
||||
<Pressable
|
||||
onPress={() => { void onToggle(task.id); }}
|
||||
style={[styles.checkbox, completed && styles.checkboxChecked]}
|
||||
hitSlop={8}
|
||||
>
|
||||
{completed && <Check size={14} color={c.primaryForeground} />}
|
||||
</Pressable>
|
||||
<View style={styles.taskText}>
|
||||
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
|
||||
{task.title || 'Untitled task'}
|
||||
</Text>
|
||||
{due && <Text style={styles.taskDue}>{due}</Text>}
|
||||
</View>
|
||||
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
|
||||
<Trash2 size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
|
||||
{showDuePicker && (
|
||||
<DateTimePicker
|
||||
value={due ?? new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={(_, d) => {
|
||||
setShowDuePicker(false);
|
||||
if (d) setDue(d);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '85%',
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.surfaceActive },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
headerTitle: { flex: 1, ...typography.h3, color: c.text },
|
||||
closeBtn: { padding: 4 },
|
||||
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
addInput: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
dueChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
height: 38,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
dueChipText: { ...typography.caption, color: c.primary },
|
||||
addBtn: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
addBtnDisabled: { opacity: 0.5 },
|
||||
|
||||
calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm },
|
||||
calChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
calSwatch: { width: 10, height: 10, borderRadius: 5 },
|
||||
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||
|
||||
list: { flexGrow: 0 },
|
||||
listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
|
||||
empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm },
|
||||
emptyText: { ...typography.body, color: c.textMuted },
|
||||
|
||||
taskRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
taskText: { flex: 1 },
|
||||
taskTitle: { ...typography.body, color: c.text },
|
||||
taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted },
|
||||
taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
taskDelete: { padding: 4 },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { BookUser, X, Check, Plus } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Book the contact(s) currently live in - shown with a check, not selectable. */
|
||||
currentBookId?: string | null;
|
||||
onPick: (addressBookId: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function AddressBookPickerSheet({
|
||||
visible, onClose, currentBookId, onPick, title = 'Move to address book',
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [newName, setNewName] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const book = await createAddressBook(name);
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
onPick(book.id);
|
||||
} catch (err) {
|
||||
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
{addressBooks.map((book) => {
|
||||
const isCurrent = book.id === currentBookId;
|
||||
const canWrite = book.myRights?.mayWrite !== false;
|
||||
const canTarget = canWrite && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={book.id}
|
||||
onPress={canTarget ? () => onPick(book.id) : undefined}
|
||||
disabled={!canTarget}
|
||||
style={({ pressed }) => [styles.row, pressed && canTarget && styles.rowPressed]}
|
||||
>
|
||||
<BookUser size={16} color={canTarget ? c.textSecondary : c.textMuted} />
|
||||
<Text style={[styles.rowLabel, !canTarget && styles.rowLabelDisabled]} numberOfLines={1}>
|
||||
{book.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{creating ? (
|
||||
<View style={styles.createRow}>
|
||||
<TextInput
|
||||
style={styles.createInput}
|
||||
placeholder="New address book name"
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void handleCreate(); }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => { void handleCreate(); }}
|
||||
disabled={!newName.trim() || busy}
|
||||
style={[styles.createBtn, (!newName.trim() || busy) && styles.createBtnDisabled]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Check size={16} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => setCreating(true)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={[styles.rowLabel, { color: c.primary }]}>New address book…</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
rowLabelDisabled: { color: c.textMuted },
|
||||
createRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
createInput: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
height: componentSizes.inputHeight,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
createBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
createBtnDisabled: { opacity: 0.4 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import { parseVCard, detectDuplicates } from '../../lib/vcard';
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen upload flow).
|
||||
type DocumentPickerModule = typeof import('expo-document-picker');
|
||||
let documentPickerModule: DocumentPickerModule | null = null;
|
||||
function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
if (documentPickerModule) return documentPickerModule;
|
||||
try {
|
||||
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
|
||||
return documentPickerModule;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Address book the imported contacts are created in. */
|
||||
targetBookId: string | null;
|
||||
targetBookName?: string;
|
||||
onChangeTarget?: () => void;
|
||||
onImported?: (count: number) => void;
|
||||
}
|
||||
|
||||
export default function ContactImportSheet({
|
||||
visible, onClose, targetBookId, targetBookName, onChangeTarget, onImported,
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const existing = useContactsStore((s) => s.contacts);
|
||||
const importContacts = useContactsStore((s) => s.importContacts);
|
||||
|
||||
const [parsed, setParsed] = React.useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<number | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const resetState = React.useCallback(() => {
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setDuplicates(new Map());
|
||||
setError(null);
|
||||
setImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
resetState();
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity, resetState]);
|
||||
|
||||
const pickFile = async () => {
|
||||
const picker = loadDocumentPicker();
|
||||
if (!picker) {
|
||||
Alert.alert(
|
||||
'Import unavailable',
|
||||
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let res;
|
||||
try {
|
||||
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not open the file picker');
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.assets.length === 0) return;
|
||||
const asset = res.assets[0];
|
||||
|
||||
if (asset.size && asset.size > 5 * 1024 * 1024) {
|
||||
setError('That file is too large (max 5 MB).');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await FileSystem.readAsStringAsync(asset.uri);
|
||||
const contacts = parseVCard(text);
|
||||
if (contacts.length === 0) {
|
||||
setError('No contacts were found in that file.');
|
||||
return;
|
||||
}
|
||||
const dupes = detectDuplicates(existing, contacts);
|
||||
const initial = new Set<number>();
|
||||
contacts.forEach((_, idx) => { if (!dupes.has(idx)) initial.add(idx); });
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
setSelected(initial);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
|
||||
const selectNone = () => setSelected(new Set());
|
||||
|
||||
const doImport = async () => {
|
||||
if (!targetBookId) {
|
||||
Alert.alert('No address book', 'Create an address book before importing contacts.');
|
||||
return;
|
||||
}
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const count = await importContacts(toImport, targetBookId);
|
||||
setResult(count);
|
||||
onImported?.(count);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Import contacts</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{result !== null ? (
|
||||
<View style={styles.center}>
|
||||
<View style={styles.successBadge}>
|
||||
<Check size={26} color={c.primaryForeground} />
|
||||
</View>
|
||||
<Text style={styles.successText}>
|
||||
Imported {result} contact{result === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
||||
</View>
|
||||
) : parsed.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }}>
|
||||
<Upload size={32} color={c.textMuted} />
|
||||
<Text style={styles.dropTitle}>Choose a vCard file</Text>
|
||||
<Text style={styles.dropHint}>.vcf / .vcard — single or multiple contacts</Text>
|
||||
</Pressable>
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Pressable
|
||||
style={styles.targetRow}
|
||||
onPress={onChangeTarget}
|
||||
disabled={!onChangeTarget}
|
||||
>
|
||||
<BookUser size={16} color={c.textSecondary} />
|
||||
<Text style={styles.targetText} numberOfLines={1}>
|
||||
Import into {targetBookName || 'address book'}
|
||||
</Text>
|
||||
{onChangeTarget && <Text style={styles.targetChange}>Change</Text>}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.toolbarCount}>
|
||||
{parsed.length} found · {selected.size} selected
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
||||
<Pressable onPress={selectAll} hitSlop={6}>
|
||||
<Text style={styles.link}>All</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={selectNone} hitSlop={6}>
|
||||
<Text style={styles.link}>None</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{parsed.map((contact, idx) => {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const isDupe = duplicates.has(idx);
|
||||
const isSelected = selected.has(idx);
|
||||
return (
|
||||
<Pressable
|
||||
key={idx}
|
||||
onPress={() => toggle(idx)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
{isSelected && <Check size={13} color={c.primaryForeground} />}
|
||||
</View>
|
||||
<FileText size={16} color={c.textMuted} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{name || email || '—'}</Text>
|
||||
{!!email && !!name && (
|
||||
<Text style={styles.rowEmail} numberOfLines={1}>{email}</Text>
|
||||
)}
|
||||
</View>
|
||||
{isDupe && (
|
||||
<Text style={styles.dupeBadge}>Duplicate</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
|
||||
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
|
||||
<Button
|
||||
onPress={() => { void doImport(); }}
|
||||
disabled={importing || selected.size === 0}
|
||||
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : undefined}
|
||||
>
|
||||
{importing ? 'Importing…' : `Import ${selected.size}`}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '85%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
|
||||
dropZone: {
|
||||
width: '100%',
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: radius.lg,
|
||||
paddingVertical: spacing.xxxl,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dropTitle: { ...typography.bodyMedium, color: c.text },
|
||||
dropHint: { ...typography.caption, color: c.textMuted },
|
||||
successBadge: {
|
||||
width: 56, height: 56, borderRadius: radius.full,
|
||||
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
successText: { ...typography.bodyMedium, color: c.text },
|
||||
targetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
targetText: { ...typography.caption, color: c.textSecondary, flex: 1 },
|
||||
targetChange: { ...typography.captionMedium, color: c.primary },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
toolbarCount: { ...typography.caption, color: c.textMuted },
|
||||
link: { ...typography.captionMedium, color: c.primary },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 48,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: radius.xs,
|
||||
borderWidth: 1, borderColor: c.border,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
dupeBadge: {
|
||||
...typography.small,
|
||||
color: c.warning,
|
||||
backgroundColor: c.warningBg,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Tag, X, Plus } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { useContactsStore, selectKeywordsUsed } from '../../stores/contacts-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the chosen tag (existing or freshly typed). */
|
||||
onPick: (keyword: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function TagAssignSheet({
|
||||
visible, onClose, onPick, title = 'Add tag',
|
||||
}: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const keywords = useContactsStore(selectKeywordsUsed);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setInput('');
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const submitNew = () => {
|
||||
const kw = input.trim();
|
||||
if (!kw) return;
|
||||
onPick(kw);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="New tag name"
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={submitNew}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={submitNew}
|
||||
disabled={!input.trim()}
|
||||
style={[styles.addBtn, !input.trim() && styles.addBtnDisabled]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Plus size={16} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={styles.empty}>No tags yet. Type one above.</Text>
|
||||
) : (
|
||||
keywords.map((kw) => (
|
||||
<Pressable
|
||||
key={kw.keyword}
|
||||
onPress={() => onPick(kw.keyword)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Tag size={16} color={c.textSecondary} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{kw.keyword}</Text>
|
||||
<Text style={styles.rowCount}>{kw.count}</Text>
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
height: componentSizes.inputHeight,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
addBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
addBtnDisabled: { opacity: 0.4 },
|
||||
empty: { ...typography.caption, color: c.textMuted, textAlign: 'center', paddingVertical: spacing.xl },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
rowCount: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { default as AddressBookPickerSheet } from './AddressBookPickerSheet';
|
||||
export { default as ContactImportSheet } from './ContactImportSheet';
|
||||
export { default as ContactListRow } from './ContactListRow';
|
||||
export { default as ContactPickerSheet } from './ContactPickerSheet';
|
||||
export { default as ContactsSidebarDrawer } from './ContactsSidebarDrawer';
|
||||
export { default as TagAssignSheet } from './TagAssignSheet';
|
||||
export { default as FieldBlock, FieldRow } from './FieldBlock';
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native';
|
||||
import {
|
||||
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
|
||||
} from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Email, CalendarEvent } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { parseCalendarBlob } from '../../api/calendar';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import {
|
||||
findCalendarAttachment,
|
||||
findParticipantByEmail,
|
||||
getOrganizerName,
|
||||
buildReplyTo,
|
||||
inferInvitationMethod,
|
||||
type InvitationMethod,
|
||||
} from '../../lib/calendar-invitation';
|
||||
|
||||
type BannerState = 'loading' | 'parsed' | 'done' | 'error';
|
||||
type RsvpStatus = 'accepted' | 'tentative' | 'declined';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
}
|
||||
|
||||
export function CalendarInvitationBanner({ email }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const enabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||
|
||||
const attachment = React.useMemo(() => findCalendarAttachment(email), [email]);
|
||||
|
||||
const [state, setState] = React.useState<BannerState>('loading');
|
||||
const [event, setEvent] = React.useState<Partial<CalendarEvent> | null>(null);
|
||||
const [method, setMethod] = React.useState<InvitationMethod>('unknown');
|
||||
const [rsvpStatus, setRsvpStatus] = React.useState<RsvpStatus | null>(null);
|
||||
const [notice, setNotice] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!attachment || !enabled) return;
|
||||
setState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const events = await parseCalendarBlob(attachment.blobId);
|
||||
if (cancelled) return;
|
||||
if (events.length === 0) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
const parsed = events[0];
|
||||
setEvent(parsed);
|
||||
setMethod(inferInvitationMethod(parsed));
|
||||
setState('parsed');
|
||||
} catch {
|
||||
if (!cancelled) setState('error');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [attachment, enabled]);
|
||||
|
||||
if (!attachment || !enabled || state === 'error') return null;
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<Text style={styles.loadingText}>Reading invitation…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!event) return null;
|
||||
|
||||
const startStr = event.showWithoutTime ? event.start : (event.utcStart || event.start);
|
||||
const startDate = startStr ? parseISO(startStr) : null;
|
||||
const dateLabel = startDate && !isNaN(startDate.getTime())
|
||||
? (event.showWithoutTime
|
||||
? format(startDate, 'EEEE, MMM d, yyyy')
|
||||
: format(startDate, 'EEE, MMM d · HH:mm'))
|
||||
: null;
|
||||
const organizer = getOrganizerName(event);
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
||||
const canRsvp = method !== 'cancel' && method !== 'reply' && !!me;
|
||||
|
||||
const writableCalendar = calendars.find((cal) => !cal.myRights || cal.myRights.mayWrite !== false);
|
||||
|
||||
const ensureImportedAndRsvp = async (status: RsvpStatus) => {
|
||||
if (busy || !writableCalendar) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
// Make sure the event exists in a local calendar (dedupes by UID).
|
||||
await importEvents([event], writableCalendar.id);
|
||||
// Re-read from the store to get the server-assigned id + participant.
|
||||
const stored = useCalendarStore.getState().events.find((e) => e.uid === event.uid);
|
||||
const participant = stored && activeEmail
|
||||
? findParticipantByEmail(stored, [activeEmail])
|
||||
: me;
|
||||
if (stored && participant) {
|
||||
await rsvpEvent(stored.id, participant.id, status, buildReplyTo(event));
|
||||
setRsvpStatus(status);
|
||||
setNotice('Response sent');
|
||||
} else {
|
||||
setNotice('Added to calendar');
|
||||
}
|
||||
setState('done');
|
||||
} catch {
|
||||
setNotice('Could not send your response');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (busy || !writableCalendar) return;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const count = await importEvents([event], writableCalendar.id);
|
||||
setNotice(count > 0 ? 'Added to calendar' : 'Already in your calendar');
|
||||
setState('done');
|
||||
} catch {
|
||||
setNotice('Could not add the event');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.iconBadge}>
|
||||
<CalendarDays size={18} color={c.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.title} numberOfLines={2}>{event.title || 'Calendar invitation'}</Text>
|
||||
{method === 'cancel' && <Text style={styles.cancelled}>This event was cancelled</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{dateLabel && (
|
||||
<Row icon={<Clock size={15} color={c.textMuted} />} text={dateLabel} styles={styles} />
|
||||
)}
|
||||
{location ? (
|
||||
<Row icon={<MapPin size={15} color={c.textMuted} />} text={location} styles={styles} />
|
||||
) : null}
|
||||
{videoUri ? (
|
||||
<Pressable style={styles.detailRow} onPress={() => { void Linking.openURL(videoUri); }}>
|
||||
<Video size={15} color={c.textMuted} />
|
||||
<Text style={[styles.detailText, { color: c.primary }]} numberOfLines={1}>Join video call</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{organizer ? (
|
||||
<Row icon={<CalendarPlus size={15} color={c.textMuted} />} text={`Organizer: ${organizer}`} styles={styles} />
|
||||
) : null}
|
||||
|
||||
{notice && <Text style={styles.notice}>{notice}</Text>}
|
||||
|
||||
{state !== 'done' && (
|
||||
<View style={styles.actions}>
|
||||
{canRsvp ? (
|
||||
<>
|
||||
<RsvpBtn label="Yes" active={rsvpStatus === 'accepted'} activeColor={c.success}
|
||||
icon={<Check size={15} color={rsvpStatus === 'accepted' ? c.textInverse : c.success} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('accepted')} c={c} styles={styles} />
|
||||
<RsvpBtn label="Maybe" active={rsvpStatus === 'tentative'} activeColor={c.warning}
|
||||
icon={<HelpCircle size={15} color={rsvpStatus === 'tentative' ? c.textInverse : c.warning} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('tentative')} c={c} styles={styles} />
|
||||
<RsvpBtn label="No" active={rsvpStatus === 'declined'} activeColor={c.error}
|
||||
icon={<X size={15} color={rsvpStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} />
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
style={[styles.importBtn, busy && { opacity: 0.5 }]}
|
||||
onPress={() => { void handleImport(); }}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.primaryForeground} />
|
||||
) : (
|
||||
<CalendarPlus size={16} color={c.primaryForeground} />
|
||||
)}
|
||||
<Text style={styles.importBtnText}>Add to calendar</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!writableCalendar && state !== 'done' && (
|
||||
<View style={styles.warnRow}>
|
||||
<AlertTriangle size={14} color={c.warning} />
|
||||
<Text style={styles.warnText}>No writable calendar available</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon, text, styles,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.detailRow}>
|
||||
{icon}
|
||||
<Text style={styles.detailText} numberOfLines={2}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RsvpBtn({
|
||||
label, icon, active, activeColor, disabled, onPress, c, styles,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
activeColor: string;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
c: ThemePalette;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.rsvpBtn,
|
||||
active && { backgroundColor: activeColor, borderColor: activeColor },
|
||||
disabled && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
loadingText: { ...typography.caption, color: c.textMuted },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.xs },
|
||||
iconBadge: {
|
||||
width: 32, height: 32, borderRadius: radius.sm,
|
||||
backgroundColor: c.primaryBg, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
cancelled: { ...typography.caption, color: c.error, marginTop: 2 },
|
||||
detailRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: 2 },
|
||||
detailText: { flex: 1, ...typography.caption, color: c.textSecondary },
|
||||
notice: { ...typography.captionMedium, color: c.primary, marginTop: spacing.xs },
|
||||
actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm },
|
||||
rsvpBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
rsvpBtnText: { ...typography.caption, color: c.text },
|
||||
importBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
importBtnText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs },
|
||||
warnText: { ...typography.caption, color: c.warning },
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import { ExternalLink } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { CloudDownload, ExternalLink } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '1', label: 'Last 24 hours' },
|
||||
{ value: '3', label: 'Last 3 days' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
{ value: '14', label: 'Last 14 days' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: '90', label: 'Last 90 days' },
|
||||
];
|
||||
|
||||
function formatRelativeTime(ms: number | undefined): string {
|
||||
if (!ms) return 'never';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
|
||||
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
|
||||
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
|
||||
}
|
||||
|
||||
const DEBUG_CATEGORIES = [
|
||||
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
|
||||
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
|
||||
@@ -36,9 +56,36 @@ export function AboutDataSettings() {
|
||||
const [syncEnabled, setSyncEnabled] = useState(true);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
|
||||
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
|
||||
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
|
||||
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
|
||||
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
if (!cacheHydrated) void cacheHydrate();
|
||||
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
|
||||
|
||||
const handleSyncNow = () => {
|
||||
void runOfflineSync({ days: offlineDays });
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
Alert.alert(
|
||||
'Clear offline mail',
|
||||
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetExternalContentPolicy('ask');
|
||||
@@ -72,6 +119,80 @@ export function AboutDataSettings() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingsSection
|
||||
title="Offline mail"
|
||||
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
|
||||
>
|
||||
<SettingItem
|
||||
label="Cache recent mail"
|
||||
description="When on, the app keeps message bodies for the selected window on this device."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={offlineEnabled}
|
||||
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Window"
|
||||
description="How far back to cache, measured by message receipt date."
|
||||
>
|
||||
<Select
|
||||
value={String(offlineDays)}
|
||||
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
|
||||
options={DAY_OPTIONS}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.cacheStatsBox}>
|
||||
<View style={styles.cacheStatsHeader}>
|
||||
<CloudDownload size={16} color={c.textSecondary} />
|
||||
<Text style={styles.cacheStatsTitle}>
|
||||
{cacheCount === 0
|
||||
? 'Nothing cached yet'
|
||||
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{sync.phase === 'fetching'
|
||||
? `Downloading ${sync.completed}/${sync.total}…`
|
||||
: sync.phase === 'scanning'
|
||||
? 'Scanning recent mail…'
|
||||
: sync.phase === 'error'
|
||||
? `Last sync failed: ${sync.message ?? 'unknown error'}`
|
||||
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
|
||||
</Text>
|
||||
{syncBusy && sync.total > 0 && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.cacheActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleSyncNow}
|
||||
disabled={!offlineEnabled || syncBusy}
|
||||
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
|
||||
>
|
||||
{syncBusy ? 'Syncing…' : 'Sync now'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleClearCache}
|
||||
disabled={cacheCount === 0 || syncBusy}
|
||||
>
|
||||
Clear cache
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
|
||||
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
|
||||
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
|
||||
@@ -170,5 +291,29 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cacheStatsBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cacheStatsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
|
||||
progressTrack: {
|
||||
height: 4,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: { height: '100%', backgroundColor: c.primary },
|
||||
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import {
|
||||
clearClientCertAlias,
|
||||
getClientCertAlias,
|
||||
isClientCertSupported,
|
||||
pickClientCertAlias,
|
||||
} from '../../lib/client-cert';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -30,8 +37,96 @@ export function AccountSecuritySettings() {
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
|
||||
|
||||
const certSupported = isClientCertSupported();
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certSupported) return;
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, [certSupported]);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
// Hint the picker with the JMAP server host so it can pre-filter to certs
|
||||
// issued for it when the OEM picker honours that.
|
||||
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
|
||||
const alias = await pickClientCertAlias(host);
|
||||
setCertAlias(alias);
|
||||
} catch (err) {
|
||||
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearCert = async () => {
|
||||
if (certBusy) return;
|
||||
Alert.alert(
|
||||
'Stop using client certificate?',
|
||||
'New requests will no longer present a certificate. The certificate stays installed in Android.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setCertBusy(true);
|
||||
try {
|
||||
await clearClientCertAlias();
|
||||
setCertAlias(null);
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && (
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>
|
||||
{certAlias ? `Active: ${certAlias}` : 'Not set'}
|
||||
</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & 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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,8 @@ export function ComposingSettings() {
|
||||
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
@@ -37,6 +39,14 @@ export function ComposingSettings() {
|
||||
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
|
||||
};
|
||||
|
||||
const SEND_DELAY_OPTIONS: { label: string; value: number }[] = [
|
||||
{ label: 'Off', value: 0 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
{ label: '20s', value: 20 },
|
||||
{ label: '30s', value: 30 },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Composing"
|
||||
@@ -49,6 +59,29 @@ export function ComposingSettings() {
|
||||
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.subBlock}>
|
||||
<Text style={styles.subLabel}>Undo send delay</Text>
|
||||
<Text style={styles.subDescription}>
|
||||
Hold outgoing mail for a few seconds so you can cancel it. Requires server support.
|
||||
</Text>
|
||||
<View style={styles.segmentRow}>
|
||||
{SEND_DELAY_OPTIONS.map((opt) => {
|
||||
const active = sendDelaySeconds === opt.value;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
onPress={() => updateSetting('sendDelaySeconds', opt.value)}
|
||||
style={[styles.segment, active && styles.segmentActive]}
|
||||
>
|
||||
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingItem
|
||||
label="Attachment reminder"
|
||||
description="Warn before sending if the message mentions an attachment but none is attached."
|
||||
@@ -110,6 +143,19 @@ function makeStyles(c: ThemePalette) {
|
||||
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
|
||||
subLabel: { ...typography.bodyMedium, color: c.text },
|
||||
subDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
|
||||
segment: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
segmentActive: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
segmentText: { ...typography.caption, color: c.text },
|
||||
segmentTextActive: { color: c.primaryForeground, fontWeight: '600' },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Alert, Share, Text } from 'react-native';
|
||||
import { Alert, Share, Text, View, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { Download } from 'lucide-react-native';
|
||||
import {
|
||||
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { ContactImportSheet } from '../contacts';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
|
||||
import { contactsToVCard } from '../../lib/vcard';
|
||||
import { isGroup } from '../../lib/contact-utils';
|
||||
import { typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
export function ContactsSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const books = useContactsStore(selectAddressBooksWithCount);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
const renameAddressBook = useContactsStore((s) => s.renameAddressBook);
|
||||
const deleteAddressBook = useContactsStore((s) => s.deleteAddressBook);
|
||||
|
||||
const [exporting, setExporting] = React.useState(false);
|
||||
const [importOpen, setImportOpen] = React.useState(false);
|
||||
|
||||
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editName, setEditName] = React.useState('');
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [newName, setNewName] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
|
||||
|
||||
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
|
||||
const exportLabel =
|
||||
exportable.length === 0
|
||||
? 'No contacts to export'
|
||||
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
|
||||
const importTargetBookId = books[0]?.id ?? null;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (exportable.length === 0 || exporting) return;
|
||||
@@ -50,40 +70,256 @@ export function ContactsSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (id: string, name: string) => {
|
||||
setAdding(false);
|
||||
setEditingId(id);
|
||||
setEditName(name);
|
||||
};
|
||||
|
||||
const commitRename = async () => {
|
||||
const name = editName.trim();
|
||||
if (!editingId || !name || busy) { setEditingId(null); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await renameAddressBook(editingId, name);
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
Alert.alert('Rename failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitCreate = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await createAddressBook(name);
|
||||
setAdding(false);
|
||||
setNewName('');
|
||||
} catch (err) {
|
||||
Alert.alert('Could not create address book', err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const { id } = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
try {
|
||||
await deleteAddressBook(id);
|
||||
} catch (err) {
|
||||
Alert.alert('Delete failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Contacts"
|
||||
description="Display preferences and address-book tools."
|
||||
>
|
||||
<SettingItem
|
||||
label="Group by first letter"
|
||||
description="Show alphabetical section headers in the contacts list."
|
||||
<>
|
||||
<SettingsSection
|
||||
title="Contacts"
|
||||
description="Display preferences and address-book tools."
|
||||
>
|
||||
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Export contacts"
|
||||
description={exportLabel}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Download size={14} color={c.text} />}
|
||||
onPress={() => { void handleExport(); }}
|
||||
disabled={exportable.length === 0 || exporting}
|
||||
loading={exporting}
|
||||
<SettingItem
|
||||
label="Group by first letter"
|
||||
description="Show alphabetical section headers in the contacts list."
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Import contacts"
|
||||
description="Importing vCard files is currently available on the web client only."
|
||||
<SettingItem
|
||||
label="Import contacts"
|
||||
description="Import contacts from a vCard (.vcf) file with duplicate detection."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Upload size={14} color={c.text} />}
|
||||
onPress={() => setImportOpen(true)}
|
||||
disabled={books.length === 0}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Export contacts"
|
||||
description={exportLabel}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Download size={14} color={c.text} />}
|
||||
onPress={() => { void handleExport(); }}
|
||||
disabled={exportable.length === 0 || exporting}
|
||||
loading={exporting}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Address books"
|
||||
description="Create, rename, and remove the address books that organize your contacts."
|
||||
>
|
||||
<Text style={[typography.caption, { color: c.textMuted }]}>Web only</Text>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
{books.map((book) => (
|
||||
<View key={book.id} style={styles.bookRow}>
|
||||
<BookUser size={16} color={c.textSecondary} />
|
||||
{editingId === book.id ? (
|
||||
<>
|
||||
<TextInput
|
||||
style={styles.bookInput}
|
||||
value={editName}
|
||||
onChangeText={setEditName}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void commitRename(); }}
|
||||
placeholderTextColor={c.textMuted}
|
||||
/>
|
||||
<Pressable onPress={() => { void commitRename(); }} hitSlop={6} style={styles.iconBtn}>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setEditingId(null)} hitSlop={6} style={styles.iconBtn}>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.bookName} numberOfLines={1}>{book.name}</Text>
|
||||
<Text style={styles.bookCount}>
|
||||
{book.count} contact{book.count === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</View>
|
||||
{book.myRights?.mayWrite !== false && (
|
||||
<Pressable onPress={() => startRename(book.id, book.name)} hitSlop={6} style={styles.iconBtn}>
|
||||
<Pencil size={15} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
{book.myRights?.mayDelete !== false && books.length > 1 && (
|
||||
<Pressable
|
||||
onPress={() => setDeleteTarget({ id: book.id, name: book.name, count: book.count })}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
>
|
||||
<Trash2 size={15} color={c.error} />
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<View style={styles.bookRow}>
|
||||
<BookUser size={16} color={c.textSecondary} />
|
||||
<TextInput
|
||||
style={styles.bookInput}
|
||||
value={newName}
|
||||
onChangeText={setNewName}
|
||||
placeholder="New address book name"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={() => { void commitCreate(); }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => { void commitCreate(); }}
|
||||
disabled={!newName.trim() || busy}
|
||||
hitSlop={6}
|
||||
style={styles.iconBtn}
|
||||
>
|
||||
<Check size={16} color={c.primary} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { setAdding(false); setNewName(''); }} hitSlop={6} style={styles.iconBtn}>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => { setEditingId(null); setAdding(true); }}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addLabel}>New address book</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={{ marginTop: spacing.sm }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => { void fetchAddressBooks(); void fetchContacts(); }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<ContactImportSheet
|
||||
visible={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
targetBookId={importTargetBookId}
|
||||
targetBookName={books[0]?.name}
|
||||
onImported={() => { void fetchContacts(); }}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={deleteTarget !== null}
|
||||
title="Delete address book"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Delete "${deleteTarget.name}"?${deleteTarget.count > 0 ? ` Its ${deleteTarget.count} contact${deleteTarget.count === 1 ? '' : 's'} will be removed from this book.` : ''} This cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
bookRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
bookName: { ...typography.body, color: c.text },
|
||||
bookCount: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
bookInput: {
|
||||
flex: 1,
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
height: componentSizes.inputHeight,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
iconBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
addLabel: { ...typography.body, color: c.primary },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'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="-- Jane Doe 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,15 +13,17 @@ 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,
|
||||
readPushAccountIds,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../../lib/push-notifications';
|
||||
|
||||
const SOUNDS = [
|
||||
const SOUNDS: { value: NotificationSound; label: string }[] = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'ping', label: 'Ping' },
|
||||
@@ -39,19 +41,27 @@ export function NotificationSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
const [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();
|
||||
@@ -98,10 +108,20 @@ export function NotificationSettings() {
|
||||
setConfirmDisable(false);
|
||||
setPushStatus({ kind: 'busy', message: 'Disabling…' });
|
||||
try {
|
||||
await teardownPushNotifications();
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId);
|
||||
}
|
||||
// The relay base URL is a device-wide setting shared with any other
|
||||
// accounts that are still using push. Only clear it (and reset the UI
|
||||
// to "not configured") when no accounts are using push any more —
|
||||
// otherwise the remaining accounts would lose their relay URL and
|
||||
// their next setup attempt would no-op.
|
||||
const remaining = await readPushAccountIds();
|
||||
if (remaining.length === 0) {
|
||||
await setStoredRelayBaseUrl(null);
|
||||
setRelayUrl(DEFAULT_RELAY_BASE_URL);
|
||||
setHasSaved(false);
|
||||
}
|
||||
setPushStatus({ kind: 'idle' });
|
||||
} catch (error) {
|
||||
setPushStatus({
|
||||
@@ -190,7 +210,7 @@ export function NotificationSettings() {
|
||||
/>
|
||||
<Select
|
||||
value={sound}
|
||||
onChange={setSound}
|
||||
onChange={(v) => update('notificationSoundChoice', v as NotificationSound)}
|
||||
options={SOUNDS}
|
||||
/>
|
||||
</View>
|
||||
@@ -199,22 +219,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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
offsetToMinutesBefore,
|
||||
minutesBeforeToOffset,
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
formatReminder,
|
||||
} from '../calendar-alerts';
|
||||
|
||||
describe('calendar-alerts', () => {
|
||||
describe('offsetToMinutesBefore', () => {
|
||||
it('parses minute offsets', () => {
|
||||
expect(offsetToMinutesBefore('-PT15M')).toBe(15);
|
||||
expect(offsetToMinutesBefore('-PT30M')).toBe(30);
|
||||
});
|
||||
it('parses hour and day offsets', () => {
|
||||
expect(offsetToMinutesBefore('-PT1H')).toBe(60);
|
||||
expect(offsetToMinutesBefore('-P1D')).toBe(60 * 24);
|
||||
});
|
||||
it('treats PT0S as at-time (0)', () => {
|
||||
expect(offsetToMinutesBefore('PT0S')).toBe(0);
|
||||
});
|
||||
it('returns negative minutesBefore for post-start offsets', () => {
|
||||
expect(offsetToMinutesBefore('PT10M')).toBe(-10);
|
||||
});
|
||||
it('returns null for garbage', () => {
|
||||
expect(offsetToMinutesBefore('nonsense')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('minutesBeforeToOffset', () => {
|
||||
it('round-trips through offsetToMinutesBefore', () => {
|
||||
for (const m of [0, 5, 15, 30, 60, 120, 60 * 24, 60 * 24 * 7]) {
|
||||
expect(offsetToMinutesBefore(minutesBeforeToOffset(m))).toBe(m);
|
||||
}
|
||||
});
|
||||
it('emits PT0S at time of event', () => {
|
||||
expect(minutesBeforeToOffset(0)).toBe('PT0S');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertsToReminders / remindersToAlerts', () => {
|
||||
it('extracts and de-dupes reminders, sorted by lead time desc', () => {
|
||||
const reminders = alertsToReminders({
|
||||
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
|
||||
b: { trigger: { '@type': 'OffsetTrigger', offset: '-P1D' }, action: 'display' },
|
||||
c: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
|
||||
});
|
||||
expect(reminders).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 15 }]);
|
||||
});
|
||||
|
||||
it('builds an alerts map from reminders', () => {
|
||||
const alerts = remindersToAlerts([{ minutesBefore: 15 }, { minutesBefore: 0 }]);
|
||||
expect(alerts && Object.keys(alerts)).toHaveLength(2);
|
||||
const offsets = Object.values(alerts!).map((a) => a.trigger.offset);
|
||||
expect(offsets).toContain('-PT15M');
|
||||
expect(offsets).toContain('PT0S');
|
||||
});
|
||||
|
||||
it('returns undefined for an empty reminder list', () => {
|
||||
expect(remindersToAlerts([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips alerts → reminders → alerts', () => {
|
||||
const reminders = [{ minutesBefore: 60 }, { minutesBefore: 10 }];
|
||||
const back = alertsToReminders(remindersToAlerts(reminders));
|
||||
expect(back).toEqual([{ minutesBefore: 60 }, { minutesBefore: 10 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReminder', () => {
|
||||
it('formats common offsets', () => {
|
||||
expect(formatReminder(0)).toBe('At time of event');
|
||||
expect(formatReminder(15)).toBe('15 minutes before');
|
||||
expect(formatReminder(60)).toBe('1 hour before');
|
||||
expect(formatReminder(60 * 24)).toBe('1 day before');
|
||||
expect(formatReminder(60 * 24 * 7)).toBe('1 week before');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseVCard,
|
||||
generateVCard,
|
||||
contactToVCard,
|
||||
detectDuplicates,
|
||||
} from '../vcard';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
|
||||
describe('parseVCard', () => {
|
||||
it('parses a basic 3.0 card with name, email, and phone', () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Jane Doe',
|
||||
'N:Doe;Jane;;;',
|
||||
'EMAIL;TYPE=WORK:jane@example.com',
|
||||
'TEL;TYPE=CELL:+1-555-0100',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const [card] = parseVCard(vcf);
|
||||
expect(card).toBeTruthy();
|
||||
const emails = Object.values(card.emails ?? {});
|
||||
expect(emails[0].address).toBe('jane@example.com');
|
||||
expect(emails[0].contexts).toEqual({ work: true });
|
||||
const phones = Object.values(card.phones ?? {});
|
||||
expect(phones[0].number).toBe('+1-555-0100');
|
||||
expect(phones[0].features).toEqual({ cell: true });
|
||||
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
|
||||
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
|
||||
expect(given).toBe('Jane');
|
||||
expect(surname).toBe('Doe');
|
||||
});
|
||||
|
||||
it('parses multiple cards in one file', () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD', 'VERSION:3.0', 'FN:A', 'EMAIL:a@x.com', 'END:VCARD',
|
||||
'BEGIN:VCARD', 'VERSION:3.0', 'FN:B', 'EMAIL:b@x.com', 'END:VCARD',
|
||||
].join('\r\n');
|
||||
expect(parseVCard(vcf)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('unfolds folded lines and decodes escaped values', () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Long Name',
|
||||
'NOTE:line one\\nline two\\, still going',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
const [card] = parseVCard(vcf);
|
||||
const note = Object.values(card.notes ?? {})[0]?.note;
|
||||
expect(note).toBe('line one\nline two, still going');
|
||||
});
|
||||
|
||||
it('parses CATEGORIES into keywords', () => {
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD';
|
||||
const [card] = parseVCard(vcf);
|
||||
expect(card.keywords).toEqual({ Friends: true, VIP: true });
|
||||
});
|
||||
|
||||
it('skips cards with neither a name nor an email', () => {
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nNOTE:orphan\r\nEND:VCARD';
|
||||
expect(parseVCard(vcf)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips mailto:/tel: URI schemes', () => {
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:U\r\nEMAIL:mailto:u@x.com\r\nTEL:tel:+15550111\r\nEND:VCARD';
|
||||
const [card] = parseVCard(vcf);
|
||||
expect(Object.values(card.emails ?? {})[0].address).toBe('u@x.com');
|
||||
expect(Object.values(card.phones ?? {})[0].number).toBe('+15550111');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateVCard', () => {
|
||||
it('round-trips name and email through parse → generate → parse', () => {
|
||||
const original = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nN:Doe;Jane;;;\r\nEMAIL;TYPE=WORK:jane@example.com\r\nEND:VCARD';
|
||||
const [parsed] = parseVCard(original);
|
||||
const generated = generateVCard([parsed]);
|
||||
expect(generated).toContain('FN:Jane Doe');
|
||||
expect(generated).toContain('EMAIL;TYPE=WORK:jane@example.com');
|
||||
|
||||
const [reparsed] = parseVCard(generated);
|
||||
expect(Object.values(reparsed.emails ?? {})[0].address).toBe('jane@example.com');
|
||||
});
|
||||
|
||||
it('contactToVCard emits a single BEGIN/END block', () => {
|
||||
const card: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
name: { full: 'Solo', components: [{ kind: 'given', value: 'Solo' }] },
|
||||
emails: { e0: { address: 'solo@x.com' } },
|
||||
};
|
||||
const out = contactToVCard(card);
|
||||
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
|
||||
expect(out.match(/END:VCARD/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectDuplicates', () => {
|
||||
const existing: ContactCard[] = [
|
||||
{ id: 'x1', addressBookIds: {}, emails: { e0: { address: 'dupe@x.com' } } },
|
||||
];
|
||||
|
||||
it('flags an incoming contact that shares an email (case-insensitive)', () => {
|
||||
const incoming: ContactCard[] = [
|
||||
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'fresh@x.com' } } },
|
||||
{ id: 'i1', addressBookIds: {}, emails: { e0: { address: 'DUPE@x.com' } } },
|
||||
];
|
||||
const dupes = detectDuplicates(existing, incoming);
|
||||
expect(dupes.has(0)).toBe(false);
|
||||
expect(dupes.get(1)).toBe('x1');
|
||||
});
|
||||
|
||||
it('returns an empty map when nothing overlaps', () => {
|
||||
const incoming: ContactCard[] = [
|
||||
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'new@x.com' } } },
|
||||
];
|
||||
expect(detectDuplicates(existing, incoming).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { eachYearOfInterval, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent, ContactCard, AnniversaryDate } from '../api/types';
|
||||
import { getContactDisplayName } from './contact-utils';
|
||||
|
||||
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
|
||||
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
|
||||
|
||||
// A read-only virtual calendar holding generated birthday events.
|
||||
export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
|
||||
return {
|
||||
id: BIRTHDAY_CALENDAR_ID,
|
||||
name,
|
||||
color: BIRTHDAY_CALENDAR_COLOR,
|
||||
sortOrder: 999,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: false,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayWrite: false,
|
||||
mayDelete: false,
|
||||
mayRSVP: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseBirthdayDate(date: AnniversaryDate): { month: number; day: number; year?: number } | null {
|
||||
if (typeof date === 'string') {
|
||||
if (date.startsWith('--')) {
|
||||
const m = date.match(/^--(\d{2})-(\d{2})$/);
|
||||
return m ? { month: parseInt(m[1], 10), day: parseInt(m[2], 10) } : null;
|
||||
}
|
||||
const parsed = parseISO(date);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ('utc' in date && date['@type'] === 'Timestamp') {
|
||||
const parsed = parseISO(date.utc);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// PartialDate
|
||||
if (date.month && date.day) {
|
||||
return { month: date.month, day: date.day, year: date.year || undefined };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate virtual all-day birthday events for the given range from contacts
|
||||
// that carry a `birth` anniversary.
|
||||
export function generateBirthdayEvents(
|
||||
contacts: ContactCard[],
|
||||
rangeStart: string,
|
||||
rangeEnd: string,
|
||||
): CalendarEvent[] {
|
||||
const events: CalendarEvent[] = [];
|
||||
const start = parseISO(rangeStart);
|
||||
const end = parseISO(rangeEnd);
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) return events;
|
||||
|
||||
const years = eachYearOfInterval({ start, end });
|
||||
const endYear = end.getFullYear();
|
||||
if (!years.some((y) => y.getFullYear() === endYear)) {
|
||||
years.push(new Date(endYear, 0, 1));
|
||||
}
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (!contact.anniversaries) continue;
|
||||
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
|
||||
if (anniversary.kind !== 'birth') continue;
|
||||
const parsed = parseBirthdayDate(anniversary.date);
|
||||
if (!parsed) continue;
|
||||
const displayName = getContactDisplayName(contact);
|
||||
if (!displayName) continue;
|
||||
|
||||
for (const yearDate of years) {
|
||||
const year = yearDate.getFullYear();
|
||||
// Feb 29 in a non-leap year: clamp to Feb 28.
|
||||
let occMonth = parsed.month;
|
||||
let occDay = parsed.day;
|
||||
const occurrence = new Date(year, occMonth - 1, occDay);
|
||||
if (occurrence.getMonth() !== occMonth - 1) {
|
||||
occurrence.setDate(0);
|
||||
occMonth = occurrence.getMonth() + 1;
|
||||
occDay = occurrence.getDate();
|
||||
}
|
||||
if (occurrence < start || occurrence > end) continue;
|
||||
|
||||
const mm = String(occMonth).padStart(2, '0');
|
||||
const dd = String(occDay).padStart(2, '0');
|
||||
const age = parsed.year ? year - parsed.year : undefined;
|
||||
const ageText = age && age > 0 ? ` (${age})` : '';
|
||||
|
||||
events.push({
|
||||
id: `birthday-${contact.id}-${key}-${year}`,
|
||||
'@type': 'Event',
|
||||
uid: `birthday-${contact.id}-${key}`,
|
||||
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
|
||||
title: `🎂 ${displayName}${ageText}`,
|
||||
start: `${year}-${mm}-${dd}T00:00:00`,
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'free',
|
||||
color: BIRTHDAY_CALENDAR_COLOR,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Alert, CalendarEvent } from '../api/types';
|
||||
|
||||
// A reminder, expressed as "minutes before the event start". 0 means "at the
|
||||
// time of the event". Negative values (reminder *after* start) are preserved
|
||||
// on round-trip but the picker only offers non-negative presets.
|
||||
export interface Reminder {
|
||||
/** Minutes before start. 0 = at time of event. */
|
||||
minutesBefore: number;
|
||||
}
|
||||
|
||||
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
||||
|
||||
// Parse an ISO 8601 duration offset ("-PT15M", "-P1D", "PT0S") to minutes.
|
||||
// Positive return value = minutes *before* the anchor (JSCalendar offsets for
|
||||
// reminders are negative, e.g. "-PT15M" fires 15 min before start).
|
||||
export function offsetToMinutesBefore(offset: string | undefined): number | null {
|
||||
if (!offset) return null;
|
||||
const m = DURATION_RE.exec(offset);
|
||||
if (!m) return null;
|
||||
const negative = m[1] === '-';
|
||||
const weeks = parseInt(m[2] || '0', 10);
|
||||
const days = parseInt(m[3] || '0', 10);
|
||||
const hours = parseInt(m[4] || '0', 10);
|
||||
const minutes = parseInt(m[5] || '0', 10);
|
||||
const seconds = parseInt(m[6] || '0', 10);
|
||||
const totalMinutes = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + minutes + Math.round(seconds / 60);
|
||||
// A negative offset (fires before start) is a positive "minutesBefore".
|
||||
// `|| 0` collapses -0 to 0 so callers get a clean "at time of event".
|
||||
return (negative ? totalMinutes : -totalMinutes) || 0;
|
||||
}
|
||||
|
||||
export function minutesBeforeToOffset(minutesBefore: number): string {
|
||||
if (minutesBefore === 0) return 'PT0S';
|
||||
const sign = minutesBefore > 0 ? '-' : '';
|
||||
let mins = Math.abs(minutesBefore);
|
||||
const days = Math.floor(mins / (24 * 60));
|
||||
mins -= days * 24 * 60;
|
||||
const hours = Math.floor(mins / 60);
|
||||
mins -= hours * 60;
|
||||
let out = 'P';
|
||||
if (days > 0) out += `${days}D`;
|
||||
if (hours > 0 || mins > 0) {
|
||||
out += 'T';
|
||||
if (hours > 0) out += `${hours}H`;
|
||||
if (mins > 0) out += `${mins}M`;
|
||||
}
|
||||
if (out === 'P') out = 'PT0M';
|
||||
return `${sign}${out}`;
|
||||
}
|
||||
|
||||
// Pull the list of reminders out of an event's `alerts` map. Only display-style
|
||||
// offset alerts relative to start are surfaced in the editor; anything exotic is
|
||||
// dropped from the UI list (but the event keeps it until the user saves).
|
||||
export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
|
||||
if (!alerts) return [];
|
||||
const out: Reminder[] = [];
|
||||
for (const alert of Object.values(alerts)) {
|
||||
const trigger = alert?.trigger;
|
||||
if (!trigger) continue;
|
||||
if (trigger.offset === undefined) continue; // skip AbsoluteTriggers in the picker
|
||||
const minutesBefore = offsetToMinutesBefore(trigger.offset);
|
||||
if (minutesBefore === null) continue;
|
||||
out.push({ minutesBefore });
|
||||
}
|
||||
// De-dupe + sort soonest-to-event last (largest lead time first).
|
||||
const seen = new Set<number>();
|
||||
return out
|
||||
.filter((r) => (seen.has(r.minutesBefore) ? false : (seen.add(r.minutesBefore), true)))
|
||||
.sort((a, b) => b.minutesBefore - a.minutesBefore);
|
||||
}
|
||||
|
||||
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
|
||||
if (reminders.length === 0) return undefined;
|
||||
const alerts: Record<string, Alert> = {};
|
||||
reminders.forEach((r, i) => {
|
||||
alerts[`reminder-${i + 1}`] = {
|
||||
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
|
||||
action: 'display',
|
||||
};
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
export function formatReminder(minutesBefore: number): string {
|
||||
if (minutesBefore === 0) return 'At time of event';
|
||||
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
|
||||
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
|
||||
if (minutesBefore < 60 * 24) {
|
||||
const h = minutesBefore / 60;
|
||||
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
|
||||
}
|
||||
if (minutesBefore < 60 * 24 * 7) {
|
||||
const d = minutesBefore / (60 * 24);
|
||||
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
|
||||
}
|
||||
const w = minutesBefore / (60 * 24 * 7);
|
||||
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
|
||||
}
|
||||
|
||||
// Presets offered by the reminder picker (minutes before start).
|
||||
export const REMINDER_PRESETS: number[] = [
|
||||
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
|
||||
];
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { CalendarEvent, Participant, Email, Attachment } from '../api/types';
|
||||
|
||||
// ─── Address helpers ─────────────────────────────────────
|
||||
|
||||
export function normalizeEmail(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function getParticipantEmail(p: Participant): string | null {
|
||||
const direct = normalizeEmail(p.email);
|
||||
if (direct) return direct;
|
||||
if (p.calendarAddress) {
|
||||
const addr = normalizeEmail(p.calendarAddress);
|
||||
if (addr) return addr;
|
||||
}
|
||||
if (p.sendTo) {
|
||||
for (const addr of Object.values(p.sendTo)) {
|
||||
const n = normalizeEmail(addr);
|
||||
if (n) return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isOrganizerParticipant(p: Participant): boolean {
|
||||
return Boolean(p.roles?.owner || p.roles?.chair);
|
||||
}
|
||||
|
||||
export function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (isOrganizerParticipant(p)) {
|
||||
const e = getParticipantEmail(p);
|
||||
if (e) return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.organizerCalendarAddress) return normalizeEmail(event.organizerCalendarAddress);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getOrganizerName(event: Partial<CalendarEvent>): string | null {
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (isOrganizerParticipant(p)) return p.name || getParticipantEmail(p);
|
||||
}
|
||||
if (event.organizerCalendarAddress) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (p.calendarAddress === event.organizerCalendarAddress) return p.name || getParticipantEmail(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return getOrganizerEmail(event);
|
||||
}
|
||||
|
||||
// Find the participant entry that matches one of the given user emails.
|
||||
export function findParticipantByEmail(
|
||||
event: Partial<CalendarEvent>,
|
||||
emails: string[],
|
||||
): { id: string; participant: Participant } | null {
|
||||
if (!event.participants || emails.length === 0) return null;
|
||||
const wanted = new Set(emails.map((e) => e.toLowerCase()).filter(Boolean));
|
||||
for (const [id, p] of Object.entries(event.participants)) {
|
||||
const e = getParticipantEmail(p);
|
||||
if (e && wanted.has(e)) return { id, participant: p };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP
|
||||
// reply (Stalwart exposes the organizer as organizerCalendarAddress).
|
||||
export function buildReplyTo(event: Partial<CalendarEvent>): Record<string, string> | null {
|
||||
if (event.replyTo) return event.replyTo;
|
||||
if (event.organizerCalendarAddress) {
|
||||
const addr = event.organizerCalendarAddress.startsWith('mailto:')
|
||||
? event.organizerCalendarAddress
|
||||
: `mailto:${event.organizerCalendarAddress}`;
|
||||
return { imip: addr };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── iMIP method detection ───────────────────────────────
|
||||
|
||||
export type InvitationMethod =
|
||||
| 'publish' | 'request' | 'reply' | 'add'
|
||||
| 'cancel' | 'refresh' | 'counter' | 'declinecounter' | 'unknown';
|
||||
|
||||
const KNOWN_METHODS = new Set<InvitationMethod>([
|
||||
'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter',
|
||||
]);
|
||||
|
||||
function normalizeMethod(value?: string | null): InvitationMethod {
|
||||
if (!value) return 'unknown';
|
||||
const v = value.trim().toLowerCase();
|
||||
return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown';
|
||||
}
|
||||
|
||||
// JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST`
|
||||
// arrives as `text/calendar`. The raw ICS METHOD line is the reliable source.
|
||||
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
|
||||
const m = rawText.match(/^METHOD:(\S+)/m);
|
||||
return m ? normalizeMethod(m[1]) : 'unknown';
|
||||
}
|
||||
|
||||
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
|
||||
if (!event.participants) return false;
|
||||
return Object.values(event.participants).some(
|
||||
(p) =>
|
||||
p.roles?.attendee &&
|
||||
!isOrganizerParticipant(p) &&
|
||||
(p.participationStatus !== 'needs-action' || !!p.participationComment),
|
||||
);
|
||||
}
|
||||
|
||||
export function inferInvitationMethod(event: Partial<CalendarEvent>): InvitationMethod {
|
||||
if (event.status === 'cancelled') return 'cancel';
|
||||
if (looksLikeReply(event)) return 'reply';
|
||||
if (event.participants && Object.keys(event.participants).length > 0) {
|
||||
if (Object.values(event.participants).some(isOrganizerParticipant) || event.organizerCalendarAddress) {
|
||||
return 'request';
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ─── Calendar attachment detection ───────────────────────
|
||||
|
||||
const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']);
|
||||
|
||||
function isCalendarType(type?: string | null): boolean {
|
||||
if (!type) return false;
|
||||
const base = type.split(';')[0].trim().toLowerCase();
|
||||
return CAL_MIME.has(base);
|
||||
}
|
||||
|
||||
export function findCalendarAttachment(email: Pick<Email, 'attachments'>): Attachment | null {
|
||||
if (!email.attachments) return null;
|
||||
for (const att of email.attachments) {
|
||||
const name = att.name?.toLowerCase() || '';
|
||||
if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) {
|
||||
return att;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CAPABILITIES } from '../api/types';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
// When the session is null (cold start, offline restore) we assume features
|
||||
// are available so they don't flicker off mid-restore. Once the live session
|
||||
// arrives, the real capability set takes over.
|
||||
function sessionHasCapability(capability: string): boolean {
|
||||
const session = useAuthStore.getState().session;
|
||||
if (!session) return true;
|
||||
return capability in session.capabilities;
|
||||
}
|
||||
|
||||
export function hasCalendarCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.CALENDARS);
|
||||
}
|
||||
|
||||
export function hasContactsCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.CONTACTS);
|
||||
}
|
||||
|
||||
export function hasFilesCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.FILES);
|
||||
}
|
||||
|
||||
export function useHasCalendar(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
|
||||
}
|
||||
|
||||
export function useHasContacts(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
|
||||
}
|
||||
|
||||
export function useHasFiles(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
|
||||
}
|
||||
@@ -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
@@ -1,15 +1,13 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { getDownloadUrl } from '../api/blob';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
|
||||
function authHeader(): string {
|
||||
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
|
||||
if (!h) throw new Error('Not connected');
|
||||
return h;
|
||||
return jmapClient.authHeader;
|
||||
}
|
||||
|
||||
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
||||
@@ -19,13 +17,43 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
|
||||
return `attachment.${fallbackExt}`;
|
||||
}
|
||||
|
||||
// On Android, sharing a `file://` URI from the cache directory throws a
|
||||
// FileUriExposedException on modern OS versions; the system requires a
|
||||
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
|
||||
// via `file.contentUri`, which is the only field iOS doesn't expose.
|
||||
function shareableUri(file: { uri: string; contentUri?: string }): string {
|
||||
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
|
||||
return file.uri;
|
||||
// expo-sharing only accepts `file://` URLs and rejects `content://` with
|
||||
// "Only local file URLs are supported". On Android it then wraps the file
|
||||
// itself with its bundled SharingFileProvider before launching the share
|
||||
// intent, so we must NOT pre-translate to `file.contentUri`.
|
||||
|
||||
// Routes the download via the client-cert-aware native module when the user
|
||||
// has picked a cert, and via expo-file-system's native streaming downloader
|
||||
// otherwise. The streaming path scales to large attachments without buffering
|
||||
// in JS, so we keep using it as the default.
|
||||
async function downloadInto(
|
||||
url: string,
|
||||
dest: File,
|
||||
parent: Directory,
|
||||
): Promise<File> {
|
||||
const alias = await getClientCertAlias();
|
||||
if (!alias) {
|
||||
// The static returns a separately-typed `FileSystemFile`; we already
|
||||
// have a fully-typed `File` referencing the same uri, so we ignore the
|
||||
// return value and re-use our `dest` reference for downstream code.
|
||||
await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
return dest;
|
||||
}
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: authHeader() },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
if (!parent.exists) parent.create({ intermediates: true, idempotent: true });
|
||||
if (dest.exists) dest.delete();
|
||||
const buffer = await response.arrayBuffer();
|
||||
dest.create();
|
||||
dest.write(new Uint8Array(buffer));
|
||||
return dest;
|
||||
}
|
||||
|
||||
export async function shareAttachment(
|
||||
@@ -37,14 +65,35 @@ export async function shareAttachment(
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(shareableUri(downloaded), {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
}
|
||||
|
||||
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
|
||||
// so on both platforms we land the file in the document directory and hand it
|
||||
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
|
||||
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
|
||||
// uses cache + share, which on most viewers opens directly without prompting.
|
||||
export async function downloadAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.document, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await downloadInto(url, dest, Paths.document);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
@@ -52,7 +101,7 @@ export async function shareAttachment(
|
||||
|
||||
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
|
||||
const r = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||
return r.text();
|
||||
}
|
||||
@@ -65,14 +114,11 @@ function safeFilename(subject: string | undefined): string {
|
||||
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||
const dest = new File(Paths.cache, safeFilename(subject));
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(shareableUri(downloaded), {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType: RFC822,
|
||||
dialogTitle: 'Share email',
|
||||
UTI: 'public.email-message',
|
||||
|
||||
Binary file not shown.
@@ -3,12 +3,18 @@ import { File, Paths } from 'expo-file-system';
|
||||
import * as FileSystemLegacy from 'expo-file-system/legacy';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import type { ReleaseAsset } from '../api/updates';
|
||||
import { Sha256 } from './sha256';
|
||||
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
|
||||
|
||||
// Bytes per file-read chunk while computing the post-download SHA-256. Larger
|
||||
// chunks are faster but balloon the JS string buffer (base64 reads on this
|
||||
// platform). 256 KiB keeps memory bounded while still amortising the bridge.
|
||||
const HASH_CHUNK_BYTES = 256 * 1024;
|
||||
|
||||
export interface InstallProgress {
|
||||
phase: 'downloading' | 'installing';
|
||||
phase: 'downloading' | 'verifying' | 'installing';
|
||||
/** 0-1 during download; undefined for installing phase */
|
||||
progress?: number;
|
||||
/** Bytes received during current download */
|
||||
@@ -19,10 +25,51 @@ export interface InstallProgress {
|
||||
|
||||
export type InstallProgressListener = (p: InstallProgress) => void;
|
||||
|
||||
export interface InstallParams {
|
||||
asset: ReleaseAsset;
|
||||
/** Optional lower-case hex SHA-256. When present, mismatched downloads abort. */
|
||||
expectedSha256?: string | null;
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise<string> {
|
||||
const hasher = new Sha256();
|
||||
let offset = 0;
|
||||
while (offset < totalBytes || totalBytes === 0) {
|
||||
const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES);
|
||||
const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, {
|
||||
encoding: FileSystemLegacy.EncodingType.Base64,
|
||||
position: offset,
|
||||
length,
|
||||
});
|
||||
if (!chunkB64) break;
|
||||
const bytes = base64ToBytes(chunkB64);
|
||||
if (bytes.length === 0) break;
|
||||
hasher.update(bytes);
|
||||
offset += bytes.length;
|
||||
if (totalBytes > 0) {
|
||||
onProgress?.({ phase: 'verifying', progress: offset / totalBytes });
|
||||
}
|
||||
if (bytes.length < length) break;
|
||||
}
|
||||
return hasher.digestHex();
|
||||
}
|
||||
|
||||
export async function downloadAndInstallApk(
|
||||
asset: ReleaseAsset,
|
||||
params: InstallParams | ReleaseAsset,
|
||||
onProgress?: InstallProgressListener,
|
||||
): Promise<void> {
|
||||
// Backwards-compat: callers used to pass the asset directly. Promote to the
|
||||
// params shape so the verification path is opt-in by passing a checksum.
|
||||
const { asset, expectedSha256 }: InstallParams =
|
||||
'asset' in params ? params : { asset: params, expectedSha256: null };
|
||||
|
||||
if (Platform.OS !== 'android') {
|
||||
throw new Error('APK install is only supported on Android');
|
||||
}
|
||||
@@ -53,6 +100,33 @@ export async function downloadAndInstallApk(
|
||||
throw new Error('APK download failed');
|
||||
}
|
||||
|
||||
// Size sanity check. GitHub's `assets[].size` is signed by their TLS cert,
|
||||
// so a byte-count mismatch is a strong signal the file was truncated /
|
||||
// tampered with in transport. Cheap to compute, fail-fast.
|
||||
const info = await FileSystemLegacy.getInfoAsync(result.uri);
|
||||
const actualSize = (info as { size?: number }).size ?? 0;
|
||||
if (asset.size > 0 && actualSize !== asset.size) {
|
||||
await tryDelete(result.uri);
|
||||
throw new Error(
|
||||
`APK size mismatch: expected ${asset.size} bytes, got ${actualSize}`,
|
||||
);
|
||||
}
|
||||
|
||||
// SHA-256 verification when the release publishes one. Without it, we still
|
||||
// get Android's package-signer check at install time — which rejects an APK
|
||||
// signed with a different key — but that fails late (after the user sees a
|
||||
// confusing system dialog). Bailing here surfaces the problem cleanly.
|
||||
if (expectedSha256) {
|
||||
onProgress?.({ phase: 'verifying', progress: 0 });
|
||||
const actual = await hashFile(result.uri, actualSize || asset.size, onProgress);
|
||||
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
|
||||
await tryDelete(result.uri);
|
||||
throw new Error(
|
||||
`APK checksum mismatch: expected ${expectedSha256}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'installing' });
|
||||
|
||||
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
|
||||
@@ -64,6 +138,14 @@ export async function downloadAndInstallApk(
|
||||
});
|
||||
}
|
||||
|
||||
async function tryDelete(uri: string): Promise<void> {
|
||||
try {
|
||||
await FileSystemLegacy.deleteAsync(uri, { idempotent: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system "Install unknown apps" settings page for this app, so the
|
||||
* user can grant permission to install APKs from the package installer.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { secureFetch } from './client-cert';
|
||||
|
||||
// Webmail-mediated login. The app opens the webmail's normal login page with
|
||||
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
|
||||
// uses its existing password and OAuth flows, and once the user is signed
|
||||
// in, redirects back to the app's custom scheme with credentials packed into
|
||||
// the URL fragment. Fragments aren't sent to the server, so password and
|
||||
// token material don't appear in HTTP access logs along the way.
|
||||
|
||||
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
|
||||
|
||||
export interface OAuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // epoch ms
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export type HandoffResult =
|
||||
| {
|
||||
flow: 'password';
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
| {
|
||||
flow: 'oauth';
|
||||
serverUrl: string;
|
||||
tokens: OAuthTokens;
|
||||
};
|
||||
|
||||
export class HandoffError extends Error {}
|
||||
export class HandoffCancelledError extends HandoffError {
|
||||
constructor() {
|
||||
super('Sign-in cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function randomState(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
let s = '';
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildHandoffUrl(webmailUrl: string, state: string): string {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
const params = new URLSearchParams({
|
||||
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
|
||||
mobile_state: state,
|
||||
});
|
||||
return `${base}/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseFragment(url: string): URLSearchParams {
|
||||
const hashIdx = url.indexOf('#');
|
||||
if (hashIdx === -1) return new URLSearchParams();
|
||||
return new URLSearchParams(url.slice(hashIdx + 1));
|
||||
}
|
||||
|
||||
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
|
||||
const state = randomState();
|
||||
const handoffUrl = buildHandoffUrl(webmailUrl, state);
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
|
||||
|
||||
if (result.type === 'cancel' || result.type === 'dismiss') {
|
||||
throw new HandoffCancelledError();
|
||||
}
|
||||
if (result.type !== 'success' || !result.url) {
|
||||
throw new HandoffError(`Sign-in failed: ${result.type}`);
|
||||
}
|
||||
|
||||
const params = parseFragment(result.url);
|
||||
const err = params.get('error');
|
||||
if (err) throw new HandoffError(err);
|
||||
|
||||
// CSRF guard: the state we generated must round-trip through the webmail
|
||||
// unchanged. A mismatch means the redirect didn't come from the flow we
|
||||
// started, and the rest of the fragment shouldn't be trusted.
|
||||
if (params.get('state') !== state) {
|
||||
throw new HandoffError('State mismatch');
|
||||
}
|
||||
|
||||
const flow = params.get('flow');
|
||||
const serverUrl = params.get('server_url');
|
||||
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
|
||||
|
||||
if (flow === 'password') {
|
||||
const username = params.get('username');
|
||||
const password = params.get('password');
|
||||
if (!username || !password) {
|
||||
throw new HandoffError('Sign-in response missing credentials');
|
||||
}
|
||||
return { flow: 'password', serverUrl, username, password };
|
||||
}
|
||||
|
||||
if (flow === 'oauth') {
|
||||
const accessToken = params.get('access_token');
|
||||
const tokenEndpoint = params.get('token_endpoint');
|
||||
const clientId = params.get('client_id');
|
||||
if (!accessToken || !tokenEndpoint || !clientId) {
|
||||
throw new HandoffError('Sign-in response missing OAuth tokens');
|
||||
}
|
||||
const refreshToken = params.get('refresh_token') ?? undefined;
|
||||
const expiresIn = params.get('expires_in');
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
|
||||
}
|
||||
|
||||
// OAuth refresh — exchanges the refresh token at the original token endpoint
|
||||
// for a new access token. Returns the updated bundle so the caller can
|
||||
// persist it.
|
||||
export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAuthTokens> {
|
||||
if (!tokens.refreshToken) {
|
||||
throw new HandoffError('No refresh token available');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HandoffError(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
@@ -0,0 +1,148 @@
|
||||
// Background sync that fills the offline mail cache. Runs the discovery
|
||||
// query (Email/query filtered by `after`), then fetches full bodies in
|
||||
// batches, reporting progress to the offline-cache-store so the
|
||||
// OfflineCacheBanner and the Settings screen can show live updates.
|
||||
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { queryEmailsByFilter, getFullEmails } from '../api/email';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
// Approximate the on-disk size of a serialised Email so we can show "Y MB
|
||||
// downloaded" without measuring AsyncStorage usage. Body values dominate; the
|
||||
// envelope is small. Using JSON.stringify().length is correct in code-units
|
||||
// not bytes, but it's close enough for a UI-facing estimate.
|
||||
function approxSize(email: Email): number {
|
||||
try {
|
||||
return JSON.stringify(email).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap so a misconfigured "30 days" against a noisy account doesn't try
|
||||
// to enumerate 100k messages.
|
||||
const DISCOVERY_LIMIT = 5000;
|
||||
// Each Email/get request is bounded by the server's maxObjectsInGet. We
|
||||
// further chunk to keep individual responses small (a 50-message response
|
||||
// with full bodies + body values is already several MB).
|
||||
const FETCH_CHUNK_FALLBACK = 25;
|
||||
|
||||
export interface RunOptions {
|
||||
days: number;
|
||||
}
|
||||
|
||||
export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
const cache = useOfflineCacheStore.getState();
|
||||
// If a sync is in flight, request its abort and wait for the next caller
|
||||
// to start fresh. We don't queue — the most recent intent wins.
|
||||
if (cache.sync.phase === 'scanning' || cache.sync.phase === 'fetching') {
|
||||
cache.requestAbort();
|
||||
return;
|
||||
}
|
||||
if (!cache.hydrated) await cache.hydrate();
|
||||
cache.resetSync();
|
||||
|
||||
const startedAt = Date.now();
|
||||
cache.setSyncState({ phase: 'scanning', startedAt });
|
||||
|
||||
const since = new Date(startedAt - opts.days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT);
|
||||
} catch (err) {
|
||||
cache.setSyncState({
|
||||
phase: 'error',
|
||||
message: err instanceof Error ? err.message : 'Discovery query failed',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop entries from the cache that fell out of the lookback window — the
|
||||
// user expects the cache to track "the last X days", not grow forever.
|
||||
const keepSet = new Set(ids);
|
||||
const stale = Object.keys(cache.index.entries).filter((id) => !keepSet.has(id));
|
||||
if (stale.length > 0) {
|
||||
await cache.remove(stale);
|
||||
}
|
||||
|
||||
const total = ids.length;
|
||||
cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 });
|
||||
|
||||
// Skip already-cached ids — bodies on disk are immutable per messageId.
|
||||
const toFetch = ids.filter((id) => !cache.has(id));
|
||||
const skipped = total - toFetch.length;
|
||||
cache.setSyncState({ completed: skipped });
|
||||
|
||||
if (toFetch.length === 0) {
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = Math.min(
|
||||
FETCH_CHUNK_FALLBACK,
|
||||
Math.max(1, jmapClient.getMaxObjectsInGet()),
|
||||
);
|
||||
|
||||
let fetched = 0;
|
||||
let bytes = 0;
|
||||
let completed = skipped;
|
||||
|
||||
for (let i = 0; i < toFetch.length; i += chunkSize) {
|
||||
if (useOfflineCacheStore.getState().consumeAbort()) {
|
||||
cache.setSyncState({
|
||||
phase: 'cancelled',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = toFetch.slice(i, i + chunkSize);
|
||||
let emails: Email[];
|
||||
try {
|
||||
emails = await getFullEmails(chunk);
|
||||
} catch (err) {
|
||||
cache.setSyncState({
|
||||
phase: 'error',
|
||||
message: err instanceof Error ? err.message : 'Fetch failed',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const email of emails) {
|
||||
const size = approxSize(email);
|
||||
await cache.put(email, size);
|
||||
fetched += 1;
|
||||
bytes += size;
|
||||
completed += 1;
|
||||
}
|
||||
|
||||
// Some chunked ids may have been deleted server-side between query and
|
||||
// fetch — they don't come back. Account for that in the completed count
|
||||
// so the progress bar still finishes at 100%.
|
||||
completed += chunk.length - emails.length;
|
||||
|
||||
cache.setSyncState({ completed, fetched, bytes });
|
||||
}
|
||||
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
completed: total,
|
||||
fetched,
|
||||
bytes,
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
+152
-47
@@ -8,9 +8,37 @@ import {
|
||||
getFaviconDomain,
|
||||
getFaviconUrl,
|
||||
} from './avatar-utils';
|
||||
import {
|
||||
deviceClientIdKey,
|
||||
lastNotifiedKey,
|
||||
migrateLegacyPushKeys,
|
||||
readPushAccountIds,
|
||||
} from './push-notifications';
|
||||
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
||||
// import the Zustand store (would pull in React) so we read AsyncStorage
|
||||
// directly. Keep this in sync if the store key ever changes.
|
||||
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
|
||||
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
|
||||
// the user's active account at the end of the headless task so we can leave
|
||||
// the jmapClient singleton in a state consistent with what the UI expects
|
||||
// when the app resumes.
|
||||
const ACCOUNT_REGISTRY_KEY = 'account-registry';
|
||||
|
||||
interface PushPersistedSettings {
|
||||
emailNotificationsEnabled?: boolean;
|
||||
}
|
||||
|
||||
async function emailNotificationsAllowed(): Promise<boolean> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (!raw) return true; // first launch: default to on
|
||||
const parsed = JSON.parse(raw) as PushPersistedSettings;
|
||||
return parsed.emailNotificationsEnabled !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface ShowNotificationOptions {
|
||||
notificationId: string;
|
||||
@@ -22,74 +50,151 @@ interface ShowNotificationOptions {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface BulwarkFcmNative {
|
||||
showNotification(opts: ShowNotificationOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
|
||||
// received the push on (the URL slot from createPushSubscription). The exact
|
||||
// key the relay uses isn't part of any client-controlled contract, so rather
|
||||
// than guessing a field name we scan all string values in the data payload
|
||||
// and look for one that matches a deviceClientId we've registered locally.
|
||||
// Returns null if nothing matches (e.g. legacy relay payload format) so the
|
||||
// caller falls back to iterating every known account.
|
||||
async function identifyAccountFromFcmData(
|
||||
data: unknown,
|
||||
accountIds: string[],
|
||||
): Promise<string | null> {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const reverseMap = new Map<string, string>();
|
||||
for (const accountId of accountIds) {
|
||||
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||
if (dcid) reverseMap.set(dcid, accountId);
|
||||
}
|
||||
if (reverseMap.size === 0) return null;
|
||||
|
||||
for (const value of Object.values(data as Record<string, unknown>)) {
|
||||
if (typeof value === 'string') {
|
||||
const match = reverseMap.get(value);
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the persisted active account directly from AsyncStorage (Zustand's
|
||||
// persist middleware stores the JSON-serialised state under its `name`).
|
||||
async function readActiveAccountId(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
|
||||
return parsed.state?.activeAccountId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
|
||||
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
export async function pushBackgroundTask(data: unknown): Promise<void> {
|
||||
let activeAccountId: string | null = null;
|
||||
try {
|
||||
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
|
||||
if (!accountId) return;
|
||||
if (!(await emailNotificationsAllowed())) return;
|
||||
|
||||
const loaded = await jmapClient.loadAccount(accountId);
|
||||
if (!loaded) return;
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
||||
if (!inbox) return;
|
||||
const accountIds = await readPushAccountIds();
|
||||
if (accountIds.length === 0) return;
|
||||
|
||||
const { ids } = await queryEmails(inbox.id, {
|
||||
filter: { notKeyword: '$seen' },
|
||||
limit: 1,
|
||||
});
|
||||
if (ids.length === 0) return;
|
||||
activeAccountId = await readActiveAccountId();
|
||||
|
||||
const emailId = ids[0];
|
||||
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
if (previouslyNotified === emailId) return;
|
||||
const matched = await identifyAccountFromFcmData(data, accountIds);
|
||||
const accountsToCheck = matched ? [matched] : accountIds;
|
||||
|
||||
const [email] = await getEmails([emailId]);
|
||||
if (!email) return;
|
||||
|
||||
const from = email.from?.[0];
|
||||
const name = from?.name ?? '';
|
||||
const address = from?.email ?? '';
|
||||
const title = name || address || 'New mail';
|
||||
const body = email.subject || '(no subject)';
|
||||
const initials = getEmailInitials(name, address);
|
||||
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
|
||||
const faviconDomain = getFaviconDomain(address);
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||
if (!native?.showNotification) return;
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${emailId}`,
|
||||
title,
|
||||
body,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
|
||||
for (const accountId of accountsToCheck) {
|
||||
try {
|
||||
await processAccountForPush(accountId);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[push] background check failed for account',
|
||||
accountId,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[push] background task failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
} finally {
|
||||
// Leave the singleton bound to the user's active account so the UI sees
|
||||
// a consistent jmapClient state when the app resumes. Without this, the
|
||||
// last account processed above would remain loaded and the UI's cached
|
||||
// email-store data would not match what the next JMAP request returns.
|
||||
if (activeAccountId) {
|
||||
await jmapClient.loadAccount(activeAccountId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processAccountForPush(accountId: string): Promise<void> {
|
||||
const loaded = await jmapClient.loadAccount(accountId);
|
||||
if (!loaded) return;
|
||||
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
||||
if (!inbox) return;
|
||||
|
||||
const { ids } = await queryEmails(inbox.id, {
|
||||
filter: { notKeyword: '$seen' },
|
||||
limit: 1,
|
||||
});
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const emailId = ids[0];
|
||||
const lastKey = lastNotifiedKey(accountId);
|
||||
const previouslyNotified = await AsyncStorage.getItem(lastKey);
|
||||
if (previouslyNotified === emailId) return;
|
||||
|
||||
const [email] = await getEmails([emailId]);
|
||||
if (!email) return;
|
||||
|
||||
const from = email.from?.[0];
|
||||
const name = from?.name ?? '';
|
||||
const address = from?.email ?? '';
|
||||
const title = name || address || 'New mail';
|
||||
const body = email.subject || '(no subject)';
|
||||
const initials = getEmailInitials(name, address);
|
||||
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
|
||||
const faviconDomain = getFaviconDomain(address);
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||
if (!native?.showNotification) return;
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${emailId}`,
|
||||
title,
|
||||
body,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
accountId,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(lastKey, emailId);
|
||||
}
|
||||
|
||||
function hslToHex(hsl: string): string {
|
||||
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
|
||||
if (!match) return '#2563eb';
|
||||
|
||||
+205
-43
@@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client';
|
||||
import { generateAccountId } from './account-utils';
|
||||
|
||||
// Persist identifiers across launches so we reuse the same JMAP subscription
|
||||
// after app restarts. deviceClientId also routes to the relay slot.
|
||||
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
|
||||
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
|
||||
// after app restarts. Each account gets its own deviceClientId so the hosted
|
||||
// relay can distinguish per-account pushes via the URL slot it forwards.
|
||||
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
|
||||
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
|
||||
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
|
||||
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
|
||||
|
||||
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
||||
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
||||
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
|
||||
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
|
||||
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
|
||||
export function deviceClientIdKey(accountId: string): string {
|
||||
return DEVICE_CLIENT_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function subscriptionIdKey(accountId: string): string {
|
||||
return SUBSCRIPTION_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export function lastNotifiedKey(accountId: string): string {
|
||||
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export async function readPushAccountIds(): Promise<string[]> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writePushAccountIds(ids: string[]): Promise<void> {
|
||||
const deduped = Array.from(new Set(ids));
|
||||
if (deduped.length === 0) {
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
} else {
|
||||
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot migration from the pre-multi-account schema. If the legacy
|
||||
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
|
||||
// setup: reuse the legacy deviceClientId and JMAP subscription id under
|
||||
// the new per-account keys so the user doesn't lose push on upgrade.
|
||||
export async function migrateLegacyPushKeys(): Promise<void> {
|
||||
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
|
||||
if (!legacyAccountId) return;
|
||||
|
||||
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
|
||||
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
|
||||
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
|
||||
if (legacyDcid) {
|
||||
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
|
||||
}
|
||||
if (legacySubId) {
|
||||
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
|
||||
}
|
||||
if (legacyLastId) {
|
||||
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
|
||||
}
|
||||
|
||||
const ids = await readPushAccountIds();
|
||||
if (!ids.includes(legacyAccountId)) {
|
||||
await writePushAccountIds([...ids, legacyAccountId]);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiRemove([
|
||||
LEGACY_PUSH_ACCOUNT_ID_KEY,
|
||||
LEGACY_DEVICE_CLIENT_ID_KEY,
|
||||
LEGACY_SUBSCRIPTION_ID_KEY,
|
||||
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
|
||||
]);
|
||||
}
|
||||
|
||||
// Hosted relay so users don't need to run their own Firebase project. The
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
|
||||
@@ -72,11 +148,12 @@ function randomClientId(): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function getOrCreateDeviceClientId(): Promise<string> {
|
||||
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
|
||||
const key = deviceClientIdKey(accountId);
|
||||
const existing = await AsyncStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const next = randomClientId();
|
||||
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
|
||||
await AsyncStorage.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -184,20 +261,24 @@ export async function setupPushNotifications(
|
||||
const fcmToken = await native.getToken();
|
||||
if (!fcmToken) throw new Error('FCM token unavailable');
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId();
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Persist the account id so the background push task can reload credentials
|
||||
// from SecureStore without the main app being running.
|
||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||
// need its username/serverUrl up-front so we can key per-account state.
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (username && serverUrl) {
|
||||
await AsyncStorage.setItem(
|
||||
PUSH_ACCOUNT_ID_KEY,
|
||||
generateAccountId(username, serverUrl),
|
||||
);
|
||||
if (!username || !serverUrl) {
|
||||
throw new Error('No account loaded - cannot set up push');
|
||||
}
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId(accountId);
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Register this account's device-client-id with the relay. Multiple
|
||||
// accounts on the same device end up as separate registrations sharing
|
||||
// one fcmToken - the relay forwards each push individually so the headless
|
||||
// task can identify the source account via the FCM data payload.
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
@@ -208,26 +289,31 @@ export async function setupPushNotifications(
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const storedServerId = await AsyncStorage.getItem(subKey);
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(match);
|
||||
if (refreshed) {
|
||||
await addPushAccountId(accountId);
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
// Server rejected the refresh (likely the subscription was already
|
||||
// deleted server-side) - drop the stale id and recreate below.
|
||||
await destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this device.
|
||||
// These pile up when a previous enable attempt failed mid-flow (verify
|
||||
// race, network blip, app killed). Stalwart per-account rate-limits
|
||||
// PushVerification posts, so leaving stragglers around blocks the new
|
||||
// one's verification - the symptom is a confusing "code does not match".
|
||||
// Reap any leftover Stalwart subscriptions still bound to this account's
|
||||
// deviceClientId. These pile up when a previous enable attempt failed
|
||||
// mid-flow (verify race, network blip, app killed). Stalwart per-account
|
||||
// rate-limits PushVerification posts, so leaving stragglers around blocks
|
||||
// the new one's verification - the symptom is a confusing "code does not
|
||||
// match". Only reap matches for THIS account's deviceClientId; other
|
||||
// accounts' subscriptions on this server (rare but possible if the same
|
||||
// JMAP server backs multiple accounts) must be left alone.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
@@ -245,11 +331,19 @@ export async function setupPushNotifications(
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
|
||||
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
|
||||
await AsyncStorage.setItem(subKey, serverAssignedId);
|
||||
await addPushAccountId(accountId);
|
||||
|
||||
return { subscriptionId: serverAssignedId, verified: true };
|
||||
}
|
||||
|
||||
async function addPushAccountId(accountId: string): Promise<void> {
|
||||
const ids = await readPushAccountIds();
|
||||
if (!ids.includes(accountId)) {
|
||||
await writePushAccountIds([...ids, accountId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Push the subscription's expires forward when it's getting close to the
|
||||
// server's ceiling. Returns false if the server rejects the update, which the
|
||||
// caller treats as "recreate".
|
||||
@@ -273,28 +367,92 @@ async function refreshSubscriptionExpires(
|
||||
}
|
||||
}
|
||||
|
||||
async function deregisterFromRelay(
|
||||
relayBaseUrl: string,
|
||||
deviceClientId: string,
|
||||
): Promise<void> {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
* FCM token. Never throws - callers treat teardown as best effort.
|
||||
* Tear down push for a single account. Destroys that account's JMAP
|
||||
* subscription (assumes the jmapClient is currently authenticated to that
|
||||
* account; the active-account logout flow guarantees this) and tells the
|
||||
* relay to drop its mapping. Other accounts' push setups are untouched.
|
||||
*
|
||||
* If no accounts have push left after removal, also deletes the FCM token
|
||||
* so the device stops receiving FCM messages entirely. Never throws -
|
||||
* callers treat teardown as best effort.
|
||||
*/
|
||||
export async function teardownPushNotificationsForAccount(
|
||||
accountId: string,
|
||||
): Promise<void> {
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
||||
const dcidKey = deviceClientIdKey(accountId);
|
||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
|
||||
if (storedSubId) {
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
||||
|
||||
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
|
||||
await writePushAccountIds(remaining);
|
||||
|
||||
// If this was the last account with push, kill the FCM token so the device
|
||||
// truly goes silent. Otherwise the token stays alive so the remaining
|
||||
// accounts keep receiving pushes.
|
||||
if (remaining.length === 0) {
|
||||
const native = getNative();
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push for ALL accounts on this device. Used by the logout-all
|
||||
* flow; best-effort because we typically aren't authenticated to every
|
||||
* account's JMAP server at the moment we need to call destroy on it. The
|
||||
* FCM token is always deleted so no push gets through regardless.
|
||||
*/
|
||||
export async function teardownPushNotifications(): Promise<void> {
|
||||
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
if (storedId) {
|
||||
await destroyPushSubscription(storedId).catch(() => undefined);
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
const accountIds = await readPushAccountIds();
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const dcidKey = deviceClientIdKey(accountId);
|
||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
||||
|
||||
if (storedSubId) {
|
||||
// Will only succeed if the jmapClient happens to be authenticated to
|
||||
// this account right now. We don't switch the client to attempt each
|
||||
// one; the subscription will expire server-side instead (90-day TTL).
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||
}
|
||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
||||
}
|
||||
if (relayBaseUrl && deviceClientId) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
|
||||
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
|
||||
const native = getNative();
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
@@ -327,6 +485,10 @@ export interface NotificationTapPayload {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
// Identifies which logged-in account the notification was generated for.
|
||||
// Optional for back-compat: older notifications already on the system tray
|
||||
// won't carry this and will fall back to the active account on tap.
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Minimal SHA-256 implementation. Used to verify APK update downloads against
|
||||
// the checksum published in the GitHub release body — the JS runtime has no
|
||||
// built-in `crypto.subtle.digest` and we don't currently depend on
|
||||
// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per
|
||||
// MB on a modern phone, which is fine for the rare update flow.
|
||||
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
|
||||
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
||||
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
||||
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
||||
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
|
||||
function rotr(x: number, n: number): number {
|
||||
return (x >>> n) | (x << (32 - n));
|
||||
}
|
||||
|
||||
export class Sha256 {
|
||||
private h = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
private buffer = new Uint8Array(64);
|
||||
private bufferLen = 0;
|
||||
private byteCount = 0;
|
||||
|
||||
update(bytes: Uint8Array): void {
|
||||
let i = 0;
|
||||
this.byteCount += bytes.length;
|
||||
while (i < bytes.length) {
|
||||
const room = 64 - this.bufferLen;
|
||||
const chunk = Math.min(room, bytes.length - i);
|
||||
this.buffer.set(bytes.subarray(i, i + chunk), this.bufferLen);
|
||||
this.bufferLen += chunk;
|
||||
i += chunk;
|
||||
if (this.bufferLen === 64) {
|
||||
this.process(this.buffer);
|
||||
this.bufferLen = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
digestHex(): string {
|
||||
// Final block: append 0x80, pad with zeros, then 8-byte big-endian length.
|
||||
const totalBits = this.byteCount * 8;
|
||||
this.buffer[this.bufferLen++] = 0x80;
|
||||
if (this.bufferLen > 56) {
|
||||
while (this.bufferLen < 64) this.buffer[this.bufferLen++] = 0;
|
||||
this.process(this.buffer);
|
||||
this.bufferLen = 0;
|
||||
}
|
||||
while (this.bufferLen < 56) this.buffer[this.bufferLen++] = 0;
|
||||
// 64-bit length, big-endian. We can't represent >2^53 - JS bitops are
|
||||
// 32-bit, so split into hi/lo words.
|
||||
const hi = Math.floor(totalBits / 0x100000000);
|
||||
const lo = totalBits >>> 0;
|
||||
this.buffer[56] = (hi >>> 24) & 0xff;
|
||||
this.buffer[57] = (hi >>> 16) & 0xff;
|
||||
this.buffer[58] = (hi >>> 8) & 0xff;
|
||||
this.buffer[59] = hi & 0xff;
|
||||
this.buffer[60] = (lo >>> 24) & 0xff;
|
||||
this.buffer[61] = (lo >>> 16) & 0xff;
|
||||
this.buffer[62] = (lo >>> 8) & 0xff;
|
||||
this.buffer[63] = lo & 0xff;
|
||||
this.process(this.buffer);
|
||||
|
||||
let out = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out += this.h[i].toString(16).padStart(8, '0');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private process(block: Uint8Array): void {
|
||||
const w = new Uint32Array(64);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
w[i] =
|
||||
(block[i * 4] << 24) |
|
||||
(block[i * 4 + 1] << 16) |
|
||||
(block[i * 4 + 2] << 8) |
|
||||
block[i * 4 + 3];
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
||||
}
|
||||
|
||||
let a = this.h[0], b = this.h[1], c = this.h[2], d = this.h[3];
|
||||
let e = this.h[4], f = this.h[5], g = this.h[6], hh = this.h[7];
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const temp1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
||||
const mj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (S0 + mj) | 0;
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temp1) | 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temp1 + temp2) | 0;
|
||||
}
|
||||
|
||||
this.h[0] = (this.h[0] + a) | 0;
|
||||
this.h[1] = (this.h[1] + b) | 0;
|
||||
this.h[2] = (this.h[2] + c) | 0;
|
||||
this.h[3] = (this.h[3] + d) | 0;
|
||||
this.h[4] = (this.h[4] + e) | 0;
|
||||
this.h[5] = (this.h[5] + f) | 0;
|
||||
this.h[6] = (this.h[6] + g) | 0;
|
||||
this.h[7] = (this.h[7] + hh) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function sha256Hex(bytes: Uint8Array): string {
|
||||
const h = new Sha256();
|
||||
h.update(bytes);
|
||||
return h.digestHex();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
|
||||
// available (Hermes/React Native 0.81 exposes it), falling back to a
|
||||
// Math.random implementation. These IDs are only used as temporary client-side
|
||||
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
|
||||
// the Math.random fallback's weaker entropy is acceptable.
|
||||
export function generateUUID(): string {
|
||||
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
||||
if (c?.randomUUID) {
|
||||
try {
|
||||
return c.randomUUID();
|
||||
} catch {
|
||||
// fall through to the manual implementation
|
||||
}
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
+1203
-116
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ export type RootStackParamList = {
|
||||
ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean };
|
||||
GroupDetail: { groupId: string };
|
||||
AddAccount: undefined;
|
||||
Scheduled: undefined;
|
||||
UnifiedInbox: undefined;
|
||||
};
|
||||
|
||||
export type MainTabsParamList = {
|
||||
|
||||
+131
-16
@@ -16,6 +16,7 @@ import {
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
ListChecks,
|
||||
Menu,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
@@ -35,6 +36,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';
|
||||
@@ -46,11 +48,17 @@ import {
|
||||
type RecurrenceEditScope,
|
||||
} from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
|
||||
import { TasksSheet } from '../components/calendar/TasksSheet';
|
||||
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
|
||||
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
type EventDayIndex,
|
||||
} from '../lib/calendar-utils';
|
||||
import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
@@ -62,15 +70,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 +91,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 +111,23 @@ 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 showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = React.useState(new Date());
|
||||
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);
|
||||
@@ -105,6 +135,9 @@ export default function CalendarScreen() {
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(false);
|
||||
const [tasksVisible, setTasksVisible] = React.useState(false);
|
||||
const [importVisible, setImportVisible] = React.useState(false);
|
||||
const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const hydrate = useCalendarStore((s) => s.hydrate);
|
||||
@@ -114,12 +147,36 @@ export default function CalendarScreen() {
|
||||
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const tasks = useCalendarStore((s) => s.tasks);
|
||||
const createTask = useCalendarStore((s) => s.createTask);
|
||||
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
|
||||
const deleteTask = useCalendarStore((s) => s.deleteTask);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const allCalendars = useCalendarStore((s) => s.calendars);
|
||||
const storeCalendars = useCalendarStore((s) => s.calendars);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
const loading = useCalendarStore((s) => s.loading);
|
||||
const error = useCalendarStore((s) => s.error);
|
||||
const allEvents = useCalendarStore((s) => s.events);
|
||||
const storeEvents = useCalendarStore((s) => s.events);
|
||||
|
||||
// The birthday calendar is a client-side virtual calendar: its events are
|
||||
// generated from contacts for the visible range and merged in alongside the
|
||||
// server calendars. Toggling it on/off is instant (no refetch).
|
||||
const birthdayEvents = React.useMemo(() => {
|
||||
if (!showBirthdayCalendar) return [];
|
||||
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
|
||||
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
|
||||
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
|
||||
|
||||
const allCalendars = React.useMemo(
|
||||
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
|
||||
[storeCalendars, showBirthdayCalendar],
|
||||
);
|
||||
const allEvents = React.useMemo(
|
||||
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
|
||||
[storeEvents, birthdayEvents],
|
||||
);
|
||||
|
||||
const calendars = React.useMemo(
|
||||
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
|
||||
@@ -144,9 +201,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) => {
|
||||
@@ -190,23 +247,30 @@ export default function CalendarScreen() {
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const isReadOnlyEvent = React.useCallback(
|
||||
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect]);
|
||||
}, [openEditDirect, isReadOnlyEvent]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent]);
|
||||
}, [deleteEvent, isReadOnlyEvent]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
@@ -267,12 +331,21 @@ 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>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
{enableCalendarTasks && (
|
||||
<Pressable
|
||||
style={styles.headerBtn}
|
||||
onPress={() => setTasksVisible(true)}
|
||||
hitSlop={6}
|
||||
>
|
||||
<ListChecks size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
@@ -321,6 +394,8 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
showWeekNumbers={calendarShowWeekNumbers}
|
||||
onSelectDate={handleSelectDate}
|
||||
onLongPressDate={openCreate}
|
||||
/>
|
||||
@@ -331,6 +406,8 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
weekStartsOn={calendarFirstDayOfWeek}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectDate={handleSelectDate}
|
||||
onSelectEvent={setDetailEvent}
|
||||
onCreateAtTime={openCreate}
|
||||
@@ -373,6 +450,20 @@ export default function CalendarScreen() {
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
onRsvp={async (ev, participantId, status) => {
|
||||
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev));
|
||||
setDetailEvent((cur) =>
|
||||
cur && cur.id === ev.id && cur.participants?.[participantId]
|
||||
? {
|
||||
...cur,
|
||||
participants: {
|
||||
...cur.participants,
|
||||
[participantId]: { ...cur.participants[participantId], participationStatus: status },
|
||||
},
|
||||
}
|
||||
: cur,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
@@ -398,6 +489,30 @@ export default function CalendarScreen() {
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
|
||||
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
|
||||
/>
|
||||
|
||||
<TasksSheet
|
||||
visible={tasksVisible}
|
||||
tasks={tasks}
|
||||
calendars={allCalendars}
|
||||
onClose={() => setTasksVisible(false)}
|
||||
onCreate={createTask}
|
||||
onToggle={toggleTaskComplete}
|
||||
onDelete={deleteTask}
|
||||
/>
|
||||
|
||||
<ICalImportSheet
|
||||
visible={importVisible}
|
||||
calendars={storeCalendars}
|
||||
onClose={() => setImportVisible(false)}
|
||||
onImport={importEvents}
|
||||
/>
|
||||
|
||||
<ICalSubscriptionSheet
|
||||
visible={subscriptionsVisible}
|
||||
onClose={() => setSubscriptionsVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
+361
-16
@@ -1,16 +1,17 @@
|
||||
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,
|
||||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||
Undo2, Redo2, FileText,
|
||||
Undo2, Redo2, FileText, Clock, Check,
|
||||
} from 'lucide-react-native';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { File as FsFile } from 'expo-file-system';
|
||||
@@ -24,6 +25,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,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
} from '../lib/contact-utils';
|
||||
import { getIdentities } from '../api/identity';
|
||||
import { sendEmail, type OutgoingAttachment } from '../api/email';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { uploadBlob } from '../api/blob';
|
||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
|
||||
import { stripDangerousTags } from '../lib/email-html';
|
||||
@@ -209,6 +212,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 +257,18 @@ 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 [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
|
||||
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
|
||||
// can only show one field at a time, so we walk date → time.
|
||||
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
||||
const customDraftRef = React.useRef<Date>(new Date());
|
||||
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||
|
||||
const initialTo = React.useMemo<Recipient[]>(() => {
|
||||
if (!replyTo) {
|
||||
@@ -348,7 +396,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 +712,131 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
};
|
||||
|
||||
// ── Send ─────────────────────────────────────────────────────────────
|
||||
const onSend = async () => {
|
||||
|
||||
// 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) },
|
||||
],
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Translate an absolute "send at" time into the HOLDFOR seconds the server
|
||||
// expects, clamping to what it actually supports. Returns null (and alerts)
|
||||
// when scheduling isn't possible so the caller can abort.
|
||||
const resolveHoldForScheduledAt = (date: Date): number | null => {
|
||||
const seconds = Math.ceil((date.getTime() - Date.now()) / 1000);
|
||||
if (seconds <= 0) {
|
||||
Alert.alert(
|
||||
t('email_composer.schedule_past_title', 'Pick a future time'),
|
||||
t('email_composer.schedule_past_body', 'The scheduled time must be in the future.'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!jmapClient.hasDelayedSend()) {
|
||||
Alert.alert(
|
||||
t('email_composer.schedule_unsupported_title', 'Scheduling unavailable'),
|
||||
t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const max = jmapClient.getMaxDelayedSend();
|
||||
if (max > 0 && seconds > max) {
|
||||
Alert.alert(
|
||||
t('email_composer.schedule_too_late_title', 'Too far ahead'),
|
||||
t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return seconds;
|
||||
};
|
||||
|
||||
// The Send button: applies the global undo-send delay when the server
|
||||
// supports it, otherwise sends immediately.
|
||||
const onSend = () => {
|
||||
const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined;
|
||||
void performSend(holdFor);
|
||||
};
|
||||
|
||||
const onScheduleConfirm = (date: Date) => {
|
||||
const holdFor = resolveHoldForScheduledAt(date);
|
||||
if (holdFor == null) return;
|
||||
setScheduleSheetOpen(false);
|
||||
void performSend(holdFor, date);
|
||||
};
|
||||
|
||||
const schedulePresets = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
|
||||
const tomorrowMorning = new Date(now);
|
||||
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
|
||||
tomorrowMorning.setHours(8, 0, 0, 0);
|
||||
return [
|
||||
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) },
|
||||
{ label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },
|
||||
{ label: t('email_composer.schedule_tomorrow', 'Tomorrow, 8:00 AM'), date: tomorrowMorning },
|
||||
];
|
||||
}, [t, scheduleSheetOpen]);
|
||||
|
||||
const startCustomPicker = () => {
|
||||
customDraftRef.current = new Date(Date.now() + 3600 * 1000);
|
||||
setScheduleSheetOpen(false);
|
||||
setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date');
|
||||
};
|
||||
|
||||
const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => {
|
||||
if (event.type === 'dismissed' || !selected) {
|
||||
setCustomStage(null);
|
||||
return;
|
||||
}
|
||||
if (Platform.OS === 'ios') {
|
||||
// Single spinner — keep it open, just remember the latest value.
|
||||
customDraftRef.current = selected;
|
||||
return;
|
||||
}
|
||||
// Android: combine the date step with the existing time, then ask for time.
|
||||
if (customStage === 'date') {
|
||||
const d = new Date(customDraftRef.current);
|
||||
d.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
|
||||
customDraftRef.current = d;
|
||||
setCustomStage('time');
|
||||
return;
|
||||
}
|
||||
if (customStage === 'time') {
|
||||
const d = new Date(customDraftRef.current);
|
||||
d.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
|
||||
setCustomStage(null);
|
||||
onScheduleConfirm(d);
|
||||
}
|
||||
};
|
||||
|
||||
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||
if (!(await passesAttachmentReminder())) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
||||
@@ -668,7 +872,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
const outgoing = [...inlineFromBody, ...fileAttachments];
|
||||
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
{
|
||||
from,
|
||||
to: finalTo.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||
@@ -676,7 +880,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,
|
||||
@@ -684,7 +890,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
},
|
||||
primaryIdentity.id,
|
||||
sentMailbox.id,
|
||||
holdForSeconds,
|
||||
);
|
||||
// Confirm an explicit "send later" so the user knows it didn't go out
|
||||
// now. The brief undo-send delay stays silent — it's meant to be
|
||||
// invisible unless the user cancels from the Scheduled view.
|
||||
if (scheduledAt && result.scheduled) {
|
||||
const when = result.sendAt ? new Date(result.sendAt) : scheduledAt;
|
||||
Alert.alert(
|
||||
t('email_composer.scheduled_title', 'Scheduled'),
|
||||
t('email_composer.scheduled_body', 'Your message will be sent at {time}.').replace(
|
||||
'{time}',
|
||||
when.toLocaleString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
Alert.alert(
|
||||
@@ -703,7 +923,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} />
|
||||
@@ -719,10 +939,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
>
|
||||
<Paperclip size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setScheduleSheetOpen(true)}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
disabled={!canSend}
|
||||
>
|
||||
<Clock size={20} color={canSend ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={() => { void onSend(); }}
|
||||
onPress={onSend}
|
||||
disabled={!canSend}
|
||||
icon={
|
||||
sending ? (
|
||||
@@ -741,10 +969,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 +977,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 +1151,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}
|
||||
@@ -973,6 +1202,93 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Schedule send sheet */}
|
||||
<Modal
|
||||
visible={scheduleSheetOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setScheduleSheetOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t('email_composer.schedule_send', 'Schedule send')}
|
||||
</Text>
|
||||
{!jmapClient.hasDelayedSend() && (
|
||||
<Text style={styles.scheduleWarning}>
|
||||
{t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.')}
|
||||
</Text>
|
||||
)}
|
||||
{schedulePresets.map((preset) => (
|
||||
<Pressable
|
||||
key={preset.label}
|
||||
style={styles.scheduleRow}
|
||||
onPress={() => onScheduleConfirm(preset.date)}
|
||||
>
|
||||
<Clock size={16} color={c.textSecondary} />
|
||||
<Text style={styles.scheduleRowLabel}>{preset.label}</Text>
|
||||
<Text style={styles.scheduleRowTime}>
|
||||
{preset.date.toLocaleString(undefined, {
|
||||
weekday: 'short', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable style={styles.scheduleRow} onPress={startCustomPicker}>
|
||||
<Check size={16} color={c.textSecondary} />
|
||||
<Text style={styles.scheduleRowLabel}>
|
||||
{t('email_composer.schedule_custom', 'Pick date & time…')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.scheduleCancel}
|
||||
onPress={() => setScheduleSheetOpen(false)}
|
||||
>
|
||||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{customStage !== null && Platform.OS === 'ios' && (
|
||||
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode="datetime"
|
||||
display="spinner"
|
||||
minimumDate={new Date()}
|
||||
onChange={onCustomPickerChange}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable style={styles.modalCancel} onPress={() => setCustomStage(null)}>
|
||||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.modalConfirm}
|
||||
onPress={() => { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
|
||||
>
|
||||
<Text style={styles.modalConfirmText}>
|
||||
{t('email_composer.schedule_send', 'Schedule send')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{customStage !== null && Platform.OS !== 'ios' && (
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode={customStage === 'time' ? 'time' : 'date'}
|
||||
display="default"
|
||||
minimumDate={customStage === 'date' ? new Date() : undefined}
|
||||
onChange={onCustomPickerChange}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1192,5 +1508,34 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
scheduleCard: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
scheduleWarning: {
|
||||
...typography.caption,
|
||||
color: c.error,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
scheduleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
scheduleRowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
scheduleRowTime: { ...typography.caption, color: c.textMuted },
|
||||
scheduleCancel: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
|
||||
Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
|
||||
Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages,
|
||||
Clock, KeyRound,
|
||||
Clock, KeyRound, FolderInput,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
@@ -27,6 +27,7 @@ import { contactToVCard } from '../lib/vcard';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import Dialog from '../components/Dialog';
|
||||
import { ContactActivity } from '../components/contacts/ContactActivity';
|
||||
import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
@@ -84,9 +85,11 @@ export default function ContactDetailScreen() {
|
||||
const allContacts = useContactsStore((s) => s.contacts);
|
||||
const deleteContact = useContactsStore((s) => s.deleteContact);
|
||||
const createContact = useContactsStore((s) => s.createContact);
|
||||
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const [moreOpen, setMoreOpen] = React.useState(false);
|
||||
const [moveOpen, setMoveOpen] = React.useState(false);
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
@@ -232,12 +235,26 @@ export default function ContactDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const doMove = async (bookId: string) => {
|
||||
setMoveOpen(false);
|
||||
try {
|
||||
await moveContactsToAddressBook([contact.id], bookId);
|
||||
} catch (err) {
|
||||
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const moreItems = [
|
||||
!isGroup(contact) && {
|
||||
icon: <Copy size={16} color={c.text} />,
|
||||
label: 'Duplicate',
|
||||
onPress: () => { void doDuplicate(); },
|
||||
},
|
||||
addressBooks.length > 0 && {
|
||||
icon: <FolderInput size={16} color={c.text} />,
|
||||
label: 'Move to address book',
|
||||
onPress: () => setMoveOpen(true),
|
||||
},
|
||||
{
|
||||
icon: <Share2 size={16} color={c.text} />,
|
||||
label: 'Export vCard',
|
||||
@@ -614,6 +631,13 @@ export default function ContactDetailScreen() {
|
||||
items={moreItems}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
/>
|
||||
|
||||
<AddressBookPickerSheet
|
||||
visible={moveOpen}
|
||||
onClose={() => setMoveOpen(false)}
|
||||
currentBookId={bookIds[0] ?? null}
|
||||
onPick={(id) => { void doMove(id); }}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
@@ -25,7 +26,12 @@ import {
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { ContactListRow } from '../components/contacts';
|
||||
import {
|
||||
ContactListRow,
|
||||
AddressBookPickerSheet,
|
||||
ContactImportSheet,
|
||||
TagAssignSheet,
|
||||
} from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
@@ -91,6 +97,8 @@ export default function ContactsScreen() {
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
|
||||
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
@@ -99,6 +107,11 @@ export default function ContactsScreen() {
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
||||
const [moveSheetOpen, setMoveSheetOpen] = React.useState(false);
|
||||
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
|
||||
const [importOpen, setImportOpen] = React.useState(false);
|
||||
const [importTargetOpen, setImportTargetOpen] = React.useState(false);
|
||||
const [importTargetBookId, setImportTargetBookId] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
@@ -183,6 +196,45 @@ export default function ContactsScreen() {
|
||||
await bulkDelete(ids);
|
||||
};
|
||||
|
||||
// Default target for moves/imports: the currently-viewed address book, else
|
||||
// the first available book.
|
||||
const defaultBookId = React.useMemo(() => {
|
||||
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
|
||||
return addressBooks[0]?.id ?? null;
|
||||
}, [selectedCategory, addressBooks]);
|
||||
|
||||
const handleBulkMove = async (bookId: string) => {
|
||||
const ids = Array.from(selection);
|
||||
setMoveSheetOpen(false);
|
||||
clearSelection();
|
||||
try {
|
||||
await moveContactsToAddressBook(ids, bookId);
|
||||
} catch (err) {
|
||||
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkTag = async (keyword: string) => {
|
||||
const ids = Array.from(selection);
|
||||
setTagSheetOpen(false);
|
||||
clearSelection();
|
||||
try {
|
||||
await addKeywordToContacts(ids, keyword);
|
||||
} catch (err) {
|
||||
Alert.alert('Tagging failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const openImport = () => {
|
||||
setImportTargetBookId(defaultBookId);
|
||||
setImportOpen(true);
|
||||
};
|
||||
|
||||
const importTargetName = React.useMemo(
|
||||
() => addressBooks.find((b) => b.id === importTargetBookId)?.name,
|
||||
[addressBooks, importTargetBookId],
|
||||
);
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
@@ -234,6 +286,22 @@ export default function ContactsScreen() {
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
{addressBooks.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setMoveSheetOpen(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<FolderInput size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => setTagSheetOpen(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Tag size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
@@ -258,6 +326,13 @@ export default function ContactsScreen() {
|
||||
>
|
||||
<Search size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={openImport}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Upload size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
@@ -380,6 +455,35 @@ export default function ContactsScreen() {
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
|
||||
<AddressBookPickerSheet
|
||||
visible={moveSheetOpen}
|
||||
onClose={() => setMoveSheetOpen(false)}
|
||||
onPick={(id) => { void handleBulkMove(id); }}
|
||||
/>
|
||||
|
||||
<TagAssignSheet
|
||||
visible={tagSheetOpen}
|
||||
onClose={() => setTagSheetOpen(false)}
|
||||
onPick={(kw) => { void handleBulkTag(kw); }}
|
||||
/>
|
||||
|
||||
<ContactImportSheet
|
||||
visible={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
targetBookId={importTargetBookId}
|
||||
targetBookName={importTargetName}
|
||||
onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
|
||||
onImported={() => { void fetchContacts(); }}
|
||||
/>
|
||||
|
||||
<AddressBookPickerSheet
|
||||
visible={importTargetOpen}
|
||||
onClose={() => setImportTargetOpen(false)}
|
||||
currentBookId={importTargetBookId}
|
||||
title="Import into address book"
|
||||
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
+139
-18
@@ -5,18 +5,22 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
|
||||
import {
|
||||
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||
Archive, FolderInput, Tag,
|
||||
} from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useTypography, useDensity } from '../theme/dynamic';
|
||||
import SidebarDrawer from '../components/SidebarDrawer';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { TagSheet } from '../components/TagSheet';
|
||||
import { UndoSnackbar } from '../components/UndoSnackbar';
|
||||
import { OfflineBanner } from '../components/OfflineBanner';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import { useKeywordsStore } from '../stores/keywords-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
function getSenderName(email: Email): string {
|
||||
@@ -56,12 +60,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 +77,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 +91,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.emailRow,
|
||||
{ paddingVertical: density.rowPaddingVertical },
|
||||
pressed && styles.emailRowPressed,
|
||||
selected && styles.emailRowSelected,
|
||||
]}
|
||||
@@ -97,14 +108,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 +128,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>
|
||||
);
|
||||
@@ -174,11 +194,49 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
|
||||
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
|
||||
const archiveEmailsBatch = useEmailStore((s) => s.archiveEmailsBatch);
|
||||
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
|
||||
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
|
||||
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
||||
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
||||
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
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],
|
||||
@@ -194,12 +252,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
// Move-to-folder picker triggered by the swipe action.
|
||||
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
|
||||
// Batch (multi-select) sheets.
|
||||
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
|
||||
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
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 +335,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 +350,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 +359,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 +371,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));
|
||||
@@ -336,11 +400,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
await Promise.all(ids.map((id) => deleteEmailAction(id, trash.id, currentMailboxId)));
|
||||
await deleteEmailsBatch(Array.from(selectedIds), trash.id, currentMailboxId);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleBulkArchive = async () => {
|
||||
await archiveEmailsBatch(Array.from(selectedIds));
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const canArchiveSelection =
|
||||
archiveMailboxId != null && currentMailboxId !== archiveMailboxId;
|
||||
|
||||
const handleBatchMovePick = (toId: string) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchMoveOpen(false);
|
||||
clearSelection();
|
||||
void moveEmailsToMailbox(ids, toId);
|
||||
};
|
||||
|
||||
const handleBatchTagToggle = (token: string, on: boolean) => {
|
||||
void setKeywordForEmails(Array.from(selectedIds), token, on);
|
||||
};
|
||||
|
||||
// Local input state for uninterrupted typing; debounce into the store.
|
||||
const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
|
||||
React.useEffect(() => {
|
||||
@@ -437,6 +519,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<MailOpen size={20} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setTagSheetOpen(true)}
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Tag size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setBatchMoveOpen(true)}
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<FolderInput size={20} color={c.text} />
|
||||
</Pressable>
|
||||
{canArchiveSelection && (
|
||||
<Pressable
|
||||
onPress={() => { void handleBulkArchive(); }}
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Archive size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => { void handleBulkDelete(); }}
|
||||
style={styles.headerButton}
|
||||
@@ -619,7 +724,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={emails}
|
||||
data={visibleEmails}
|
||||
keyExtractor={emailKeyExtractor}
|
||||
renderItem={renderEmailRow}
|
||||
ItemSeparatorComponent={EmailRowSeparator}
|
||||
@@ -842,6 +947,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}}
|
||||
/>
|
||||
|
||||
<MoveSheet
|
||||
visible={batchMoveOpen}
|
||||
onClose={() => setBatchMoveOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={handleBatchMovePick}
|
||||
/>
|
||||
|
||||
<TagSheet
|
||||
visible={tagSheetOpen}
|
||||
onClose={() => setTagSheetOpen(false)}
|
||||
keywords={keywordDefs}
|
||||
selectedEmails={selectedEmails}
|
||||
onToggle={handleBatchTagToggle}
|
||||
/>
|
||||
|
||||
<UndoSnackbar />
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import EmailBodyView from '../components/EmailBodyView';
|
||||
import { CalendarInvitationBanner } from '../components/email/CalendarInvitationBanner';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
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 +86,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 +174,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 +183,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 +199,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 +429,14 @@ 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()}
|
||||
|
||||
{/* Calendar invitation (auto-detected .ics) */}
|
||||
<CalendarInvitationBanner email={email} />
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
@@ -736,10 +770,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 +789,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>
|
||||
);
|
||||
}
|
||||
|
||||
+943
-124
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Constants from 'expo-constants';
|
||||
import { Eye, EyeOff, X } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button, Input } from '../components';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin?: () => void;
|
||||
isAddMode?: boolean;
|
||||
@@ -17,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -27,6 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
|
||||
const canHandoff = Boolean(serverUrl.trim());
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!canSubmit) {
|
||||
@@ -41,6 +46,25 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebmailHandoff = async () => {
|
||||
if (!canHandoff) return;
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
|
||||
// loginViaWebmail swallows the "user cancelled the browser" case
|
||||
// silently — only fire the navigation hook when authentication
|
||||
// actually completed.
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if (after && !before) {
|
||||
onLogin?.();
|
||||
} else if (isAddMode && after) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = React.useCallback(
|
||||
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
|
||||
if (useAuthStore.getState().error) {
|
||||
@@ -146,17 +170,24 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* OAuth buttons */}
|
||||
{/* Webmail-mediated login */}
|
||||
<View style={styles.oauthRow}>
|
||||
<Button variant="outline" size="md" disabled>
|
||||
SSO Provider
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
void handleWebmailHandoff();
|
||||
}}
|
||||
disabled={!canHandoff || isLoading}
|
||||
>
|
||||
Sign in via webmail
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
|
||||
<Text style={styles.footerLink}>Privacy Policy</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, Clock, X } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
listScheduledEmails,
|
||||
cancelScheduledSend,
|
||||
type ScheduledEmail,
|
||||
} from '../api/email';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
||||
|
||||
function recipientLabel(item: ScheduledEmail): string {
|
||||
const first = item.to?.[0];
|
||||
if (!first) return '(no recipient)';
|
||||
const name = first.name || first.email;
|
||||
const extra = (item.to?.length ?? 0) - 1;
|
||||
return extra > 0 ? `${name} +${extra}` : name;
|
||||
}
|
||||
|
||||
export default function ScheduledScreen({ navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [items, setItems] = React.useState<ScheduledEmail[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setItems(await listScheduledEmails());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load scheduled messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCancel = (item: ScheduledEmail) => {
|
||||
Alert.alert(
|
||||
'Cancel scheduled send?',
|
||||
'The message will not be delivered. A copy stays in your Sent folder.',
|
||||
[
|
||||
{ text: 'Keep scheduled', style: 'cancel' },
|
||||
{
|
||||
text: 'Cancel send',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setCancelling(item.emailSubmissionId);
|
||||
void (async () => {
|
||||
try {
|
||||
await cancelScheduledSend(item.emailSubmissionId);
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => i.emailSubmissionId !== item.emailSubmissionId),
|
||||
);
|
||||
} catch (e) {
|
||||
Alert.alert('Cancel failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCancelling(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: ScheduledEmail }) => (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.sendAtRow}>
|
||||
<Clock size={13} color={c.primary} />
|
||||
<Text style={styles.sendAt}>{new Date(item.sendAt).toLocaleString()}</Text>
|
||||
</View>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
<Text style={styles.recipient} numberOfLines={1}>
|
||||
To: {recipientLabel(item)}
|
||||
</Text>
|
||||
{item.preview ? (
|
||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => onCancel(item)}
|
||||
style={styles.cancelBtn}
|
||||
hitSlop={8}
|
||||
disabled={cancelling === item.emailSubmissionId}
|
||||
>
|
||||
{cancelling === item.emailSubmissionId ? (
|
||||
<ActivityIndicator size="small" color={c.error} />
|
||||
) : (
|
||||
<X size={18} color={c.error} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>Scheduled</Text>
|
||||
<View style={styles.headerBtn} />
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.error}>{error}</Text>
|
||||
<Pressable onPress={() => { void load(); }}>
|
||||
<Text style={styles.retry}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : items.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Clock size={40} color={c.textMuted} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>No scheduled messages</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Use the clock icon in the composer to send a message later.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(i) => i.emailSubmissionId}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshing={loading}
|
||||
onRefresh={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
|
||||
error: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
|
||||
emptyText: { ...typography.body, color: c.textSecondary },
|
||||
emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center', maxWidth: 280 },
|
||||
listContent: { paddingVertical: spacing.sm },
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowMain: { flex: 1, minWidth: 0, gap: 2 },
|
||||
sendAtRow: { flexDirection: 'row', alignItems: 'center', gap: 5 },
|
||||
sendAt: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
subject: { ...typography.bodyMedium, color: c.text },
|
||||
recipient: { ...typography.caption, color: c.textSecondary },
|
||||
preview: { ...typography.caption, color: c.textMuted },
|
||||
cancelBtn: {
|
||||
width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
|
||||
import { ComposingSettings } from '../components/settings/ComposingSettings';
|
||||
import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -155,6 +156,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
// Subscribe to locale so labels re-render when the user picks a different language.
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const unavailableTabs = React.useMemo(() => {
|
||||
const set = new Set<Tab>();
|
||||
if (!hasCalendar) set.add('calendar');
|
||||
if (!hasContacts) set.add('contacts');
|
||||
if (!hasFiles) set.add('files');
|
||||
return set;
|
||||
}, [hasCalendar, hasContacts, hasFiles]);
|
||||
const groupedTabs = React.useMemo(() => {
|
||||
void locale; // dependency: re-translate on locale change
|
||||
return groupTabs().map((g) => ({
|
||||
@@ -178,6 +189,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
if (unavailableTabs.has(tab.id)) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
@@ -210,14 +222,20 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title.
|
||||
Settings is a tab root, so the back button is only rendered when a
|
||||
parent passes onBack (it'd otherwise lead nowhere). */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
{onBack ? (
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.headerLeftSpacer} />
|
||||
)}
|
||||
<Settings size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
|
||||
</View>
|
||||
@@ -235,7 +253,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
const unavailable = unavailableTabs.has(tab.id);
|
||||
const disabled = !tab.implemented || unavailable;
|
||||
const badgeLabel = !tab.implemented
|
||||
? 'Not implemented'
|
||||
: unavailable
|
||||
? 'Unavailable'
|
||||
: null;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
@@ -263,9 +287,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
{badgeLabel ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
<Text style={styles.notWorkingText}>{badgeLabel}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={c.mutedForeground} />
|
||||
@@ -310,6 +334,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerBackBtnPressed: { backgroundColor: c.accent },
|
||||
headerLeftSpacer: { width: spacing.sm },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
|
||||
scrollArea: { flex: 1 },
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, Star, Paperclip, AlertTriangle } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
|
||||
|
||||
function senderName(email: UnifiedEmail): string {
|
||||
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const diffMin = Math.floor((Date.now() - date.getTime()) / 60000);
|
||||
if (diffMin < 1) return 'now';
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay}d`;
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export default function UnifiedInboxScreen({ navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
|
||||
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
|
||||
const accountById = React.useMemo(
|
||||
() => new Map(accounts.map((a) => [a.id, a])),
|
||||
[accounts],
|
||||
);
|
||||
const accountIds = React.useMemo(() => accounts.map((a) => a.id), [accounts]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchUnifiedInbox(accountIds);
|
||||
setEmails(result.emails);
|
||||
setErrors(result.errors);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accountIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onOpen = React.useCallback(
|
||||
(email: UnifiedEmail) => {
|
||||
if (opening) return;
|
||||
setOpening(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.activeAccountId !== email.sourceAccountId) {
|
||||
await switchAccount(email.sourceAccountId);
|
||||
if (useAuthStore.getState().activeAccountId !== email.sourceAccountId) return;
|
||||
}
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
});
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[opening, switchAccount, navigation],
|
||||
);
|
||||
|
||||
const errorCount = Object.keys(errors).length;
|
||||
|
||||
const renderItem = ({ item }: { item: UnifiedEmail }) => {
|
||||
const acc = accountById.get(item.sourceAccountId);
|
||||
const unread = !item.keywords?.$seen;
|
||||
const starred = !!item.keywords?.$flagged;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onOpen(item)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={styles.avatarWrap}>
|
||||
<SenderAvatar
|
||||
name={senderName(item)}
|
||||
email={item.from?.[0]?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
/>
|
||||
{acc && <View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />}
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.line}>
|
||||
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
|
||||
{senderName(item)}
|
||||
</Text>
|
||||
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
|
||||
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.time}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
|
||||
</View>
|
||||
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
<View style={styles.line}>
|
||||
{acc && (
|
||||
<Text style={styles.account} numberOfLines={1}>
|
||||
{acc.email || acc.username}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{item.preview ? (
|
||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>All inboxes</Text>
|
||||
<View style={styles.headerBtn}>
|
||||
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{errorCount > 0 && (
|
||||
<View style={styles.errorBanner}>
|
||||
<AlertTriangle size={14} color={c.error} />
|
||||
<Text style={styles.errorBannerText} numberOfLines={1}>
|
||||
{errorCount === 1
|
||||
? '1 account could not be loaded'
|
||||
: `${errorCount} accounts could not be loaded`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && emails.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
<Text style={styles.loadingText}>Loading all inboxes…</Text>
|
||||
</View>
|
||||
) : emails.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.loadingText}>No mail across your accounts</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={emails}
|
||||
keyExtractor={(item) => `${item.sourceAccountId}:${item.id}`}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
refreshing={loading}
|
||||
onRefresh={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||
errorBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
},
|
||||
errorBannerText: { ...typography.caption, color: c.error, flex: 1 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm },
|
||||
loadingText: { ...typography.body, color: c.textSecondary },
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
avatarWrap: { position: 'relative' },
|
||||
accountDot: {
|
||||
position: 'absolute',
|
||||
right: -2, bottom: -2,
|
||||
width: 12, height: 12,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: c.background,
|
||||
},
|
||||
content: { flex: 1, minWidth: 0, gap: 1 },
|
||||
line: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
sender: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
bold: { fontWeight: '700' },
|
||||
time: { ...typography.caption, color: c.textMuted },
|
||||
subject: { ...typography.body, color: c.text },
|
||||
account: { ...typography.caption, color: c.textMuted },
|
||||
preview: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -1,42 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('expo-secure-store', () => ({
|
||||
setItemAsync: vi.fn(),
|
||||
getItemAsync: vi.fn(),
|
||||
deleteItemAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
connect: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
restoreSession: vi.fn(),
|
||||
loadAccount: vi.fn(),
|
||||
consumeLegacyCredentials: vi.fn(async () => null),
|
||||
clearAccountCredentials: vi.fn(async () => undefined),
|
||||
clearAllCredentials: vi.fn(async () => undefined),
|
||||
reset: vi.fn(),
|
||||
accountId: 'acc-1',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
username: 'user',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
},
|
||||
AuthenticationError: class AuthenticationError extends Error {
|
||||
constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; }
|
||||
},
|
||||
NetworkError: class NetworkError extends Error {
|
||||
constructor(msg: string) { super(msg); this.name = 'NetworkError'; }
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useAccountStore } from '../account-store';
|
||||
|
||||
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
||||
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
||||
const mockRestore = jmapClient.restoreSession as ReturnType<typeof vi.fn>;
|
||||
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
|
||||
|
||||
function resetAccountStore(): void {
|
||||
useAccountStore.setState({
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
defaultAccountId: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset store state
|
||||
resetAccountStore();
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
hasRestoredSession: false,
|
||||
error: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: null,
|
||||
client: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +104,7 @@ describe('auth-store', () => {
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should reset all state', async () => {
|
||||
it('should reset all state when no other accounts remain', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, serverUrl: 'x', username: 'y' });
|
||||
mockLogout.mockResolvedValue(undefined);
|
||||
|
||||
@@ -97,23 +118,40 @@ describe('auth-store', () => {
|
||||
});
|
||||
|
||||
describe('restoreSession', () => {
|
||||
it('should restore on success', async () => {
|
||||
mockRestore.mockResolvedValue(true);
|
||||
it('should return false when there is no registered account', async () => {
|
||||
const restored = await useAuthStore.getState().restoreSession();
|
||||
|
||||
expect(restored).toBe(false);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(useAuthStore.getState().hasRestoredSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should restore when loadAccount succeeds for the active account', async () => {
|
||||
useAccountStore.setState({
|
||||
accounts: [
|
||||
{
|
||||
id: 'acc-1',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user',
|
||||
displayName: 'user',
|
||||
email: 'user',
|
||||
avatarColor: '#000',
|
||||
lastLoginAt: 0,
|
||||
isConnected: false,
|
||||
hasError: false,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
activeAccountId: 'acc-1',
|
||||
defaultAccountId: 'acc-1',
|
||||
});
|
||||
mockLoadAccount.mockResolvedValue(true);
|
||||
|
||||
const restored = await useAuthStore.getState().restoreSession();
|
||||
|
||||
expect(restored).toBe(true);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no session to restore', async () => {
|
||||
mockRestore.mockResolvedValue(false);
|
||||
|
||||
const restored = await useAuthStore.getState().restoreSession();
|
||||
|
||||
expect(restored).toBe(false);
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearError', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -271,7 +272,9 @@ describe('calendar-store', () => {
|
||||
{ title: 'New' } as any,
|
||||
);
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' });
|
||||
// The third arg is the iMIP scheduling flag — undefined here since the
|
||||
// event has no participants to notify.
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,8 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../api/email', () => ({
|
||||
getMailboxes: vi.fn(),
|
||||
// New helpers used by the incremental-sync path. Default behavior: behave
|
||||
// like a first-ever load — no prior state, full re-query expected.
|
||||
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||
getMailboxChanges: vi.fn(async () => null),
|
||||
queryEmails: vi.fn(),
|
||||
getEmailQueryChanges: vi.fn(async () => null),
|
||||
getEmails: vi.fn(),
|
||||
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
|
||||
getEmailChanges: vi.fn(async () => null),
|
||||
getFullEmail: vi.fn(),
|
||||
setEmailKeywords: vi.fn(),
|
||||
moveEmail: vi.fn(),
|
||||
@@ -17,15 +25,57 @@ vi.mock('../../api/email', () => ({
|
||||
// trip on react-native's Flow-typed entrypoint under vitest. The store only
|
||||
// reads archiveMode in archiveEmail, so a minimal stub is enough.
|
||||
vi.mock('../settings-store', () => ({
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
|
||||
}));
|
||||
|
||||
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
|
||||
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
|
||||
// switch). Stub it as an empty cache so tests don't need to set up
|
||||
// AsyncStorage.
|
||||
vi.mock('../offline-cache-store', () => ({
|
||||
useOfflineCacheStore: {
|
||||
getState: () => ({
|
||||
activeAccountId: null,
|
||||
hydrated: true,
|
||||
hydrate: vi.fn(),
|
||||
setAccount: vi.fn(async () => undefined),
|
||||
totalCount: () => 0,
|
||||
getEmailsInMailbox: vi.fn(async () => []),
|
||||
has: () => false,
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// email-store now early-returns from fetch actions unless jmapClient is
|
||||
// connected AND serving the same logical account the store has active
|
||||
// (`generateAccountId(username, serverUrl) === activeAccountId`). The
|
||||
// tests exercise those actions, so present a fully connected stub plus
|
||||
// the matching username/serverUrl pair. beforeEach() syncs the store's
|
||||
// activeAccountId to the id these credentials produce.
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'acc-1',
|
||||
username: 'test@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateAccountId } from '../../lib/account-utils';
|
||||
const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.example.com');
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import { useEmailStore } from '../email-store';
|
||||
|
||||
const mockGetMailboxes = emailApi.getMailboxes as ReturnType<typeof vi.fn>;
|
||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
||||
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
|
||||
const mockSetKeywords = emailApi.setEmailKeywords as ReturnType<typeof vi.fn>;
|
||||
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
|
||||
@@ -35,6 +85,10 @@ const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useEmailStore.getState().reset();
|
||||
// Wire the store's active account to the one the mocked jmapClient is
|
||||
// serving, so the guard inside fetchMailboxes / refreshEmails / etc.
|
||||
// doesn't short-circuit the tests.
|
||||
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
|
||||
});
|
||||
|
||||
describe('email-store', () => {
|
||||
@@ -44,7 +98,7 @@ describe('email-store', () => {
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox' },
|
||||
{ id: 'mb-2', name: 'Sent', role: 'sent' },
|
||||
];
|
||||
mockGetMailboxes.mockResolvedValue(mailboxes);
|
||||
mockGetMailboxesWithState.mockResolvedValue({ list: mailboxes, state: 'mb-state-1' });
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes();
|
||||
|
||||
@@ -52,7 +106,7 @@ describe('email-store', () => {
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
mockGetMailboxes.mockRejectedValue(new Error('Network error'));
|
||||
mockGetMailboxesWithState.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes();
|
||||
|
||||
@@ -62,12 +116,12 @@ describe('email-store', () => {
|
||||
|
||||
describe('selectMailbox', () => {
|
||||
it('should query and fetch emails for mailbox', async () => {
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2 });
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2, queryState: 'q-1' });
|
||||
const emails = [
|
||||
{ id: 'e1', subject: 'Email 1' },
|
||||
{ id: 'e2', subject: 'Email 2' },
|
||||
];
|
||||
mockGetEmails.mockResolvedValue(emails);
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: emails, state: 'em-state-1' });
|
||||
|
||||
await useEmailStore.getState().selectMailbox('mb-1');
|
||||
|
||||
@@ -79,11 +133,14 @@ describe('email-store', () => {
|
||||
});
|
||||
|
||||
it('should handle empty mailbox', async () => {
|
||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0 });
|
||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q-empty' });
|
||||
|
||||
await useEmailStore.getState().selectMailbox('mb-empty');
|
||||
|
||||
expect(useEmailStore.getState().emails).toEqual([]);
|
||||
// First-time load with no ids hits getEmailsWithState only to prime
|
||||
// emailState (with an empty ids array). It should NOT call legacy
|
||||
// getEmails — that path is reserved for pagination/search.
|
||||
expect(mockGetEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+257
-25
@@ -1,12 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { jmapClient, AuthenticationError } from '../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
|
||||
import type { JMAPSession } from '../api/types';
|
||||
import { useAccountStore } from './account-store';
|
||||
import { useEmailStore } from './email-store';
|
||||
import { useContactsStore } from './contacts-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { teardownPushNotifications } from '../lib/push-notifications';
|
||||
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../lib/push-notifications';
|
||||
|
||||
// Persist middleware hydrates asynchronously on cold start. Without this
|
||||
// guard, restoreSession() can read the account-store before AsyncStorage has
|
||||
@@ -35,15 +39,37 @@ export interface AuthState {
|
||||
client: typeof jmapClient | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
restoreSession: () => Promise<boolean>;
|
||||
retrySession: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function resetFeatureStores(): void {
|
||||
useEmailStore.getState().reset();
|
||||
// Wipe ALL cached feature data for ALL accounts. Used for logoutAll where
|
||||
// the user is signing out of everything — we don't want stale snapshots
|
||||
// lingering on disk for accounts that no longer exist.
|
||||
function clearAllFeatureStores(): void {
|
||||
useEmailStore.getState().clearAllAccounts();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
// Drop the named account from the email cache, then reset the (per-session,
|
||||
// not yet per-account) contacts and calendar stores. Used by logout when
|
||||
// signing one account out while others remain.
|
||||
function clearAccountFeatureStores(accountId: string | null): void {
|
||||
if (accountId) {
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
} else {
|
||||
useEmailStore.getState().clearAllAccounts();
|
||||
}
|
||||
// Contacts and calendar stores aren't yet keyed by account — the safe
|
||||
// thing on logout is still to wipe them so the next account doesn't see
|
||||
// the previous user's data. Per-account caching for those stores is a
|
||||
// follow-up.
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
@@ -104,11 +130,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (serverUrl, username, password, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Adding an additional account - snapshot/reset feature stores so the
|
||||
// new account starts with a clean slate.
|
||||
// Adding an additional account - snapshot the current account away so
|
||||
// its cache survives, then reset the JMAP client for the new login.
|
||||
// Contacts/calendar are still single-bucket, so wipe those.
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const session = await jmapClient.connect(serverUrl, username, password);
|
||||
@@ -125,6 +153,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
// Swap the email store's active view to the new account so the rest of
|
||||
// this function (and refetchFeatureStores) writes to the right bucket.
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
} catch (err) {
|
||||
@@ -138,13 +169,83 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginViaWebmail: async (webmailUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
let result;
|
||||
try {
|
||||
result = await runWebmailHandoff(webmailUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof HandoffCancelledError) {
|
||||
// User closed the browser tab — quiet exit, no error banner.
|
||||
set({ isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result.flow === 'password') {
|
||||
// Hand the credentials to the existing password login path so account
|
||||
// registration + feature-store wiring all behave identically to a
|
||||
// manual sign-in.
|
||||
await get().login(result.serverUrl, result.username, result.password, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth — the webmail did the dance against Stalwart and handed us a
|
||||
// token bundle. Bootstrap the JMAP session with Bearer auth and let
|
||||
// ensure/forceRefreshToken keep it alive going forward.
|
||||
try {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
||||
result.serverUrl,
|
||||
result.tokens,
|
||||
);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'OAuth sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
|
||||
// Best-effort: revoke the JMAP PushSubscription and drop the relay
|
||||
// mapping before we lose credentials. Do not abort logout on failure.
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
// Best-effort: revoke this account's JMAP PushSubscription and drop its
|
||||
// relay mapping before we lose credentials. Other logged-in accounts'
|
||||
// push setups remain untouched. Do not abort logout on failure.
|
||||
if (currentId) {
|
||||
await teardownPushNotificationsForAccount(currentId).catch(() => undefined);
|
||||
} else {
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Clear credentials for this account first
|
||||
if (currentId) {
|
||||
@@ -155,7 +256,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
clearAccountFeatureStores(currentId);
|
||||
|
||||
// Switch to next remaining account, if any
|
||||
const remaining = accountStore.accounts;
|
||||
@@ -189,7 +290,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
await jmapClient.clearAllCredentials(ids);
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
clearAllFeatureStores();
|
||||
|
||||
for (const id of ids) accountStore.removeAccount(id);
|
||||
|
||||
@@ -216,14 +317,50 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
// Swap the email-store view to the new account *before* the network
|
||||
// round-trip. The previous account's data is tucked into its snapshot;
|
||||
// the new account's data (if previously cached) is restored to the
|
||||
// top-level fields so the EmailListScreen immediately shows the new
|
||||
// account's last-known mail instead of flashing empty. The network
|
||||
// refresh below applies incremental updates on top.
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
// Contacts and calendar stores aren't yet per-account, so they still
|
||||
// need a reset to avoid showing the previous account's data.
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
|
||||
// Load the new account's session. loadAccount overwrites
|
||||
// credentials/session/_accountId itself, so we don't need to reset
|
||||
// jmapClient first. If it fails, restore the previous active account
|
||||
// so we don't leave the user stranded on a half-switched state.
|
||||
const previousActive = get().activeAccountId;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||
accountStore.removeAccount(accountId);
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
// NetworkError or anything else - keep the previous active account
|
||||
// intact instead of stranding the user on a half-switched state.
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to switch account',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,15 +423,64 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
// Point the email store at the target account before any await — so
|
||||
// the EmailListScreen, which re-renders the moment the persisted state
|
||||
// hydrates, sees the right account's cached emails instead of stale
|
||||
// data from a previous session.
|
||||
useEmailStore.getState().setActiveAccount(target.id);
|
||||
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
// No stored credentials (or corrupt) — genuine logout.
|
||||
accountStore.removeAccount(target.id);
|
||||
useEmailStore.getState().removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkError) {
|
||||
// Server unreachable. Keep credentials, mark account offline, and
|
||||
// surface the cached UI so the user can still browse persisted
|
||||
// mail / contacts / calendar. The login screen would lose their
|
||||
// settings without recourse, which is the bug we're fixing here.
|
||||
accountStore.setActiveAccount(target.id);
|
||||
accountStore.updateAccount(target.id, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
hasRestoredSession: true,
|
||||
error: null,
|
||||
serverUrl: target.serverUrl,
|
||||
username: target.username,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: target.id,
|
||||
client: jmapClient,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (err instanceof AuthenticationError) {
|
||||
// Server reachable but credentials rejected — drop them.
|
||||
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
|
||||
accountStore.removeAccount(target.id);
|
||||
useEmailStore.getState().removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
accountStore.setActiveAccount(target.id);
|
||||
accountStore.updateAccount(target.id, { isConnected: true, hasError: false });
|
||||
accountStore.updateAccount(target.id, {
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
const session = jmapClient.currentSession!;
|
||||
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
||||
@@ -309,5 +495,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Re-attempt session establishment for the currently active account
|
||||
// without disturbing UI state on failure. Used by the network-recovery
|
||||
// watcher and any explicit "retry" button. Idempotent: returns true if
|
||||
// a session is already live.
|
||||
retrySession: async () => {
|
||||
const { activeAccountId, session } = get();
|
||||
if (!activeAccountId) return false;
|
||||
if (session) return true;
|
||||
const accountStore = useAccountStore.getState();
|
||||
const target = accountStore.getAccountById(activeAccountId);
|
||||
if (!target) return false;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(activeAccountId);
|
||||
if (!ok) return false;
|
||||
accountStore.updateAccount(activeAccountId, {
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
const fresh = jmapClient.currentSession!;
|
||||
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
|
||||
refetchFeatureStores();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
// Now we know the credentials are bad — fall back to logout flow.
|
||||
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
|
||||
accountStore.removeAccount(activeAccountId);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
hasRestoredSession: true,
|
||||
error: 'Session expired',
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: null,
|
||||
client: null,
|
||||
});
|
||||
}
|
||||
// NetworkError or anything else: stay where we are.
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
@@ -9,10 +9,19 @@ import {
|
||||
createEvent as apiCreateEvent,
|
||||
updateEvent as apiUpdateEvent,
|
||||
deleteEvents as apiDeleteEvents,
|
||||
batchCreateEvents as apiBatchCreateEvents,
|
||||
rsvpEvent as apiRsvpEvent,
|
||||
createCalendar as apiCreateCalendar,
|
||||
} from '../api/calendar';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { expandRecurringEvents } from '../lib/recurrence-expansion';
|
||||
|
||||
// Does the event carry attendees the server should notify over iMIP? Used to
|
||||
// decide whether to set sendSchedulingMessages on create/update/delete.
|
||||
function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boolean {
|
||||
return !!event?.participants && Object.keys(event.participants).length > 0;
|
||||
}
|
||||
|
||||
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
|
||||
|
||||
export interface LoadedRange {
|
||||
@@ -23,6 +32,7 @@ export interface LoadedRange {
|
||||
export interface CalendarState {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
tasks: CalendarEvent[];
|
||||
hiddenCalendarIds: string[];
|
||||
loadedRange: LoadedRange | null;
|
||||
loading: boolean;
|
||||
@@ -38,6 +48,20 @@ export interface CalendarState {
|
||||
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
|
||||
updateEvent: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
// Invitations / scheduling
|
||||
rsvpEvent: (
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
) => Promise<void>;
|
||||
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
createCalendar: (name: string, color?: string) => Promise<Calendar>;
|
||||
// Tasks
|
||||
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
|
||||
updateTask: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
|
||||
toggleTaskComplete: (id: string) => Promise<void>;
|
||||
deleteTask: (id: string) => Promise<void>;
|
||||
toggleCalendarVisibility: (id: string) => void;
|
||||
setCalendarHidden: (id: string, hidden: boolean) => void;
|
||||
reset: () => void;
|
||||
@@ -66,6 +90,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
(set, get) => ({
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
hiddenCalendarIds: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
@@ -89,6 +114,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
fetchCalendars: async () => {
|
||||
// CalendarScreen fires this on mount; on cold start that happens before
|
||||
// restoreSession has connected jmapClient. Bail rather than surfacing
|
||||
// a "Not authenticated" error - the refetch driven by the auth-store
|
||||
// will run this again once the session is live.
|
||||
if (!jmapClient.isConnected) return;
|
||||
try {
|
||||
const calendars = (await fetchCalendars()) ?? [];
|
||||
set({ calendars });
|
||||
@@ -98,18 +128,21 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
fetchEvents: async (calendarIds, after, before) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
|
||||
const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : [];
|
||||
// Stalwart returns both Events and Tasks from CalendarEvent/query; tasks
|
||||
// lack `start` and must be filtered out client-side.
|
||||
// Stalwart returns both Events and Tasks from CalendarEvent/query. Events
|
||||
// have a `start`; tasks are surfaced separately so they don't pollute the
|
||||
// grid (they're shown in the task list with their due date instead).
|
||||
const onlyEvents = raw.filter((e) => {
|
||||
const t = (e as { '@type'?: string })['@type'];
|
||||
return (t === 'Event' || t === undefined) && !!e.start;
|
||||
});
|
||||
const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task');
|
||||
const events = expandRecurringEvents(onlyEvents, after, before);
|
||||
set({ events, loadedRange: { after, before }, loading: false });
|
||||
set({ events, tasks, loadedRange: { after, before }, loading: false });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
|
||||
}
|
||||
@@ -142,6 +175,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
@@ -159,7 +193,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
createEvent: async (event, calendarId) => {
|
||||
const created = await apiCreateEvent(event, calendarId);
|
||||
const created = await apiCreateEvent(
|
||||
event,
|
||||
calendarId,
|
||||
hasSchedulingParticipants(event) ? true : undefined,
|
||||
);
|
||||
set({ events: [...get().events, created] });
|
||||
return created;
|
||||
},
|
||||
@@ -168,7 +206,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
// Resolve client-side expanded occurrence IDs back to the master event ID.
|
||||
const storeEvent = get().events.find((e) => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
await apiUpdateEvent(realId, changes);
|
||||
// Notify attendees if either the stored event or the incoming changes
|
||||
// carry participants.
|
||||
const schedule =
|
||||
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
|
||||
await apiUpdateEvent(realId, changes, schedule ? true : undefined);
|
||||
set({
|
||||
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)),
|
||||
});
|
||||
@@ -177,10 +219,89 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
deleteEvent: async (id) => {
|
||||
const storeEvent = get().events.find((e) => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
await apiDeleteEvents([realId]);
|
||||
await apiDeleteEvents(
|
||||
[realId],
|
||||
hasSchedulingParticipants(storeEvent) ? true : undefined,
|
||||
);
|
||||
set({ events: get().events.filter((e) => e.id !== id) });
|
||||
},
|
||||
|
||||
rsvpEvent: async (eventId, participantId, status, replyTo) => {
|
||||
if (!participantId || participantId.includes('..')) {
|
||||
throw new Error('Invalid participant ID');
|
||||
}
|
||||
const storeEvent = get().events.find((e) => e.id === eventId);
|
||||
const realId = storeEvent?.originalId || eventId;
|
||||
await apiRsvpEvent(realId, participantId, status, replyTo);
|
||||
set({
|
||||
events: get().events.map((e) => {
|
||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||
return {
|
||||
...e,
|
||||
participants: {
|
||||
...e.participants,
|
||||
[participantId]: { ...e.participants[participantId], participationStatus: status },
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
importEvents: async (events, calendarId) => {
|
||||
if (events.length === 0) return 0;
|
||||
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
|
||||
// already exists; create the rest. (We don't attempt cross-calendar linking
|
||||
// on mobile — a duplicate is simply skipped.)
|
||||
let toCreate = events;
|
||||
try {
|
||||
const existingIds = await queryEvents([], '', '');
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
|
||||
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
|
||||
} catch {
|
||||
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
||||
}
|
||||
if (toCreate.length === 0) return 0;
|
||||
const count = await apiBatchCreateEvents(toCreate, calendarId);
|
||||
await get().refresh();
|
||||
return count;
|
||||
},
|
||||
|
||||
createCalendar: async (name, color) => {
|
||||
const created = await apiCreateCalendar(name, color);
|
||||
set({ calendars: [...get().calendars, created] });
|
||||
return created;
|
||||
},
|
||||
|
||||
createTask: async (task, calendarId) => {
|
||||
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId);
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
updateTask: async (id, changes) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
const realId = task?.originalId || id;
|
||||
await apiUpdateEvent(realId, changes);
|
||||
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
||||
},
|
||||
|
||||
toggleTaskComplete: async (id) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
const completed = task.progress === 'completed';
|
||||
const next: Partial<CalendarEvent> = completed
|
||||
? { progress: 'in-process', percentComplete: 0 }
|
||||
: { progress: 'completed', percentComplete: 100 };
|
||||
await get().updateTask(id, next);
|
||||
},
|
||||
|
||||
deleteTask: async (id) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
const realId = task?.originalId || id;
|
||||
await apiDeleteEvents([realId]);
|
||||
set({ tasks: get().tasks.filter((t) => t.id !== id) });
|
||||
},
|
||||
|
||||
toggleCalendarVisibility: (id) => {
|
||||
const { hiddenCalendarIds } = get();
|
||||
const next = hiddenCalendarIds.includes(id)
|
||||
@@ -204,6 +325,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
reset: () => set({
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -218,6 +340,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
partialize: (state) => ({
|
||||
calendars: state.calendars,
|
||||
events: state.events,
|
||||
tasks: state.tasks,
|
||||
loadedRange: state.loadedRange,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { CalendarEvent } from '../api/types';
|
||||
import {
|
||||
createCalendar,
|
||||
deleteCalendar,
|
||||
parseCalendarBlob,
|
||||
queryEvents,
|
||||
getEvents,
|
||||
deleteEvents,
|
||||
} from '../api/calendar';
|
||||
import { uploadBytes } from '../api/blob';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
|
||||
export interface CalendarSubscription {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
color?: string;
|
||||
/** The local Stalwart calendar that mirrors this remote feed. */
|
||||
calendarId: string;
|
||||
lastSyncAt: number | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
interface SubscriptionsState {
|
||||
subscriptions: CalendarSubscription[];
|
||||
syncing: Record<string, boolean>;
|
||||
|
||||
addSubscription: (input: { name: string; url: string; color?: string }) => Promise<CalendarSubscription>;
|
||||
removeSubscription: (id: string) => Promise<void>;
|
||||
syncSubscription: (id: string) => Promise<void>;
|
||||
syncAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
// webcal:// is just iCalendar over HTTP(S) — browsers/clients map it to https.
|
||||
function normalizeFeedUrl(url: string): string {
|
||||
return url.trim().replace(/^webcal:\/\//i, 'https://');
|
||||
}
|
||||
|
||||
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
|
||||
const res = await fetch(normalizeFeedUrl(url));
|
||||
if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`);
|
||||
const text = await res.text();
|
||||
if (!/BEGIN:VCALENDAR/i.test(text)) {
|
||||
throw new Error('That URL did not return an iCalendar feed');
|
||||
}
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const { blobId } = await uploadBytes(bytes, 'text/calendar');
|
||||
const events = await parseCalendarBlob(blobId);
|
||||
return events.filter((e) => !!e.start);
|
||||
}
|
||||
|
||||
// Reconcile the local calendar with the remote feed: delete events whose UID
|
||||
// disappeared upstream, then import the current set (importEvents dedupes by
|
||||
// UID, so unchanged events are left in place).
|
||||
async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> {
|
||||
const parsed = await fetchAndParseFeed(url);
|
||||
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
|
||||
try {
|
||||
const allIds = await queryEvents([], '', '');
|
||||
const all = allIds.length > 0 ? await getEvents(allIds) : [];
|
||||
const stale = all
|
||||
.filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid))
|
||||
.map((e) => e.id);
|
||||
if (stale.length > 0) await deleteEvents(stale);
|
||||
} catch {
|
||||
// Non-fatal: if we can't enumerate existing events, still import new ones.
|
||||
}
|
||||
|
||||
await useCalendarStore.getState().importEvents(parsed, calendarId);
|
||||
}
|
||||
|
||||
export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
subscriptions: [],
|
||||
syncing: {},
|
||||
|
||||
addSubscription: async ({ name, url, color }) => {
|
||||
// Create a dedicated local calendar to hold the feed's events.
|
||||
const calendar = await createCalendar(name, color);
|
||||
const sub: CalendarSubscription = {
|
||||
id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name,
|
||||
url: url.trim(),
|
||||
color,
|
||||
calendarId: calendar.id,
|
||||
lastSyncAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
set({ subscriptions: [...get().subscriptions, sub] });
|
||||
// Make the new calendar visible immediately.
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
try {
|
||||
set({ syncing: { ...get().syncing, [sub.id]: true } });
|
||||
await syncFeedIntoCalendar(sub.calendarId, sub.url);
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed';
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === sub.id ? { ...s, lastError: message } : s,
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
set({ syncing: { ...get().syncing, [sub.id]: false } });
|
||||
}
|
||||
return sub;
|
||||
},
|
||||
|
||||
removeSubscription: async (id) => {
|
||||
const sub = get().subscriptions.find((s) => s.id === id);
|
||||
set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
|
||||
if (sub) {
|
||||
try {
|
||||
await deleteCalendar(sub.calendarId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
await useCalendarStore.getState().refresh();
|
||||
}
|
||||
},
|
||||
|
||||
syncSubscription: async (id) => {
|
||||
const sub = get().subscriptions.find((s) => s.id === id);
|
||||
if (!sub) return;
|
||||
set({ syncing: { ...get().syncing, [id]: true } });
|
||||
try {
|
||||
await syncFeedIntoCalendar(sub.calendarId, sub.url);
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed';
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === id ? { ...s, lastError: message } : s,
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
set({ syncing: { ...get().syncing, [id]: false } });
|
||||
}
|
||||
},
|
||||
|
||||
syncAll: async () => {
|
||||
const subs = get().subscriptions;
|
||||
for (const sub of subs) {
|
||||
await get().syncSubscription(sub.id);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'calendar-subscriptions',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({ subscriptions: state.subscriptions }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
createContact as apiCreateContact,
|
||||
updateContact as apiUpdateContact,
|
||||
deleteContacts as apiDeleteContacts,
|
||||
createAddressBook as apiCreateAddressBook,
|
||||
updateAddressBook as apiUpdateAddressBook,
|
||||
deleteAddressBook as apiDeleteAddressBook,
|
||||
getContactsInBook,
|
||||
} from '../api/contacts';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import {
|
||||
@@ -20,6 +24,10 @@ import {
|
||||
|
||||
const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1';
|
||||
|
||||
// Dedicated JMAP address book that backs the "trusted senders" allow-list.
|
||||
// Mirrors the webmail so the same book is shared across clients.
|
||||
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
|
||||
|
||||
export type ContactCategory =
|
||||
| { type: 'all' }
|
||||
| { type: 'addressBook'; addressBookId: string }
|
||||
@@ -35,6 +43,13 @@ export interface ContactsState {
|
||||
error: string | null;
|
||||
hydrated: boolean;
|
||||
|
||||
// Trusted senders are stored as contacts in a dedicated JMAP address book so
|
||||
// the allow-list syncs across devices (matches the webmail behavior).
|
||||
trustedSendersBookId: string | null;
|
||||
trustedSenderEmails: string[];
|
||||
trustedSendersLoaded: boolean;
|
||||
trustedSendersLoading: boolean;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
fetchAddressBooks: () => Promise<void>;
|
||||
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
|
||||
@@ -45,11 +60,25 @@ export interface ContactsState {
|
||||
updateContact: (id: string, changes: Partial<ContactCard>) => Promise<void>;
|
||||
deleteContact: (id: string) => Promise<void>;
|
||||
bulkDelete: (ids: string[]) => Promise<void>;
|
||||
importContacts: (contacts: Partial<ContactCard>[], addressBookId: string) => Promise<number>;
|
||||
|
||||
addContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
|
||||
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
|
||||
|
||||
// Address book management
|
||||
createAddressBook: (name: string) => Promise<AddressBook>;
|
||||
renameAddressBook: (id: string, name: string) => Promise<void>;
|
||||
deleteAddressBook: (id: string) => Promise<void>;
|
||||
|
||||
// Trusted senders address book. Passive loads (createIfMissing=false) only
|
||||
// read an existing book; the book is created lazily on the first add.
|
||||
loadTrustedSendersBook: (createIfMissing?: boolean) => Promise<void>;
|
||||
addToTrustedSendersBook: (email: string) => Promise<void>;
|
||||
removeFromTrustedSendersBook: (email: string) => Promise<void>;
|
||||
isTrustedAddressBookSender: (email: string) => boolean;
|
||||
|
||||
setSelectedCategory: (category: ContactCategory) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -70,6 +99,11 @@ export const useContactsStore = create<ContactsState>()(
|
||||
error: null,
|
||||
hydrated: false,
|
||||
|
||||
trustedSendersBookId: null,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
@@ -87,6 +121,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
fetchAddressBooks: async () => {
|
||||
// No-op until a JMAP session exists. ContactsScreen fires this from a
|
||||
// mount-time useEffect, which on cold start runs before restoreSession
|
||||
// has set up the client.
|
||||
if (!jmapClient.isConnected) return;
|
||||
try {
|
||||
const addressBooks = (await fetchAddressBooks()) ?? [];
|
||||
set({ addressBooks });
|
||||
@@ -96,6 +134,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
fetchContacts: async (filter) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const ids = (await queryContacts(filter)) ?? [];
|
||||
@@ -111,6 +150,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
@@ -154,6 +194,22 @@ export const useContactsStore = create<ContactsState>()(
|
||||
set({ contacts: get().contacts.filter((c) => !idSet.has(c.id)) });
|
||||
},
|
||||
|
||||
importContacts: async (incoming, addressBookId) => {
|
||||
let imported = 0;
|
||||
for (const contact of incoming) {
|
||||
try {
|
||||
// Strip the temporary client-side id; the server assigns a real one.
|
||||
const { id: _id, addressBookIds: _abIds, ...data } = contact;
|
||||
const created = await apiCreateContact(data, addressBookId);
|
||||
set({ contacts: [...get().contacts, created] });
|
||||
imported++;
|
||||
} catch (err) {
|
||||
console.warn('[contacts-store] import failed for one contact', err);
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
},
|
||||
|
||||
addContactKeyword: async (id, keyword) => {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) return;
|
||||
@@ -170,12 +226,115 @@ export const useContactsStore = create<ContactsState>()(
|
||||
await get().updateContact(id, { keywords: rest });
|
||||
},
|
||||
|
||||
addKeywordToContacts: async (ids, keyword) => {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) return;
|
||||
for (const id of ids) {
|
||||
await get().addContactKeyword(id, kw);
|
||||
}
|
||||
},
|
||||
|
||||
moveContactsToAddressBook: async (ids, addressBookId) => {
|
||||
for (const id of ids) {
|
||||
await get().updateContact(id, { addressBookIds: { [addressBookId]: true } });
|
||||
}
|
||||
},
|
||||
|
||||
createAddressBook: async (name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error('Address book name is required');
|
||||
const book = await apiCreateAddressBook(trimmed);
|
||||
set({ addressBooks: [...get().addressBooks, book] });
|
||||
return book;
|
||||
},
|
||||
|
||||
renameAddressBook: async (id, name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
await apiUpdateAddressBook(id, { name: trimmed });
|
||||
set({
|
||||
addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
|
||||
});
|
||||
},
|
||||
|
||||
deleteAddressBook: async (id) => {
|
||||
await apiDeleteAddressBook(id);
|
||||
set({
|
||||
addressBooks: get().addressBooks.filter((b) => b.id !== id),
|
||||
// Drop the deleted book id from any cached contact so the UI doesn't
|
||||
// keep filtering against a book that no longer exists.
|
||||
contacts: get().contacts.map((c) => {
|
||||
if (!c.addressBookIds?.[id]) return c;
|
||||
const { [id]: _gone, ...rest } = c.addressBookIds;
|
||||
return { ...c, addressBookIds: rest };
|
||||
}),
|
||||
});
|
||||
const category = get().selectedCategory;
|
||||
if (category.type === 'addressBook' && category.addressBookId === id) {
|
||||
get().setSelectedCategory({ type: 'all' });
|
||||
}
|
||||
},
|
||||
|
||||
loadTrustedSendersBook: async (createIfMissing = false) => {
|
||||
if (!jmapClient.isConnected || get().trustedSendersLoading) return;
|
||||
set({ trustedSendersLoading: true });
|
||||
try {
|
||||
const books = (await fetchAddressBooks()) ?? [];
|
||||
let book = books.find((b) => b.name === TRUSTED_SENDERS_BOOK_NAME);
|
||||
if (!book) {
|
||||
if (!createIfMissing) {
|
||||
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
return;
|
||||
}
|
||||
book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
|
||||
}
|
||||
const bookId = book.id;
|
||||
const cards = await getContactsInBook(bookId);
|
||||
const emails = cards.flatMap((c) =>
|
||||
c.emails ? Object.values(c.emails).map((e) => e.address.toLowerCase().trim()) : [],
|
||||
).filter(Boolean);
|
||||
set({
|
||||
trustedSendersBookId: bookId,
|
||||
trustedSenderEmails: emails,
|
||||
trustedSendersLoaded: true,
|
||||
trustedSendersLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[contacts-store] load trusted senders failed', err);
|
||||
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addToTrustedSendersBook: async (email) => {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized || get().trustedSenderEmails.includes(normalized)) return;
|
||||
|
||||
let bookId = get().trustedSendersBookId;
|
||||
if (!bookId) {
|
||||
await get().loadTrustedSendersBook(true);
|
||||
bookId = get().trustedSendersBookId;
|
||||
}
|
||||
if (!bookId) throw new Error('Could not find or create the trusted senders address book');
|
||||
|
||||
await apiCreateContact({ emails: { email: { address: normalized } } }, bookId);
|
||||
set({ trustedSenderEmails: [...get().trustedSenderEmails, normalized] });
|
||||
},
|
||||
|
||||
removeFromTrustedSendersBook: async (email) => {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
const bookId = get().trustedSendersBookId;
|
||||
if (!bookId) return;
|
||||
const cards = await getContactsInBook(bookId);
|
||||
const match = cards.find((c) =>
|
||||
c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase().trim() === normalized),
|
||||
);
|
||||
if (match) await apiDeleteContacts([match.id]);
|
||||
set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== normalized) });
|
||||
},
|
||||
|
||||
isTrustedAddressBookSender: (email) =>
|
||||
get().trustedSenderEmails.includes(email.toLowerCase().trim()),
|
||||
|
||||
setSelectedCategory: (category) => {
|
||||
set({ selectedCategory: category });
|
||||
persistCategory(category);
|
||||
@@ -187,6 +346,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
selectedCategory: { type: 'all' },
|
||||
loading: false,
|
||||
error: null,
|
||||
trustedSendersBookId: null,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
|
||||
+786
-37
@@ -1,21 +1,50 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { Email, Mailbox, StateChange } from '../api/types';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import {
|
||||
getMailboxes as fetchMailboxes,
|
||||
getMailboxesWithState,
|
||||
getMailboxesByIds,
|
||||
getMailboxChanges,
|
||||
queryEmails,
|
||||
getEmailQueryChanges,
|
||||
getEmails as fetchEmails,
|
||||
getEmailsWithState,
|
||||
getEmailChanges,
|
||||
getFullEmail,
|
||||
setEmailKeywords,
|
||||
setKeywordsForEmails,
|
||||
moveEmail,
|
||||
moveEmails as apiMoveEmails,
|
||||
archiveEmails as apiArchiveEmails,
|
||||
deleteEmail as apiDeleteEmail,
|
||||
deleteEmails as apiDeleteEmails,
|
||||
restoreEmailMailboxes,
|
||||
searchEmails as apiSearchEmails,
|
||||
} from '../api/email';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { useSettingsStore } from './settings-store';
|
||||
import { useOfflineCacheStore } from './offline-cache-store';
|
||||
|
||||
// True only when the JMAP client is actually serving the email-store's active
|
||||
// account. During an account switch there's a window between
|
||||
// setActiveAccount() (which swaps the email-store view immediately) and
|
||||
// jmapClient.loadAccount() resolving, when the client is still on the
|
||||
// *previous* account. Without this guard, any fetchMailboxes/refreshEmails
|
||||
// fired in that window (e.g. by an EmailListScreen useEffect reacting to
|
||||
// the empty new-account view) would return the previous account's data and
|
||||
// stamp it into the new account's snapshot.
|
||||
function jmapClientServesActiveAccount(activeAccountId: string | null): boolean {
|
||||
if (!activeAccountId) return false;
|
||||
if (!jmapClient.isConnected) return false;
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (!username || !serverUrl) return false;
|
||||
return generateAccountId(username, serverUrl) === activeAccountId;
|
||||
}
|
||||
|
||||
export interface EmailFilters {
|
||||
from?: string;
|
||||
@@ -41,21 +70,61 @@ export interface UndoEntry {
|
||||
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
|
||||
}
|
||||
|
||||
export interface EmailState {
|
||||
// Cached emails for one mailbox (the base view: no search query, no filters).
|
||||
// `queryState` is the JMAP queryState for the matching Email/query, used to
|
||||
// drive Email/queryChanges on the next refresh.
|
||||
interface MailboxSnapshot {
|
||||
emails: Email[];
|
||||
total: number;
|
||||
queryState?: string;
|
||||
}
|
||||
|
||||
// Everything we cache for one account so switching accounts can restore the
|
||||
// previous view instantly instead of going through a network round-trip.
|
||||
interface AccountSnapshot {
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes)
|
||||
emailState?: string; // JMAP Email state (drives Email/changes)
|
||||
currentMailboxId: string | null;
|
||||
mailboxSnapshots: Record<string, MailboxSnapshot>;
|
||||
}
|
||||
|
||||
export interface EmailState {
|
||||
// ── Per-account persisted caches ──────────────────────────────
|
||||
// accountSnapshots is the source of truth for accounts the user is *not*
|
||||
// currently viewing. The active account's data lives in the top-level
|
||||
// fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailState`,
|
||||
// `currentMailboxId`, `emails`, `totalEmails`, `queryState`) so consumers
|
||||
// keep reading the same shape they always have.
|
||||
accountSnapshots: Record<string, AccountSnapshot>;
|
||||
activeAccountId: string | null;
|
||||
|
||||
// ── Active view (the currently-shown account/mailbox) ─────────
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string;
|
||||
emailState?: string;
|
||||
currentMailboxId: string | null;
|
||||
mailboxSnapshots: Record<string, MailboxSnapshot>;
|
||||
emails: Email[];
|
||||
totalEmails: number;
|
||||
queryState?: string; // queryState for the currently-shown mailbox
|
||||
|
||||
// ── UI state (not persisted, not per-account) ─────────────────
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
filters: EmailFilters;
|
||||
pendingUndo: UndoEntry | null;
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────
|
||||
setActiveAccount: (accountId: string | null) => void;
|
||||
removeAccount: (accountId: string) => void;
|
||||
clearAllAccounts: () => void;
|
||||
fetchMailboxes: () => Promise<void>;
|
||||
selectMailbox: (mailboxId: string) => Promise<void>;
|
||||
loadMoreEmails: () => Promise<void>;
|
||||
refreshEmails: () => Promise<void>;
|
||||
handleStateChange: (change: StateChange) => Promise<void>;
|
||||
getEmailDetail: (id: string) => Promise<Email>;
|
||||
markRead: (emailId: string) => Promise<void>;
|
||||
markUnread: (emailId: string) => Promise<void>;
|
||||
@@ -64,6 +133,11 @@ export interface EmailState {
|
||||
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
|
||||
archiveEmail: (emailId: string) => Promise<void>;
|
||||
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
|
||||
// ── Batch (multi-select) actions ──────────────────────────────
|
||||
archiveEmailsBatch: (emailIds: string[]) => Promise<void>;
|
||||
moveEmailsToMailbox: (emailIds: string[], toMailboxId: string) => Promise<void>;
|
||||
deleteEmailsBatch: (emailIds: string[], trashMailboxId: string, currentMailboxId: string) => Promise<void>;
|
||||
setKeywordForEmails: (emailIds: string[], token: string, on: boolean) => Promise<void>;
|
||||
undoLast: () => Promise<void>;
|
||||
clearUndo: () => void;
|
||||
searchEmails: (query: string) => Promise<Email[]>;
|
||||
@@ -112,87 +186,584 @@ function buildJmapFilter(
|
||||
return { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
// True when the user has no search/filters active. Only in this case do we
|
||||
// touch the per-mailbox snapshot cache or use Email/queryChanges — once a
|
||||
// filter is in play, the queryState belongs to a different query and the
|
||||
// cached list no longer represents what's on screen.
|
||||
function isBaseView(searchQuery: string, filters: EmailFilters): boolean {
|
||||
return !searchQuery.trim() && Object.keys(filters).length === 0;
|
||||
}
|
||||
|
||||
function snapshotFromActive(state: EmailState): AccountSnapshot {
|
||||
// Persist the currently-visible mailbox into its snapshot before tucking
|
||||
// the whole account away.
|
||||
let mailboxSnapshots = state.mailboxSnapshots;
|
||||
if (state.currentMailboxId && isBaseView(state.searchQuery, state.filters)) {
|
||||
mailboxSnapshots = {
|
||||
...mailboxSnapshots,
|
||||
[state.currentMailboxId]: {
|
||||
emails: state.emails,
|
||||
total: state.totalEmails,
|
||||
queryState: state.queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mailboxes: state.mailboxes,
|
||||
mailboxState: state.mailboxState,
|
||||
emailState: state.emailState,
|
||||
currentMailboxId: state.currentMailboxId,
|
||||
mailboxSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
function viewFromSnapshot(snap: AccountSnapshot | null): {
|
||||
mailboxes: Mailbox[];
|
||||
mailboxState?: string;
|
||||
emailState?: string;
|
||||
currentMailboxId: string | null;
|
||||
mailboxSnapshots: Record<string, MailboxSnapshot>;
|
||||
emails: Email[];
|
||||
totalEmails: number;
|
||||
queryState?: string;
|
||||
} {
|
||||
if (!snap) {
|
||||
return {
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
};
|
||||
}
|
||||
const mailboxSnap = snap.currentMailboxId
|
||||
? snap.mailboxSnapshots[snap.currentMailboxId]
|
||||
: undefined;
|
||||
return {
|
||||
mailboxes: snap.mailboxes,
|
||||
mailboxState: snap.mailboxState,
|
||||
emailState: snap.emailState,
|
||||
currentMailboxId: snap.currentMailboxId,
|
||||
mailboxSnapshots: snap.mailboxSnapshots,
|
||||
emails: mailboxSnap?.emails ?? [],
|
||||
totalEmails: mailboxSnap?.total ?? 0,
|
||||
queryState: mailboxSnap?.queryState,
|
||||
};
|
||||
}
|
||||
|
||||
// JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get.
|
||||
// Chunk to that ceiling (with a small safety fallback) so large change sets
|
||||
// don't trip 429/413 responses.
|
||||
async function fetchEmailsChunked(ids: string[]): Promise<Email[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const cap = Math.max(1, jmapClient.getMaxObjectsInGet());
|
||||
const chunk = Math.min(cap, 200);
|
||||
if (ids.length <= chunk) return fetchEmails(ids);
|
||||
const out: Email[] = [];
|
||||
for (let i = 0; i < ids.length; i += chunk) {
|
||||
const slice = await fetchEmails(ids.slice(i, i + chunk));
|
||||
out.push(...slice);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Merge a fresh batch of emails into an existing list keyed by id. New entries
|
||||
// replace stale ones (keywords/mailboxIds may have changed); destroyed ids are
|
||||
// dropped. Order is preserved according to the supplied id order — pass the
|
||||
// authoritative id list from Email/query when re-syncing.
|
||||
function applyEmailDiff(
|
||||
current: Email[],
|
||||
orderedIds: string[],
|
||||
fetched: Email[],
|
||||
destroyed: Set<string>,
|
||||
): Email[] {
|
||||
const byId = new Map<string, Email>();
|
||||
for (const e of current) byId.set(e.id, e);
|
||||
for (const e of fetched) byId.set(e.id, e);
|
||||
const out: Email[] = [];
|
||||
for (const id of orderedIds) {
|
||||
if (destroyed.has(id)) continue;
|
||||
const e = byId.get(id);
|
||||
if (e) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const useEmailStore = create<EmailState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accountSnapshots: {},
|
||||
activeAccountId: null,
|
||||
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
|
||||
loading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
|
||||
// Swap which account's data is currently visible. The previous account's
|
||||
// view is tucked into accountSnapshots so a return-trip can restore it
|
||||
// without a network call; the new account's view is pulled from its
|
||||
// snapshot (or empty defaults if we've never seen it). Callers (auth-store)
|
||||
// run the network refresh afterwards.
|
||||
setActiveAccount: (accountId) => {
|
||||
const state = get();
|
||||
if (state.activeAccountId === accountId) return;
|
||||
|
||||
const nextSnapshots = { ...state.accountSnapshots };
|
||||
if (state.activeAccountId) {
|
||||
nextSnapshots[state.activeAccountId] = snapshotFromActive(state);
|
||||
}
|
||||
const incoming = accountId ? nextSnapshots[accountId] ?? null : null;
|
||||
const view = viewFromSnapshot(incoming);
|
||||
|
||||
set({
|
||||
accountSnapshots: nextSnapshots,
|
||||
activeAccountId: accountId,
|
||||
...view,
|
||||
// UI state is reset on switch — search/filters and pending undo belong
|
||||
// to the previous account's intent.
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// Point the offline body cache at the same account so getEmailDetail's
|
||||
// fallback and selectMailbox's seed read from the right bucket. Fire-
|
||||
// and-forget — the cache returns empty until hydration completes,
|
||||
// which is the correct degraded behaviour.
|
||||
void useOfflineCacheStore.getState().setAccount(accountId);
|
||||
},
|
||||
|
||||
removeAccount: (accountId) => {
|
||||
const state = get();
|
||||
const { [accountId]: _drop, ...rest } = state.accountSnapshots;
|
||||
if (state.activeAccountId === accountId) {
|
||||
set({
|
||||
accountSnapshots: rest,
|
||||
activeAccountId: null,
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(null);
|
||||
} else {
|
||||
set({ accountSnapshots: rest });
|
||||
}
|
||||
},
|
||||
|
||||
clearAllAccounts: () => {
|
||||
set({
|
||||
accountSnapshots: {},
|
||||
activeAccountId: null,
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
void useOfflineCacheStore.getState().setAccount(null);
|
||||
},
|
||||
|
||||
fetchMailboxes: async () => {
|
||||
// Skip silently when there's no live session, or when jmapClient is
|
||||
// mid-transition to a different account (see jmapClientServesActiveAccount).
|
||||
// Screens fire this from mount-time useEffects, and on cold start
|
||||
// App.tsx renders MainTabs before restoreSession() finishes; without
|
||||
// this guard the underlying API call would either throw "Not
|
||||
// authenticated - call connect() first" or — worse, during an account
|
||||
// switch — return the *previous* account's mailboxes and stamp them
|
||||
// into the new account's snapshot.
|
||||
const activeAccountId = get().activeAccountId;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
const prevState = get().mailboxState;
|
||||
try {
|
||||
const mailboxes = await fetchMailboxes();
|
||||
set({ mailboxes });
|
||||
// Incremental path: ask for just what changed since last time. Fall
|
||||
// through to a full refetch when the server can't compute the diff or
|
||||
// we have no previous state to compare against.
|
||||
if (prevState) {
|
||||
const changes = await getMailboxChanges(prevState);
|
||||
// Bail if the user switched accounts during the await — anything we
|
||||
// set() now would land in the wrong account's bucket.
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
if (changes) {
|
||||
// No changes at all — keep the cached list, just bump the state.
|
||||
if (
|
||||
changes.created.length === 0 &&
|
||||
changes.updated.length === 0 &&
|
||||
changes.destroyed.length === 0
|
||||
) {
|
||||
set({ mailboxState: changes.newState });
|
||||
return;
|
||||
}
|
||||
const toFetch = [...changes.created, ...changes.updated];
|
||||
const fetched = toFetch.length > 0
|
||||
? (await getMailboxesByIds(toFetch)).list
|
||||
: [];
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
const destroyed = new Set(changes.destroyed);
|
||||
const byId = new Map<string, Mailbox>();
|
||||
for (const m of get().mailboxes) byId.set(m.id, m);
|
||||
for (const m of fetched) byId.set(m.id, m);
|
||||
for (const id of destroyed) byId.delete(id);
|
||||
set({
|
||||
mailboxes: Array.from(byId.values()),
|
||||
mailboxState: changes.hasMoreChanges ? prevState : changes.newState,
|
||||
});
|
||||
// hasMoreChanges = there are still pending changes past the
|
||||
// server's response cap. Run the same path again to drain.
|
||||
if (changes.hasMoreChanges) {
|
||||
void get().fetchMailboxes();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// changes === null → cannotCalculateChanges. Fall through to full.
|
||||
}
|
||||
|
||||
const { list, state } = await getMailboxesWithState();
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ mailboxes: list, mailboxState: state });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] fetchMailboxes failed:', err);
|
||||
set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' });
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
// Don't overwrite the cached list on a transient failure — the user
|
||||
// can still navigate folders. Only surface the error when we have no
|
||||
// mailboxes at all to show.
|
||||
if (get().mailboxes.length === 0) {
|
||||
set({ error: err instanceof Error ? err.message : 'Failed to load mailboxes' });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
selectMailbox: async (mailboxId) => {
|
||||
// Reset search/filters when switching mailbox - matches webmail behavior.
|
||||
// Switching mailboxes invalidates any pending undo: the snackbar would be
|
||||
// stale and the restored email would re-appear in a different view.
|
||||
const state = get();
|
||||
// Tuck the previously-visible mailbox into its snapshot so a return-trip
|
||||
// can restore it without a network call. Only do this for the base view —
|
||||
// a filter or search makes the visible list unrepresentative of the
|
||||
// cached "no-filter" snapshot.
|
||||
let mailboxSnapshots = state.mailboxSnapshots;
|
||||
if (
|
||||
state.currentMailboxId &&
|
||||
state.currentMailboxId !== mailboxId &&
|
||||
isBaseView(state.searchQuery, state.filters)
|
||||
) {
|
||||
mailboxSnapshots = {
|
||||
...mailboxSnapshots,
|
||||
[state.currentMailboxId]: {
|
||||
emails: state.emails,
|
||||
total: state.totalEmails,
|
||||
queryState: state.queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const incoming = mailboxSnapshots[mailboxId];
|
||||
// Swap to the new mailbox's cached view immediately. If there's no
|
||||
// snapshot, fall through to the offline cache as a second-best seed;
|
||||
// if that's also empty we render the empty-state, not a spinner over
|
||||
// a blank list — better than the previous flash to "Loading…".
|
||||
let seededEmails: Email[] = incoming?.emails ?? [];
|
||||
let seededTotal = incoming?.total ?? 0;
|
||||
let seededQueryState = incoming?.queryState;
|
||||
|
||||
if (seededEmails.length === 0) {
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
try {
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
seededEmails = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
|
||||
seededTotal = seededEmails.length;
|
||||
} catch (err) {
|
||||
console.warn('[email-store] cache seed failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
currentMailboxId: mailboxId,
|
||||
emails: seededEmails,
|
||||
totalEmails: seededTotal,
|
||||
queryState: seededQueryState,
|
||||
mailboxSnapshots,
|
||||
loading: true,
|
||||
error: null,
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
try {
|
||||
const filter = buildJmapFilter(mailboxId, '', {});
|
||||
const { ids, total } = await queryEmails(mailboxId, { limit: 50, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] selectMailbox failed:', err);
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
|
||||
|
||||
// Stop here if there's no live session OR jmapClient is mid-transition
|
||||
// to a different account. The cached seed already gave the user
|
||||
// something to look at, and the refetch driven by restoreSession() /
|
||||
// switchAccount will run the network half once the client catches up.
|
||||
if (!jmapClientServesActiveAccount(get().activeAccountId)) {
|
||||
set({ loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await get().refreshEmails();
|
||||
},
|
||||
|
||||
loadMoreEmails: async () => {
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
|
||||
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters, activeAccountId } = get();
|
||||
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
|
||||
set({ loading: true });
|
||||
try {
|
||||
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) : [];
|
||||
set({ emails: [...emails, ...newEmails], loading: false });
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : [];
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
const merged = [...emails, ...newEmails];
|
||||
const updates: Partial<EmailState> = { emails: merged, loading: false };
|
||||
if (isBaseView(searchQuery, filters)) {
|
||||
updates.mailboxSnapshots = {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: merged,
|
||||
total: get().totalEmails,
|
||||
queryState: get().queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
set(updates);
|
||||
} catch (err) {
|
||||
if (get().activeAccountId !== activeAccountId) return;
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
|
||||
}
|
||||
},
|
||||
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters } = get();
|
||||
const state = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
|
||||
if (!currentMailboxId) return;
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
set({ loading: true, error: null });
|
||||
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const baseView = isBaseView(searchQuery, filters);
|
||||
|
||||
try {
|
||||
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
|
||||
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
// Incremental sync path: requires the base unfiltered view AND a known
|
||||
// queryState (so Email/queryChanges has something to diff against).
|
||||
// Anything else — search, filter active, first-ever load — falls
|
||||
// through to a full re-query.
|
||||
if (baseView && queryState) {
|
||||
const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, {
|
||||
filter: undefined,
|
||||
});
|
||||
if (queryChanges) {
|
||||
// What's in the visible window now: drop removed ids, then apply
|
||||
// added (id, index) entries. Newly added ids need bodies fetched.
|
||||
const removed = new Set(queryChanges.removed);
|
||||
const addedIds = queryChanges.added.map((a) => a.id);
|
||||
|
||||
// Email/changes catches updates to messages already in our list
|
||||
// (e.g. another device toggled $seen) that queryChanges wouldn't
|
||||
// report. Skipped when we have no emailState yet — first refresh
|
||||
// after a cold start primes it from the Email/get below.
|
||||
let updatedIds: string[] = [];
|
||||
let destroyedExtra: string[] = [];
|
||||
let nextEmailState: string | undefined = emailState;
|
||||
if (emailState) {
|
||||
const ec = await getEmailChanges(emailState);
|
||||
if (ec) {
|
||||
updatedIds = ec.updated;
|
||||
destroyedExtra = ec.destroyed;
|
||||
nextEmailState = ec.newState;
|
||||
} else {
|
||||
// cannotCalculateChanges → forget the state and rely on the
|
||||
// next full re-sync to repopulate it.
|
||||
nextEmailState = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch only what we don't already have. `addedIds` are new to the
|
||||
// window; `updatedIds` may already be in `existing` but their
|
||||
// keywords/mailboxIds need refreshing.
|
||||
const existingById = new Map(existing.map((e) => [e.id, e]));
|
||||
const idsToFetch = [
|
||||
...addedIds.filter((id) => !existingById.has(id)),
|
||||
...updatedIds.filter((id) => existingById.has(id)),
|
||||
];
|
||||
let fetchState: string | undefined;
|
||||
let fetched: Email[] = [];
|
||||
if (idsToFetch.length > 0) {
|
||||
const res = await getEmailsWithState(idsToFetch);
|
||||
fetched = res.list;
|
||||
fetchState = res.state;
|
||||
}
|
||||
|
||||
// Rebuild the visible window order: start with existing emails,
|
||||
// drop removed/destroyed, then splice added at their indices.
|
||||
const allDestroyed = new Set([...destroyedExtra, ...removed]);
|
||||
const kept = existing.filter((e) => !allDestroyed.has(e.id));
|
||||
// Map updated entries onto kept array
|
||||
const fetchedById = new Map(fetched.map((e) => [e.id, e]));
|
||||
const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e);
|
||||
|
||||
// Insert added entries at the indices the server gave us. Sort
|
||||
// ascending by index so each splice lands at the right offset.
|
||||
const sortedAdded = [...queryChanges.added].sort((a, b) => a.index - b.index);
|
||||
const out = [...updatedKept];
|
||||
for (const entry of sortedAdded) {
|
||||
const email = fetchedById.get(entry.id);
|
||||
if (!email) continue;
|
||||
const idx = Math.min(entry.index, out.length);
|
||||
out.splice(idx, 0, email);
|
||||
}
|
||||
// Cap the visible list to the user's page size — Email/queryChanges
|
||||
// can push entries past the original window if many were added.
|
||||
const trimmed = out.slice(0, Math.max(limit, out.length));
|
||||
|
||||
const nextQueryState = queryChanges.newQueryState;
|
||||
const nextTotal = queryChanges.total;
|
||||
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
|
||||
set({
|
||||
emails: trimmed,
|
||||
totalEmails: nextTotal,
|
||||
queryState: nextQueryState,
|
||||
emailState: nextEmailState ?? fetchState ?? emailState,
|
||||
loading: false,
|
||||
mailboxSnapshots: {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: trimmed,
|
||||
total: nextTotal,
|
||||
queryState: nextQueryState,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// queryChanges === null → cannotCalculateChanges. Drop our queryState
|
||||
// and fall through to a full re-query, which will repopulate it.
|
||||
}
|
||||
|
||||
// Full re-query path. Used when there's no prior queryState, when the
|
||||
// user has search/filters active (queryState only tracks the base
|
||||
// query), or when the server returned cannotCalculateChanges above.
|
||||
const queryRes = await queryEmails(currentMailboxId, { limit, filter });
|
||||
const fetched = queryRes.ids.length > 0
|
||||
? await getEmailsWithState(queryRes.ids)
|
||||
: { list: [], state: undefined as string | undefined };
|
||||
|
||||
if (get().currentMailboxId !== currentMailboxId) return;
|
||||
|
||||
const updates: Partial<EmailState> = {
|
||||
emails: fetched.list,
|
||||
totalEmails: queryRes.total,
|
||||
loading: false,
|
||||
};
|
||||
if (baseView) {
|
||||
updates.queryState = queryRes.queryState;
|
||||
updates.emailState = fetched.state;
|
||||
updates.mailboxSnapshots = {
|
||||
...get().mailboxSnapshots,
|
||||
[currentMailboxId]: {
|
||||
emails: fetched.list,
|
||||
total: queryRes.total,
|
||||
queryState: queryRes.queryState,
|
||||
},
|
||||
};
|
||||
}
|
||||
set(updates);
|
||||
} catch (err) {
|
||||
console.warn('[email-store] refreshEmails failed:', err);
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
// Keep whatever's visible; only surface the error when the list is
|
||||
// empty. With cached emails on screen the OfflineBanner already
|
||||
// tells the user the data is stale.
|
||||
if (existing.length === 0) {
|
||||
try {
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
const cached = await cacheStore.getEmailsInMailbox(
|
||||
currentMailboxId,
|
||||
Math.max(limit, 50),
|
||||
);
|
||||
if (
|
||||
get().activeAccountId === activeAccountId &&
|
||||
get().currentMailboxId === currentMailboxId &&
|
||||
cached.length > 0
|
||||
) {
|
||||
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (cacheErr) {
|
||||
console.warn('[email-store] refresh cache fallback failed:', cacheErr);
|
||||
}
|
||||
}
|
||||
set({
|
||||
loading: false,
|
||||
error: existing.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.currentSession) return;
|
||||
// Drop changes that arrived for a different account than the one we're
|
||||
// currently showing (e.g. push notifications received during/just after
|
||||
// an account switch).
|
||||
if (!jmapClientServesActiveAccount(get().activeAccountId)) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
const mailboxChanged = 'Mailbox' in accountChanges;
|
||||
const emailChanged = 'Email' in accountChanges || 'EmailDelivery' in accountChanges;
|
||||
if (!mailboxChanged && !emailChanged) return;
|
||||
|
||||
if (mailboxChanged) {
|
||||
await get().fetchMailboxes();
|
||||
}
|
||||
if (emailChanged && get().currentMailboxId) {
|
||||
await get().refreshEmails();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -214,7 +785,27 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
getEmailDetail: async (id) => {
|
||||
return getFullEmail(id);
|
||||
// Try the network first so the user sees fresh keywords/flags. If that
|
||||
// fails (offline / server unreachable), fall back to the offline cache
|
||||
// when the message is in it. Without the cache hit, propagate the error
|
||||
// so the caller can surface it.
|
||||
try {
|
||||
const fresh = await getFullEmail(id);
|
||||
// Opportunistically refresh the cached copy so the next offline open
|
||||
// reflects the latest keywords without needing a full sync.
|
||||
const cache = useOfflineCacheStore.getState();
|
||||
if (cache.has(id)) {
|
||||
try {
|
||||
const size = JSON.stringify(fresh).length;
|
||||
await cache.put(fresh, size);
|
||||
} catch { /* ignore — best-effort refresh */ }
|
||||
}
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
const cached = await useOfflineCacheStore.getState().get(id);
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
markRead: async (emailId) => {
|
||||
@@ -335,14 +926,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',
|
||||
@@ -354,6 +965,133 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// ── Batch actions ─────────────────────────────────────────────
|
||||
// Each produces a single combined UndoEntry (UndoEntry.items is an array),
|
||||
// so a multi-select archive/move/delete is reversed with one snackbar tap.
|
||||
|
||||
archiveEmailsBatch: async (emailIds) => {
|
||||
const { emails, mailboxes } = get();
|
||||
const archiveMailbox = mailboxes.find(
|
||||
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
||||
);
|
||||
if (!archiveMailbox) return;
|
||||
const targets = emails.filter(
|
||||
(e) => emailIds.includes(e.id) && !e.mailboxIds?.[archiveMailbox.id],
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const mode = useSettingsStore.getState().archiveMode;
|
||||
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
|
||||
|
||||
await apiArchiveEmails(
|
||||
targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
|
||||
archiveMailbox.id,
|
||||
mode,
|
||||
mailboxes,
|
||||
);
|
||||
|
||||
const removed = new Set(targets.map((e) => e.id));
|
||||
set({
|
||||
emails: get().emails.filter((e) => !removed.has(e.id)),
|
||||
pendingUndo: {
|
||||
kind: 'archive',
|
||||
label: targets.length === 1 ? 'Email archived' : `${targets.length} emails archived`,
|
||||
createdAt: Date.now(),
|
||||
items,
|
||||
},
|
||||
});
|
||||
|
||||
if (mode !== 'single') void get().fetchMailboxes();
|
||||
},
|
||||
|
||||
moveEmailsToMailbox: async (emailIds, toMailboxId) => {
|
||||
const { emails, currentMailboxId, mailboxes } = get();
|
||||
if (!currentMailboxId || toMailboxId === currentMailboxId) return;
|
||||
const targets = emails.filter((e) => emailIds.includes(e.id));
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } }));
|
||||
|
||||
await apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId);
|
||||
|
||||
const removed = new Set(targets.map((e) => e.id));
|
||||
const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name;
|
||||
set({
|
||||
emails: get().emails.filter((e) => !removed.has(e.id)),
|
||||
pendingUndo: {
|
||||
kind: 'move',
|
||||
label: targetName
|
||||
? `${targets.length === 1 ? 'Email' : `${targets.length} emails`} moved to ${targetName}`
|
||||
: 'Emails moved',
|
||||
createdAt: Date.now(),
|
||||
items,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => {
|
||||
const { emails, mailboxes } = get();
|
||||
const settings = useSettingsStore.getState();
|
||||
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
const inTrash = currentMailboxId === trashMailboxId;
|
||||
const targets = emails.filter((e) => emailIds.includes(e.id));
|
||||
if (targets.length === 0) return;
|
||||
|
||||
// Split into permanent-destroy vs move-to-trash following the same policy
|
||||
// as the single delete: trash folder, global "permanent" default, or the
|
||||
// skip-trash-for-junk option each force a destroy.
|
||||
const toDestroy: Email[] = [];
|
||||
const toTrash: Email[] = [];
|
||||
for (const e of targets) {
|
||||
const inJunk = !!(junkMailbox && e.mailboxIds?.[junkMailbox.id]);
|
||||
const destroy =
|
||||
inTrash ||
|
||||
settings.deleteAction === 'permanent' ||
|
||||
(settings.permanentlyDeleteJunk && inJunk);
|
||||
(destroy ? toDestroy : toTrash).push(e);
|
||||
}
|
||||
|
||||
if (toDestroy.length > 0) {
|
||||
await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId);
|
||||
}
|
||||
if (toTrash.length > 0) {
|
||||
await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId);
|
||||
}
|
||||
|
||||
const removed = new Set(targets.map((e) => e.id));
|
||||
set({ emails: get().emails.filter((e) => !removed.has(e.id)) });
|
||||
|
||||
// Only the moved-to-trash items are recoverable; destroyed ones are gone.
|
||||
if (toTrash.length > 0) {
|
||||
set({
|
||||
pendingUndo: {
|
||||
kind: 'delete',
|
||||
label: toTrash.length === 1 ? 'Email moved to Trash' : `${toTrash.length} emails moved to Trash`,
|
||||
createdAt: Date.now(),
|
||||
items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setKeywordForEmails: async (emailIds, token, on) => {
|
||||
const targets = get().emails.filter((e) => emailIds.includes(e.id));
|
||||
if (targets.length === 0) return;
|
||||
const updates = targets.map((e) => {
|
||||
const keywords = { ...e.keywords };
|
||||
if (on) keywords[token] = true;
|
||||
else delete keywords[token];
|
||||
return { id: e.id, keywords };
|
||||
});
|
||||
await setKeywordsForEmails(updates);
|
||||
const byId = new Map(updates.map((u) => [u.id, u.keywords]));
|
||||
set({
|
||||
emails: get().emails.map((e) =>
|
||||
byId.has(e.id) ? { ...e, keywords: byId.get(e.id)! } : e,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
undoLast: async () => {
|
||||
const entry = get().pendingUndo;
|
||||
if (!entry) return;
|
||||
@@ -395,9 +1133,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
reset: () => set({
|
||||
mailboxes: [],
|
||||
mailboxState: undefined,
|
||||
emailState: undefined,
|
||||
currentMailboxId: null,
|
||||
mailboxSnapshots: {},
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
queryState: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
@@ -405,16 +1147,23 @@ export const useEmailStore = create<EmailState>()(
|
||||
}),
|
||||
}),
|
||||
{
|
||||
// Persist the mail list so the UI can render instantly on re-open,
|
||||
// before the JMAP session has finished restoring. auth-store triggers
|
||||
// a background refresh once the session is ready.
|
||||
// Persist the per-account caches and the active view so the UI can
|
||||
// render instantly on re-open / account switch, before the JMAP
|
||||
// session has finished restoring. auth-store triggers a background
|
||||
// refresh once the session is ready.
|
||||
name: 'email-cache',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
accountSnapshots: state.accountSnapshots,
|
||||
activeAccountId: state.activeAccountId,
|
||||
mailboxes: state.mailboxes,
|
||||
mailboxState: state.mailboxState,
|
||||
emailState: state.emailState,
|
||||
currentMailboxId: state.currentMailboxId,
|
||||
mailboxSnapshots: state.mailboxSnapshots,
|
||||
emails: state.emails,
|
||||
totalEmails: state.totalEmails,
|
||||
queryState: state.queryState,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Persistent cache for full email bodies (and optional attachment blobs)
|
||||
// powering offline mail viewing. Bodies are JSON-serialised and kept in
|
||||
// AsyncStorage; attachment blobs go to expo-file-system under
|
||||
// `Paths.document/offline-attachments/`. The store also exposes the live
|
||||
// progress state the OfflineCacheBanner reads from.
|
||||
//
|
||||
// Storage is keyed by the email-store's `activeAccountId` so logged-in
|
||||
// accounts don't leak cached bodies into each other. The store loads one
|
||||
// account's bucket at a time (the active one); switching accounts via
|
||||
// setAccount() persists the current bucket then hydrates the new one.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:';
|
||||
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
|
||||
|
||||
function indexKey(accountId: string): string {
|
||||
return `${INDEX_KEY_PREFIX}${accountId}`;
|
||||
}
|
||||
|
||||
function entryKey(accountId: string, emailId: string): string {
|
||||
return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`;
|
||||
}
|
||||
|
||||
export interface OfflineCacheIndexEntry {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
size: number; // approx bytes of cached payload
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface CacheIndex {
|
||||
entries: Record<string, OfflineCacheIndexEntry>;
|
||||
}
|
||||
|
||||
export type SyncPhase = 'idle' | 'scanning' | 'fetching' | 'done' | 'error' | 'cancelled';
|
||||
|
||||
export interface SyncState {
|
||||
phase: SyncPhase;
|
||||
total: number; // total emails the sync expects to process
|
||||
completed: number; // emails processed (skipped or fetched)
|
||||
fetched: number; // emails actually downloaded (not already cached)
|
||||
bytes: number; // bytes downloaded this run
|
||||
message?: string; // human-readable status (errors etc.)
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
|
||||
const EMPTY_INDEX: CacheIndex = { entries: {} };
|
||||
|
||||
interface OfflineCacheState {
|
||||
// Currently-loaded bucket. null = no account active (no reads/writes).
|
||||
activeAccountId: string | null;
|
||||
index: CacheIndex;
|
||||
hydrated: boolean;
|
||||
sync: SyncState;
|
||||
// Allows the sync orchestrator to abort mid-run when the user disables the
|
||||
// setting or kicks off a fresh sync. Stored on the singleton because
|
||||
// AbortControllers need to survive across the function boundary.
|
||||
abortRequested: boolean;
|
||||
|
||||
// Point the store at the given account, hydrating its bucket from
|
||||
// AsyncStorage. Pass null to detach (no reads/writes will hit storage).
|
||||
setAccount: (accountId: string | null) => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
has: (id: string) => boolean;
|
||||
get: (id: string) => Promise<Email | null>;
|
||||
put: (email: Email, approxBytes: number) => Promise<void>;
|
||||
remove: (ids: string[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Returns cached emails whose `mailboxIds` include the given mailbox,
|
||||
// sorted by `receivedAt` descending. Used by selectMailbox to seed the
|
||||
// list when the network is unavailable. Loads each entry from
|
||||
// AsyncStorage; the index doesn't carry the mailbox set.
|
||||
getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise<Email[]>;
|
||||
|
||||
totalSize: () => number;
|
||||
totalCount: () => number;
|
||||
|
||||
setSyncState: (next: Partial<SyncState>) => void;
|
||||
resetSync: () => void;
|
||||
requestAbort: () => void;
|
||||
consumeAbort: () => boolean;
|
||||
}
|
||||
|
||||
function persistIndex(accountId: string, index: CacheIndex): void {
|
||||
void AsyncStorage.setItem(indexKey(accountId), JSON.stringify(index)).catch((err) => {
|
||||
console.warn('[offline-cache] persist index failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadIndex(accountId: string): Promise<CacheIndex> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(indexKey(accountId));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheIndex;
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] hydrate failed', err);
|
||||
}
|
||||
return { entries: {} };
|
||||
}
|
||||
|
||||
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
activeAccountId: null,
|
||||
index: { entries: {} },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: false,
|
||||
|
||||
setAccount: async (accountId) => {
|
||||
const state = get();
|
||||
if (state.activeAccountId === accountId) {
|
||||
// Same account — make sure we've hydrated.
|
||||
if (!state.hydrated) await get().hydrate();
|
||||
return;
|
||||
}
|
||||
// Drop any in-flight sync state from the previous account; the abort flag
|
||||
// makes runOfflineSync stop on its next chunk boundary.
|
||||
set({
|
||||
activeAccountId: accountId,
|
||||
index: { ...EMPTY_INDEX },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: true,
|
||||
});
|
||||
if (accountId) {
|
||||
const index = await loadIndex(accountId);
|
||||
// Re-check in case another setAccount call raced past us.
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
set({ index, hydrated: true, abortRequested: false });
|
||||
} else {
|
||||
set({ hydrated: true, abortRequested: false });
|
||||
}
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
const state = get();
|
||||
if (state.hydrated) return;
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) {
|
||||
// No active account yet — mark hydrated so callers that block on this
|
||||
// (selectMailbox cache-seed path) don't spin. Reads will return empty.
|
||||
set({ hydrated: true });
|
||||
return;
|
||||
}
|
||||
const index = await loadIndex(accountId);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
set({ index, hydrated: true });
|
||||
},
|
||||
|
||||
has: (id) => Boolean(get().index.entries[id]),
|
||||
|
||||
get: async (id) => {
|
||||
const state = get();
|
||||
if (!state.activeAccountId) return null;
|
||||
if (!state.index.entries[id]) return null;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as Email;
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] get failed', id, err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
put: async (email, approxBytes) => {
|
||||
const state = get();
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) return;
|
||||
try {
|
||||
await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email));
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] put failed', email.id, err);
|
||||
return;
|
||||
}
|
||||
// Re-read the active account in case it changed while we were awaiting
|
||||
// AsyncStorage — don't stamp the new account's index with the old
|
||||
// account's entry.
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const nextEntries = {
|
||||
...get().index.entries,
|
||||
[email.id]: {
|
||||
id: email.id,
|
||||
receivedAt: email.receivedAt,
|
||||
size: approxBytes,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
const index = { entries: nextEntries };
|
||||
set({ index });
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
remove: async (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
|
||||
);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const next = { ...get().index.entries };
|
||||
for (const id of ids) delete next[id];
|
||||
const index = { entries: next };
|
||||
set({ index });
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
clearAll: async () => {
|
||||
const accountId = get().activeAccountId;
|
||||
if (!accountId) return;
|
||||
const ids = Object.keys(get().index.entries);
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
|
||||
);
|
||||
if (get().activeAccountId !== accountId) return;
|
||||
const index = { entries: {} };
|
||||
set({ index, sync: { ...IDLE } });
|
||||
persistIndex(accountId, index);
|
||||
},
|
||||
|
||||
getEmailsInMailbox: async (mailboxId, limit = 200) => {
|
||||
const state = get();
|
||||
const accountId = state.activeAccountId;
|
||||
if (!accountId) return [];
|
||||
// Sort the index by receivedAt descending first so we don't have to read
|
||||
// entries we won't return — the index already carries receivedAt.
|
||||
const sorted = Object.values(state.index.entries).sort((a, b) => {
|
||||
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
|
||||
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
const matches: Email[] = [];
|
||||
for (const entry of sorted) {
|
||||
if (matches.length >= limit) break;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id));
|
||||
if (!raw) continue;
|
||||
const email = JSON.parse(raw) as Email;
|
||||
if (email.mailboxIds?.[mailboxId]) {
|
||||
matches.push(email);
|
||||
}
|
||||
} catch {
|
||||
// skip corrupt entries — they'll be replaced on next sync
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
|
||||
totalSize: () => {
|
||||
let n = 0;
|
||||
for (const e of Object.values(get().index.entries)) n += e.size;
|
||||
return n;
|
||||
},
|
||||
|
||||
totalCount: () => Object.keys(get().index.entries).length,
|
||||
|
||||
setSyncState: (next) => set({ sync: { ...get().sync, ...next } }),
|
||||
|
||||
resetSync: () => set({ sync: { ...IDLE }, abortRequested: false }),
|
||||
|
||||
requestAbort: () => set({ abortRequested: true }),
|
||||
|
||||
consumeAbort: () => {
|
||||
const v = get().abortRequested;
|
||||
if (v) set({ abortRequested: false });
|
||||
return v;
|
||||
},
|
||||
}));
|
||||
+246
-40
@@ -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,166 @@ interface PersistedSettings {
|
||||
showToolbarLabels: boolean;
|
||||
animationsEnabled: boolean;
|
||||
emailAlwaysLightMode: boolean;
|
||||
activeThemeId: string | null;
|
||||
|
||||
// Composing
|
||||
autoSelectReplyIdentity: boolean;
|
||||
attachmentReminderEnabled: boolean;
|
||||
attachmentReminderKeywords: string[];
|
||||
plainTextMode: boolean;
|
||||
// Undo-send window: every send is deferred by this many seconds (via the
|
||||
// server's FUTURERELEASE support) so it can be cancelled. 0 = send instantly.
|
||||
sendDelaySeconds: number;
|
||||
|
||||
// Reading
|
||||
markAsReadDelay: number;
|
||||
deleteAction: DeleteAction;
|
||||
permanentlyDeleteJunk: boolean;
|
||||
showPreview: boolean;
|
||||
mailLayout: MailLayout;
|
||||
emailsPerPage: number;
|
||||
disableThreading: boolean;
|
||||
mailAttachmentAction: MailAttachmentAction;
|
||||
attachmentPosition: AttachmentPosition;
|
||||
|
||||
// Layout / list interactions
|
||||
swipeLeftAction: SwipeAction;
|
||||
swipeRightAction: SwipeAction;
|
||||
swipeMode: SwipeMode;
|
||||
|
||||
// Archive
|
||||
archiveMode: ArchiveMode;
|
||||
|
||||
// Calendar
|
||||
calendarDefaultView: CalendarView;
|
||||
calendarFirstDayOfWeek: FirstDayOfWeek;
|
||||
calendarTimeFormat: TimeFormat;
|
||||
calendarShowTimeInMonth: boolean;
|
||||
calendarShowWeekNumbers: boolean;
|
||||
calendarHoverPreview: CalendarHoverPreview;
|
||||
showBirthdayCalendar: boolean;
|
||||
enableCalendarTasks: boolean;
|
||||
showTasksOnCalendar: boolean;
|
||||
|
||||
// Files
|
||||
filesFolderLayout: FilesFolderLayout;
|
||||
filesDefaultViewMode: FilesViewMode;
|
||||
filesDefaultSortKey: FilesSortKey;
|
||||
filesDefaultSortDir: FilesSortDir;
|
||||
filesShowIcons: boolean;
|
||||
filesColoredIcons: boolean;
|
||||
filesShowThumbnails: boolean;
|
||||
filesShowHiddenFiles: boolean;
|
||||
|
||||
// Notifications
|
||||
notificationSoundChoice: NotificationSound;
|
||||
emailNotificationsEnabled: boolean;
|
||||
emailNotificationSound: boolean;
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
calendarInvitationParsingEnabled: boolean;
|
||||
|
||||
// Sidebar apps
|
||||
sidebarApps: SidebarApp[];
|
||||
keepAppsLoaded: boolean;
|
||||
|
||||
// S/MIME defaults (key/cert lists are server-managed; only UI-level prefs persist)
|
||||
smimeDefaultEncrypt: boolean;
|
||||
smimeRememberUnlocked: boolean;
|
||||
smimeAutoImport: boolean;
|
||||
|
||||
// Filters UI state
|
||||
filtersExpandedView: boolean;
|
||||
|
||||
// Plugins (UI-level enable map)
|
||||
pluginEnabled: Record<string, boolean>;
|
||||
|
||||
// Offline mail cache: download recent message bodies in the background so
|
||||
// they can be opened without network. Days windows the lookback. Attachment
|
||||
// caching is intentionally not implemented yet — bodies-only is much
|
||||
// smaller and covers the "open recent mail offline" UX on its own.
|
||||
offlineCacheEnabled: boolean;
|
||||
offlineCacheDays: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
externalContentPolicy: 'ask',
|
||||
trustedSenders: [],
|
||||
trustedSendersAddressBook: false,
|
||||
senderFavicons: true,
|
||||
hideInlineImageAttachments: true,
|
||||
|
||||
groupContactsByLetter: true,
|
||||
|
||||
theme: 'system',
|
||||
fontSize: 'medium',
|
||||
density: 'regular',
|
||||
showToolbarLabels: true,
|
||||
animationsEnabled: true,
|
||||
emailAlwaysLightMode: false,
|
||||
activeThemeId: null,
|
||||
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: true,
|
||||
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
|
||||
plainTextMode: false,
|
||||
sendDelaySeconds: 0,
|
||||
|
||||
markAsReadDelay: 0,
|
||||
deleteAction: 'trash',
|
||||
permanentlyDeleteJunk: false,
|
||||
showPreview: true,
|
||||
mailLayout: 'split',
|
||||
emailsPerPage: 25,
|
||||
disableThreading: false,
|
||||
mailAttachmentAction: 'preview',
|
||||
attachmentPosition: 'beside-sender',
|
||||
|
||||
swipeLeftAction: 'archive',
|
||||
swipeRightAction: 'read',
|
||||
swipeMode: 'instant',
|
||||
|
||||
archiveMode: 'single',
|
||||
|
||||
calendarDefaultView: 'month',
|
||||
calendarFirstDayOfWeek: 1,
|
||||
calendarTimeFormat: '24h',
|
||||
calendarShowTimeInMonth: true,
|
||||
calendarShowWeekNumbers: false,
|
||||
calendarHoverPreview: 'delay-500ms',
|
||||
showBirthdayCalendar: true,
|
||||
enableCalendarTasks: false,
|
||||
showTasksOnCalendar: true,
|
||||
|
||||
filesFolderLayout: 'inline',
|
||||
filesDefaultViewMode: 'list',
|
||||
filesDefaultSortKey: 'name',
|
||||
filesDefaultSortDir: 'asc',
|
||||
filesShowIcons: true,
|
||||
filesColoredIcons: true,
|
||||
filesShowThumbnails: true,
|
||||
filesShowHiddenFiles: false,
|
||||
|
||||
notificationSoundChoice: 'default',
|
||||
emailNotificationsEnabled: true,
|
||||
emailNotificationSound: true,
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
calendarInvitationParsingEnabled: true,
|
||||
|
||||
sidebarApps: [],
|
||||
keepAppsLoaded: false,
|
||||
|
||||
smimeDefaultEncrypt: false,
|
||||
smimeRememberUnlocked: false,
|
||||
smimeAutoImport: true,
|
||||
|
||||
filtersExpandedView: false,
|
||||
|
||||
pluginEnabled: {},
|
||||
|
||||
offlineCacheEnabled: false,
|
||||
offlineCacheDays: 7,
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
@@ -71,6 +231,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 +253,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 +291,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 +332,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 +341,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 +360,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 +383,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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user