mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d1e84666 | ||
|
|
55484453a1 | ||
|
|
b525c52870 | ||
|
|
b64afb462f | ||
|
|
b92f85a236 | ||
|
|
643d704b74 | ||
|
|
9e383c3616 | ||
|
|
1543db49d1 | ||
|
|
910e5418e9 | ||
|
|
a019d9e775 | ||
|
|
e6d47b6a16 | ||
|
|
b10f61a642 | ||
|
|
752aa719d8 | ||
|
|
5884a18979 | ||
|
|
3ccc83e7ce | ||
|
|
5dc4ea1a45 | ||
|
|
1bd476e052 | ||
|
|
41793455f7 | ||
|
|
d99cbf1dbb | ||
|
|
1afd2185d5 | ||
|
|
5aa1c88b1e | ||
|
|
ac95a2bc39 | ||
|
|
e75aefeb4f | ||
|
|
70863e0923 | ||
|
|
b6ecf07369 | ||
|
|
bf5d75d5e9 | ||
|
|
53520a4236 | ||
|
|
c55f78b5a7 | ||
|
|
5d47f47c8e | ||
|
|
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 | ||
|
|
a8bb83ffe0 | ||
|
|
c37256c54e | ||
|
|
e1d318ebc1 | ||
|
|
2090480bf4 | ||
|
|
329c8a2f0b | ||
|
|
49d1fc62a8 | ||
|
|
d5a336519a | ||
|
|
9c7654acb2 | ||
|
|
346a71de84 |
@@ -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,23 @@ 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 { useOutboxStore } from './src/stores/outbox-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 +55,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 +92,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 +157,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 +171,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 +185,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 +239,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 +264,36 @@ 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 offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
|
||||
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, maxMB: offlineCacheMaxMB });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
|
||||
|
||||
// Drain the offline action queue (outbox) as soon as we have a live session,
|
||||
// and again whenever the network comes back. The flush itself no-ops when
|
||||
// there's nothing queued or the client isn't ready.
|
||||
React.useEffect(() => {
|
||||
if (!haveLiveSession) return;
|
||||
void useOutboxStore.getState().flush();
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online) void useOutboxStore.getState().flush();
|
||||
});
|
||||
}, [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 +315,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 +329,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 +375,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
@@ -360,6 +464,8 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
||||
<Stack.Screen
|
||||
name="AddAccount"
|
||||
options={{
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<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"/>
|
||||
@@ -23,6 +22,12 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+13
-1
@@ -20,6 +20,7 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
@@ -45,7 +46,18 @@ module.exports = {
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
|
||||
plugins: [
|
||||
'expo-secure-store',
|
||||
'@react-native-community/datetimepicker',
|
||||
'expo-localization',
|
||||
[
|
||||
'expo-camera',
|
||||
{
|
||||
cameraPermission:
|
||||
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
|
||||
},
|
||||
],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
},
|
||||
|
||||
+13
-4
@@ -450,10 +450,15 @@
|
||||
"send": "Send",
|
||||
"cancel": "Cancel",
|
||||
"attach": "Attach",
|
||||
"attach_photos": "Photos & Videos",
|
||||
"attach_files": "Files",
|
||||
"discard": "Discard",
|
||||
"discard_draft_title": "Discard draft?",
|
||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||
"saving": "Saving...",
|
||||
"sending": "Sending...",
|
||||
"add_link": "Add link",
|
||||
"link_url_prompt": "Enter the URL",
|
||||
"draft_saved": "Draft saved",
|
||||
"save_failed": "Failed to save",
|
||||
"to_placeholder": "Recipient email addresses",
|
||||
@@ -816,10 +821,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Date Format",
|
||||
"description": "How dates should be displayed",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Custom"
|
||||
"description": "How dates are shown in the email list",
|
||||
"smart": "Smart (locale-aware)",
|
||||
"relative": "Relative (1h ago, 2d ago)",
|
||||
"full": "Always full date",
|
||||
"preview_today": "Today:",
|
||||
"preview_this_week": "This week:",
|
||||
"preview_older": "Older:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Time Format",
|
||||
@@ -1419,6 +1427,7 @@
|
||||
"size_bytes": "Size in bytes",
|
||||
"size_placeholder": "e.g., 1000000",
|
||||
"system_managed": "System-managed rule",
|
||||
"origin_external": "External",
|
||||
"opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.",
|
||||
"open_sieve_editor": "Open raw Sieve editor",
|
||||
"fetch_error": "Failed to load filters",
|
||||
|
||||
Generated
+144
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.53",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.53",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,8 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"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 +26,8 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -4755,6 +4759,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5202,6 +5212,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
@@ -5216,6 +5246,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 +5447,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",
|
||||
@@ -6181,6 +6230,12 @@
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -6297,6 +6352,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -6798,6 +6859,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -6825,6 +6898,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lighthouse-logger": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
|
||||
@@ -7985,6 +8067,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-png": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz",
|
||||
@@ -8196,6 +8284,12 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -8564,6 +8658,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
@@ -8968,6 +9083,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -9205,6 +9326,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -9741,6 +9877,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.53",
|
||||
"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,8 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"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 +32,8 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('expo-file-system', () => ({
|
||||
File: class {
|
||||
constructor(public uri: string) {}
|
||||
async bytes() { return new Uint8Array(); }
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
|
||||
@@ -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 filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
|
||||
// Stalwart rejects the draft's plural `inCalendars` filter (and
|
||||
// after/before); calendars are restricted via singular `inCalendar`
|
||||
// conditions and date filtering stays client-side.
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||
});
|
||||
@@ -50,12 +54,35 @@ 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).toEqual({ inCalendar: 'cal-1' });
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('should OR multiple calendars and respect the target account', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }],
|
||||
});
|
||||
expect(call[1].accountId).toBe('acc-shared');
|
||||
});
|
||||
|
||||
it('should omit the filter when no calendars are given', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryEvents([], '', '');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
hasCapability: vi.fn(),
|
||||
currentSession: null as unknown,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../blob', () => ({
|
||||
getDownloadUrl: vi.fn(
|
||||
(blobId: string, _name?: string, _type?: string, accountId?: string) =>
|
||||
`https://dl/${accountId ?? 'own'}/${blobId}`,
|
||||
),
|
||||
uploadBlob: vi.fn(),
|
||||
uploadBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import {
|
||||
createFolder,
|
||||
deleteFileNodes,
|
||||
getAllFileNodesAcrossAccounts,
|
||||
getFileNodeDownloadUrl,
|
||||
getPrincipals,
|
||||
isCrossAccountId,
|
||||
isFolder,
|
||||
setFileNodeShare,
|
||||
} from '../files';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
||||
|
||||
function setSession(session: unknown) {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = session;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasCapability.mockImplementation(
|
||||
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
|
||||
);
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': { name: 'me@example.com', isPersonal: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFolder', () => {
|
||||
it('treats blob-less nodes as folders and anything with a blob as a file', () => {
|
||||
// #379: a blob-marked node — even the legacy type:'d' dir markers — is a
|
||||
// 0-byte file that nothing can be parented under.
|
||||
expect(isFolder({ blobId: null })).toBe(true);
|
||||
expect(isFolder({ blobId: undefined })).toBe(true);
|
||||
expect(isFolder({ blobId: 'blob-1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('creates a real container node: no blobId/type/size, parentId set', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
|
||||
});
|
||||
|
||||
await createFolder('Docs', 'parent-1');
|
||||
|
||||
const [method, args] = mockRequest.mock.calls[0][0][0];
|
||||
expect(method).toBe('FileNode/set');
|
||||
expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' });
|
||||
});
|
||||
|
||||
it('omits parentId at the root', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
|
||||
});
|
||||
|
||||
await createFolder('Docs', null);
|
||||
|
||||
const args = mockRequest.mock.calls[0][0][0][1];
|
||||
expect(args.create['new-dir']).toEqual({ name: 'Docs' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFileNodes', () => {
|
||||
it('lets the server cascade into folder children', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']],
|
||||
});
|
||||
|
||||
await deleteFileNodes(['f1']);
|
||||
|
||||
const args = mockRequest.mock.calls[0][0][0][1];
|
||||
expect(args.destroy).toEqual(['f1']);
|
||||
expect(args.onDestroyRemoveChildren).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the server refuses a destroy', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'],
|
||||
],
|
||||
});
|
||||
|
||||
await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFileNodeShare', () => {
|
||||
it('patches shareWith/{principalId} and requests the principals:owner capability', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
|
||||
});
|
||||
const rights = {
|
||||
mayRead: true, mayAddChildren: false, mayRename: false,
|
||||
mayDelete: false, mayModifyContent: false, mayShare: false,
|
||||
};
|
||||
|
||||
await setFileNodeShare('node-1', 'principal-2', rights);
|
||||
|
||||
const [calls, using] = mockRequest.mock.calls[0];
|
||||
expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights });
|
||||
expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER);
|
||||
});
|
||||
|
||||
it('sends null rights to revoke access', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await setFileNodeShare('node-1', 'principal-2', null);
|
||||
|
||||
const args = mockRequest.mock.calls[0][0][0][1];
|
||||
expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null });
|
||||
});
|
||||
|
||||
it('throws when the server rejects or does not confirm the update', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'],
|
||||
],
|
||||
});
|
||||
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right');
|
||||
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] });
|
||||
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow(
|
||||
'Server did not confirm the share update',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFileNodesAcrossAccounts', () => {
|
||||
it('namespaces ids of nodes from other principals and tags them isShared', async () => {
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': { name: 'me@example.com', isPersonal: true },
|
||||
'acc-2': { name: 'Team', isPersonal: false },
|
||||
},
|
||||
});
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'],
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['FileNode/get', {
|
||||
list: [
|
||||
{ id: 's1', name: 'Shared Folder', parentId: null, blobId: null },
|
||||
{ id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' },
|
||||
],
|
||||
}, '0'],
|
||||
],
|
||||
});
|
||||
|
||||
const nodes = await getAllFileNodesAcrossAccounts();
|
||||
|
||||
expect(nodes).toHaveLength(3);
|
||||
const own = nodes.find((n) => n.id === 'own-1')!;
|
||||
expect(own.isShared).toBe(false);
|
||||
expect(isCrossAccountId(own.id)).toBe(false);
|
||||
|
||||
const folder = nodes.find((n) => n.name === 'Shared Folder')!;
|
||||
expect(folder.id).toBe('acc-2:s1');
|
||||
expect(folder.isShared).toBe(true);
|
||||
expect(folder.accountId).toBe('acc-2');
|
||||
expect(folder.accountName).toBe('Team');
|
||||
|
||||
// parentId is namespaced the same way so child lookups keep working.
|
||||
const child = nodes.find((n) => n.name === 'inside.txt')!;
|
||||
expect(child.parentId).toBe('acc-2:s1');
|
||||
});
|
||||
|
||||
it('skips personal accounts without the filenode capability', async () => {
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': { name: 'me@example.com', isPersonal: true },
|
||||
'acc-3': { name: 'other personal', isPersonal: true },
|
||||
},
|
||||
});
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/get', { list: [] }, '0']],
|
||||
});
|
||||
|
||||
await getAllFileNodesAcrossAccounts();
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileNodeDownloadUrl', () => {
|
||||
it('routes shared blobs to the owning account', () => {
|
||||
const url = getFileNodeDownloadUrl({
|
||||
id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain',
|
||||
blobId: 'b2', accountId: 'acc-2',
|
||||
});
|
||||
expect(url).toBe('https://dl/acc-2/b2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrincipals', () => {
|
||||
it('queries then gets via back-reference', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['Principal/query', { ids: ['p1'] }, '0'],
|
||||
['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const principals = await getPrincipals();
|
||||
|
||||
expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]);
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls[0][0]).toBe('Principal/query');
|
||||
expect(calls[1][0]).toBe('Principal/get');
|
||||
expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' });
|
||||
});
|
||||
|
||||
it('returns empty when the server lacks the principals capability', async () => {
|
||||
mockHasCapability.mockReturnValue(false);
|
||||
expect(await getPrincipals()).toEqual([]);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// Stalwart account-security management. Mirrors the webmail's
|
||||
// stores/account-security-store.ts, talking to Stalwart's JMAP extension
|
||||
// methods (x:AccountPassword, x:AppPassword, x:ApiKey, x:AccountSettings,
|
||||
// x:Account). The webmail proxies these through a server-side passthrough so
|
||||
// the basic-auth header stays in an httpOnly cookie; the mobile client already
|
||||
// holds its own credentials, so it calls the JMAP endpoint directly with the
|
||||
// `urn:stalwart:jmap` capability in `using`.
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
|
||||
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
|
||||
const STALWART_USING = [CAPABILITIES.CORE, STALWART_CAPABILITY];
|
||||
|
||||
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
|
||||
|
||||
export interface AppCredentialInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
allowedIps: string[];
|
||||
}
|
||||
|
||||
export type AppPasswordInfo = AppCredentialInfo;
|
||||
export type ApiKeyInfo = AppCredentialInfo;
|
||||
|
||||
export interface AppCredentialInput {
|
||||
description: string;
|
||||
expiresAt?: string | null;
|
||||
allowedIps?: string[];
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordInfo[];
|
||||
apiKeys: ApiKeyInfo[];
|
||||
}
|
||||
|
||||
export interface PrincipalInfo {
|
||||
displayName: string;
|
||||
emails: string[];
|
||||
quota: number;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
type MethodResponse = [string, Record<string, any>, string];
|
||||
|
||||
// True once a live session advertises Stalwart's JMAP extension. Without it
|
||||
// none of the x:* methods exist, so the screen shows a "not available" notice.
|
||||
export function isStalwartSupported(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
return STALWART_CAPABILITY in (session.capabilities ?? {});
|
||||
}
|
||||
|
||||
// Pull a single method response out by its call id and throw on a JMAP method
|
||||
// error (the `['error', {type, description}, id]` envelope).
|
||||
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
|
||||
const match = responses.find((r) => r[2] === callId);
|
||||
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
|
||||
if (match[0] === 'error') {
|
||||
const err = match[1] as { type?: string; description?: string };
|
||||
throw new Error(err.description || err.type || 'JMAP error');
|
||||
}
|
||||
return match[1] as T;
|
||||
}
|
||||
|
||||
async function send(methodCalls: [string, Record<string, unknown>, string][]): Promise<MethodResponse[]> {
|
||||
const res = await jmapClient.request(methodCalls, STALWART_USING);
|
||||
return res.methodResponses as MethodResponse[];
|
||||
}
|
||||
|
||||
function credentialFromResult(raw: Record<string, unknown>): AppCredentialInfo {
|
||||
const allowedIps =
|
||||
raw.allowedIps && typeof raw.allowedIps === 'object'
|
||||
? Object.keys(raw.allowedIps as Record<string, unknown>)
|
||||
: [];
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
description: typeof raw.description === 'string' ? raw.description : '',
|
||||
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
|
||||
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
|
||||
allowedIps,
|
||||
};
|
||||
}
|
||||
|
||||
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
|
||||
if (!ips || ips.length === 0) return undefined;
|
||||
return Object.fromEntries(ips.map((ip) => [ip, true]));
|
||||
}
|
||||
|
||||
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = { description: input.description };
|
||||
if (input.expiresAt) body.expiresAt = input.expiresAt;
|
||||
const allowed = ipsToMap(input.allowedIps);
|
||||
if (allowed) body.allowedIps = allowed;
|
||||
return body;
|
||||
}
|
||||
|
||||
function extractEncryptionType(raw: unknown): EncryptionType {
|
||||
if (!raw || typeof raw !== 'object') return 'Disabled';
|
||||
const type = (raw as { ['@type']?: string })['@type'];
|
||||
if (type === 'Aes128' || type === 'Aes256') return type;
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAuthInfo(): Promise<AuthInfo> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'p'],
|
||||
['x:AppPassword/query', { accountId }, 'aq'],
|
||||
['x:ApiKey/query', { accountId }, 'kq'],
|
||||
]);
|
||||
|
||||
const passwordResult = resultFor<{ list?: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(responses, 'p');
|
||||
const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
|
||||
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
|
||||
|
||||
const otpAuth = passwordResult.list?.[0]?.otpAuth;
|
||||
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
|
||||
|
||||
const followUps: [string, Record<string, unknown>, string][] = [];
|
||||
if (appPwQuery.ids?.length) {
|
||||
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
|
||||
}
|
||||
|
||||
let appPasswords: AppPasswordInfo[] = [];
|
||||
let apiKeys: ApiKeyInfo[] = [];
|
||||
if (followUps.length) {
|
||||
const followUpResponses = await send(followUps);
|
||||
if (appPwQuery.ids?.length) {
|
||||
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'app');
|
||||
appPasswords = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'key');
|
||||
apiKeys = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
}
|
||||
|
||||
return { otpEnabled, appPasswords, apiKeys };
|
||||
}
|
||||
|
||||
export async function fetchEncryptionType(): Promise<EncryptionType> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
|
||||
const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0');
|
||||
return extractEncryptionType(result.list?.[0]?.encryptionAtRest);
|
||||
}
|
||||
|
||||
export async function fetchPrincipal(): Promise<PrincipalInfo> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([['x:Account/get', { accountId, ids: [accountId] }, '0']]);
|
||||
const result = resultFor<{
|
||||
list?: Array<{
|
||||
description?: string | null;
|
||||
aliases?: Record<string, { name?: string; enabled?: boolean }>;
|
||||
quotas?: { maxDiskQuota?: number };
|
||||
roles?: { ['@type']?: string };
|
||||
name?: string;
|
||||
}>;
|
||||
}>(responses, '0');
|
||||
|
||||
const acc = result.list?.[0];
|
||||
const aliasAddresses = acc?.aliases
|
||||
? Object.values(acc.aliases).flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
|
||||
: [];
|
||||
const primaryEmail = acc?.name ? [acc.name] : [];
|
||||
return {
|
||||
displayName: acc?.description ?? '',
|
||||
emails: [...primaryEmail, ...aliasAddresses],
|
||||
quota: acc?.quotas?.maxDiskQuota ?? 0,
|
||||
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{ accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
|
||||
}
|
||||
|
||||
export async function updateDisplayName(displayName: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to update display name');
|
||||
}
|
||||
|
||||
export async function enableTotp(currentPassword: string, otpUrl: string, otpCode: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode } } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to enable two-factor authentication');
|
||||
}
|
||||
|
||||
export async function disableTotp(currentPassword: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl: null } } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to disable two-factor authentication');
|
||||
}
|
||||
|
||||
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
|
||||
|
||||
async function createCredential(method: SetMethod, input: AppCredentialInput): Promise<{ id: string; secret: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const tmpId = 'new';
|
||||
const responses = await send([[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0']]);
|
||||
const result = resultFor<{
|
||||
created?: Record<string, { id: string; secret: string }>;
|
||||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||
}>(responses, '0');
|
||||
|
||||
const notCreated = result.notCreated?.[tmpId];
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to create credential');
|
||||
const created = result.created?.[tmpId];
|
||||
if (!created?.id || !created.secret) throw new Error('Server did not return the created credential');
|
||||
return { id: created.id, secret: created.secret };
|
||||
}
|
||||
|
||||
async function removeCredential(method: SetMethod, id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([[method, { accountId, destroy: [id] }, '0']]);
|
||||
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notDestroyed?.[id];
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove credential');
|
||||
}
|
||||
|
||||
export function createAppPassword(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
|
||||
return createCredential('x:AppPassword/set', input);
|
||||
}
|
||||
|
||||
export function removeAppPassword(id: string): Promise<void> {
|
||||
return removeCredential('x:AppPassword/set', id);
|
||||
}
|
||||
|
||||
export function createApiKey(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
|
||||
return createCredential('x:ApiKey/set', input);
|
||||
}
|
||||
|
||||
export function removeApiKey(id: string): Promise<void> {
|
||||
return removeCredential('x:ApiKey/set', id);
|
||||
}
|
||||
+98
-12
@@ -1,4 +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,
|
||||
@@ -7,39 +9,123 @@ export async function uploadBlob(
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId);
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
// Fetch local file as blob
|
||||
const fileResponse = await fetch(uri);
|
||||
const blob = await fileResponse.blob();
|
||||
// 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: blob,
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
const detail = await response.text().catch(() => '');
|
||||
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(
|
||||
blobId: string,
|
||||
name?: string,
|
||||
type?: string,
|
||||
// Blobs of nodes shared by another principal live in the owner's account;
|
||||
// downloading them with our own accountId 404s.
|
||||
accountId?: string,
|
||||
): string {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
return session.downloadUrl
|
||||
.replace('{accountId}', jmapClient.accountId)
|
||||
.replace('{blobId}', blobId)
|
||||
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name ?? 'download'))
|
||||
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
|
||||
}
|
||||
|
||||
+255
-21
@@ -4,12 +4,48 @@ import type { Calendar, CalendarEvent } from './types';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
|
||||
/**
|
||||
* IANA time zone of the device, sent as the `timeZone` argument on
|
||||
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
|
||||
* filter values and computes utcStart/utcEnd for floating events in this zone,
|
||||
* defaulting to UTC when absent — which shifts range boundaries and
|
||||
* floating-event times for any user not in UTC. Stalwart ignores unparseable
|
||||
* values, so sending it is always safe.
|
||||
*/
|
||||
function getUserTimeZone(): string | undefined {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CalendarEvent/query filter restricting results to the given
|
||||
* calendars. Stalwart implements the singular `inCalendar` condition (one
|
||||
* calendar id per condition), not the draft's plural `inCalendars` array —
|
||||
* sending the plural form fails the whole query with `unsupportedFilter`.
|
||||
* Multiple calendars are expressed as an OR of singular conditions.
|
||||
*/
|
||||
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
|
||||
if (calendarIds.length === 1) {
|
||||
return { inCalendar: calendarIds[0] };
|
||||
}
|
||||
return {
|
||||
operator: 'OR',
|
||||
conditions: calendarIds.map((id) => ({ inCalendar: id })),
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
@@ -23,43 +59,92 @@ function methodResult<T = any>(res: any, index = 0): T {
|
||||
return entry[1] as T;
|
||||
}
|
||||
|
||||
// Other session accounts that expose the calendars capability hold calendars
|
||||
// shared with the user (JMAP surfaces shared data under the sharer's account).
|
||||
function sharedCalendarAccountIds(): string[] {
|
||||
const session = jmapClient.currentSession;
|
||||
const primary = jmapClient.accountId;
|
||||
return Object.entries(session?.accounts ?? {})
|
||||
.filter(([id, info]) =>
|
||||
id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS])
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export async function getCalendars(): Promise<Calendar[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/get', { accountId }, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ list: Calendar[] }>(res).list ?? [];
|
||||
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
|
||||
|
||||
// Calendars shared with the user live in other session accounts. Failures
|
||||
// there (revoked share, transient error) must not hide the user's own
|
||||
// calendars, so each shared account is fetched best-effort.
|
||||
const shared = await Promise.all(
|
||||
sharedCalendarAccountIds().map(async (sharedAccountId) => {
|
||||
try {
|
||||
const sharedRes = await jmapClient.request(
|
||||
[['Calendar/get', { accountId: sharedAccountId }, '0']],
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
|
||||
// JMAP calendar ids are only unique within an account, so a shared
|
||||
// "default" calendar collides with the user's own "default". Namespace
|
||||
// the id (keeping the real id in originalId) so visibility toggles,
|
||||
// event-query routing, and create/default mutations don't cross wires.
|
||||
// Mirrors webmail's getAllCalendars.
|
||||
return list.map((cal) => ({
|
||||
...cal,
|
||||
id: `${sharedAccountId}:${cal.id}`,
|
||||
originalId: cal.id,
|
||||
accountId: sharedAccountId,
|
||||
isShared: true,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return [...own, ...shared.flat()];
|
||||
}
|
||||
|
||||
export async function queryEvents(
|
||||
_calendarIds: string[],
|
||||
calendarIds: string[],
|
||||
_after: string,
|
||||
_before: string,
|
||||
accountId?: string,
|
||||
): Promise<string[]> {
|
||||
// Stalwart rejects inCalendars/after/before filters on CalendarEvent/query.
|
||||
// Fetch all events in the account; calendar visibility + date filtering is
|
||||
// done client-side.
|
||||
const accountId = jmapClient.accountId;
|
||||
// Stalwart rejects after/before filters on CalendarEvent/query; date
|
||||
// filtering is done client-side. Calendars are restricted via the singular
|
||||
// `inCalendar` condition (the plural `inCalendars` fails the whole query
|
||||
// with unsupportedFilter on Stalwart).
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
|
||||
const timeZone = getUserTimeZone();
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', { accountId, limit: 1000 }, '0']],
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
}
|
||||
|
||||
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
|
||||
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const timeZone = getUserTimeZone();
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const all: CalendarEvent[] = [];
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const batch = ids.slice(i, i + batchSize);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/get', {
|
||||
accountId,
|
||||
accountId: account,
|
||||
ids: batch,
|
||||
properties: CALENDAR_EVENT_PROPERTIES,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
@@ -69,38 +154,187 @@ 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,
|
||||
targetAccountId?: string,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || 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,
|
||||
targetAccountId?: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = targetAccountId || 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,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || 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,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || 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,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a calendar as the account default. `isDefault` is read-only in
|
||||
* Stalwart's Calendar/set — the default is changed via the
|
||||
* `onSuccessSetIsDefault` request argument instead.
|
||||
*/
|
||||
export async function setDefaultCalendar(
|
||||
calendarId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']],
|
||||
USING,
|
||||
);
|
||||
methodResult(res);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
+681
-21
@@ -1,6 +1,6 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, EmailAddress, Mailbox, Thread } from './types';
|
||||
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
@@ -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,8 +230,63 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
|
||||
return res.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
export async function getFullEmail(id: string): Promise<Email> {
|
||||
// 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, accountIdOverride?: string): Promise<Email> {
|
||||
// `accountIdOverride` lets the unified inbox open a message that lives under
|
||||
// a group/shared account in the same session instead of the user's own.
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
@@ -74,6 +303,55 @@ 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
|
||||
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
|
||||
* `importRawEmail` import step.
|
||||
*/
|
||||
export async function importEmailBlob(
|
||||
blobId: string,
|
||||
mailboxId: string,
|
||||
keywords: Record<string, boolean> = { $seen: true },
|
||||
): Promise<string> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/import', {
|
||||
accountId,
|
||||
emails: {
|
||||
'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords },
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const result = res.methodResponses[0][1];
|
||||
const notCreated = result?.notCreated?.['import-0'];
|
||||
if (notCreated) {
|
||||
throw new Error(notCreated.description || notCreated.type || 'Failed to import email');
|
||||
}
|
||||
const id = result?.created?.['import-0']?.id;
|
||||
if (!id) throw new Error('Email import succeeded but no id was returned');
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string): Promise<Thread> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
@@ -85,8 +363,9 @@ export async function getThread(threadId: string): Promise<Thread> {
|
||||
export async function setEmailKeywords(
|
||||
emailId: string,
|
||||
keywords: Record<string, boolean>,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'],
|
||||
]);
|
||||
@@ -111,6 +390,207 @@ 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.
|
||||
export async function restoreEmailMailboxes(
|
||||
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const item of items) {
|
||||
const onlyTrue: Record<string, true> = {};
|
||||
for (const [id, present] of Object.entries(item.mailboxIds)) {
|
||||
if (present) onlyTrue[id] = true;
|
||||
}
|
||||
update[item.id] = { mailboxIds: onlyTrue };
|
||||
}
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, update }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole
|
||||
// set, so this is idempotent — replaying it produces the same result no matter
|
||||
// the current server state. The offline outbox relies on that to coalesce and
|
||||
// safely retry move/archive/trash operations.
|
||||
export async function setEmailMailboxes(
|
||||
emailId: string,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
): Promise<void> {
|
||||
await restoreEmailMailboxes([{ id: emailId, mailboxIds }]);
|
||||
}
|
||||
|
||||
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
|
||||
// already-gone id is a no-op on replay.
|
||||
export async function destroyEmails(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
}
|
||||
|
||||
// Archive one or more emails into the archive mailbox, optionally auto-sorting
|
||||
// into year or year/month subfolders. Mirrors the webmail implementation in
|
||||
// lib/jmap/client.ts so behavior stays in sync between platforms.
|
||||
export async function archiveEmails(
|
||||
emails: Array<{ id: string; receivedAt: string }>,
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
|
||||
if (mode === 'single') {
|
||||
const updates = Object.fromEntries(
|
||||
emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]),
|
||||
);
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, update: updates }, '0'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
type Dest = { year: string; month?: string };
|
||||
const destFor = new Map<string, Dest>();
|
||||
for (const e of emails) {
|
||||
const d = new Date(e.receivedAt);
|
||||
const year = d.getFullYear().toString();
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
|
||||
}
|
||||
|
||||
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
|
||||
const yearIdFor = new Map<string, string>();
|
||||
const monthIdFor = new Map<string, string>();
|
||||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
const findExisting = (name: string, parentId: string) =>
|
||||
existingMailboxes.find(
|
||||
(m) => m.name === name && m.parentId === parentId,
|
||||
);
|
||||
|
||||
for (const dest of destFor.values()) {
|
||||
if (!yearIdFor.has(dest.year)) {
|
||||
const existing = findExisting(dest.year, archiveMailboxId);
|
||||
if (existing) {
|
||||
yearIdFor.set(dest.year, existing.id);
|
||||
} else {
|
||||
const cid = `year-${dest.year}`;
|
||||
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
|
||||
yearIdFor.set(dest.year, `#${cid}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'month' && dest.month) {
|
||||
const monthKey = `${dest.year}/${dest.month}`;
|
||||
if (!monthIdFor.has(monthKey)) {
|
||||
const yearRef = yearIdFor.get(dest.year)!;
|
||||
const existingMonth = yearRef.startsWith('#')
|
||||
? undefined
|
||||
: findExisting(dest.month, yearRef);
|
||||
if (existingMonth) {
|
||||
monthIdFor.set(monthKey, existingMonth.id);
|
||||
} else {
|
||||
const cid = `month-${dest.year}-${dest.month}`;
|
||||
createEntries[cid] = { name: dest.month, parentId: yearRef };
|
||||
monthIdFor.set(monthKey, `#${cid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const [emailId, dest] of destFor.entries()) {
|
||||
const destId = mode === 'month' && dest.month
|
||||
? monthIdFor.get(`${dest.year}/${dest.month}`)!
|
||||
: yearIdFor.get(dest.year)!;
|
||||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
||||
}
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
const hasCreates = Object.keys(createEntries).length > 0;
|
||||
if (hasCreates) {
|
||||
methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']);
|
||||
}
|
||||
methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]);
|
||||
|
||||
const response = await jmapClient.request(methodCalls);
|
||||
|
||||
if (hasCreates) {
|
||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||
const notCreated = mailboxResult?.notCreated as
|
||||
| Record<string, { type?: string; properties?: string[]; description?: string }>
|
||||
| undefined;
|
||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||
if (failures.length > 0) {
|
||||
const [cid, err] = failures[0];
|
||||
const parts = [err.type || 'unknown'];
|
||||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err.description) parts.push(err.description);
|
||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const emailIdx = hasCreates ? 1 : 0;
|
||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||
const notUpdated = emailResult?.notUpdated as
|
||||
| Record<string, { type?: string; description?: string }>
|
||||
| undefined;
|
||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||
if (emailFailures.length > 0) {
|
||||
const [id, err] = emailFailures[0];
|
||||
throw new Error(
|
||||
`Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEmail(
|
||||
emailId: string,
|
||||
trashMailboxId: string,
|
||||
@@ -164,6 +644,24 @@ export async function queryEmailsByFilter(
|
||||
return res.methodResponses[0][1].ids;
|
||||
}
|
||||
|
||||
export interface OutgoingAttachment {
|
||||
blobId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
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[];
|
||||
@@ -173,13 +671,17 @@ export async function sendEmail(
|
||||
subject: string;
|
||||
htmlBody?: string;
|
||||
textBody?: string;
|
||||
attachments?: Array<{ blobId: string; type: string; name: string }>;
|
||||
attachments?: OutgoingAttachment[];
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
},
|
||||
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,
|
||||
@@ -191,21 +693,29 @@ export async function sendEmail(
|
||||
keywords: { $seen: true },
|
||||
};
|
||||
|
||||
const bodyValues: Record<string, { value: string }> = {};
|
||||
if (email.htmlBody) {
|
||||
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
|
||||
emailCreate.bodyValues = { html: { value: email.htmlBody } };
|
||||
} else {
|
||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
||||
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
|
||||
bodyValues.html = { value: email.htmlBody };
|
||||
}
|
||||
if (email.textBody || !email.htmlBody) {
|
||||
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
|
||||
bodyValues.text = { value: email.textBody ?? '' };
|
||||
}
|
||||
emailCreate.bodyValues = bodyValues;
|
||||
|
||||
if (email.attachments?.length) {
|
||||
emailCreate.attachments = email.attachments.map((a) => ({
|
||||
blobId: a.blobId,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
disposition: 'attachment',
|
||||
}));
|
||||
emailCreate.attachments = email.attachments.map((a) => {
|
||||
const part: Record<string, unknown> = {
|
||||
blobId: a.blobId,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
disposition: a.disposition ?? 'attachment',
|
||||
};
|
||||
if (a.size != null) part.size = a.size;
|
||||
if (a.cid) part.cid = a.cid;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
if (email.inReplyTo) {
|
||||
@@ -213,14 +723,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');
|
||||
}
|
||||
}
|
||||
|
||||
+266
-28
@@ -1,39 +1,277 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import type { FileNode, FileNodeRights, Principal } from './types';
|
||||
import { getDownloadUrl, uploadBlob } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): 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']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
// A FileNode is a folder (container) only when it has no blob content — the
|
||||
// server stores it with `file == null`. Sending a blobId/type/size on create
|
||||
// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that
|
||||
// nothing can ever be parented under (#379). The webmail migrates those
|
||||
// legacy flat-named nodes into the real parentId hierarchy on load; this app
|
||||
// reads and writes only the real hierarchy.
|
||||
export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
|
||||
return node.blobId == null;
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
fileNode: Partial<FileNode>,
|
||||
): Promise<FileNode> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', {
|
||||
accountId,
|
||||
create: { 'new-file': fileNode },
|
||||
}, '0']],
|
||||
USING,
|
||||
// Stalwart omits shareWith/myRights from FileNode/get unless they are
|
||||
// requested explicitly, so the share sheet and indicators must name them here.
|
||||
const FILE_NODE_PROPERTIES = [
|
||||
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated',
|
||||
'shareWith', 'myRights',
|
||||
];
|
||||
|
||||
export function supportsSharing(): boolean {
|
||||
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
|
||||
}
|
||||
|
||||
function filesAccountId(): string {
|
||||
return (
|
||||
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
|
||||
jmapClient.accountId
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
}
|
||||
|
||||
function fileUsing(): string[] {
|
||||
const using: string[] = [CAPABILITIES.CORE];
|
||||
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
|
||||
using.push(CAPABILITIES.FILES);
|
||||
}
|
||||
// Required for shareWith/myRights on FileNode and for cross-account
|
||||
// (shared-with-me) FileNode/get.
|
||||
if (supportsSharing()) {
|
||||
using.push(CAPABILITIES.PRINCIPALS_OWNER);
|
||||
}
|
||||
return using;
|
||||
}
|
||||
|
||||
// Nodes fetched from another principal's account are namespaced
|
||||
// "accountId:nodeId" so they can't collide with the primary account's ids.
|
||||
// JMAP ids never contain ':', so the separator unambiguously marks a shared
|
||||
// (cross-account) node.
|
||||
export function isCrossAccountId(id: string | null | undefined): boolean {
|
||||
return id != null && id.includes(':');
|
||||
}
|
||||
|
||||
// Fetch every FileNode in the account, files AND folders, to build the
|
||||
// hierarchy client-side from parentId links.
|
||||
//
|
||||
// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT
|
||||
// FileNode/query — Stalwart's query only returns leaf files and omits folder
|
||||
// nodes entirely, which would make every folder invisible.
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0];
|
||||
if (!result || result[0] === 'error') {
|
||||
throw new Error(result?.[1]?.description || 'FileNode list failed');
|
||||
}
|
||||
return (result[1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
|
||||
// account that advertises the filenode capability or is non-personal, since
|
||||
// Stalwart doesn't always advertise capabilities on shared accounts.
|
||||
function filesCapableAccountIds(): string[] {
|
||||
const primaryId = filesAccountId();
|
||||
const accounts = jmapClient.currentSession?.accounts ?? {};
|
||||
const rest = Object.entries(accounts)
|
||||
.filter(([id, info]) =>
|
||||
id !== primaryId &&
|
||||
(info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal))
|
||||
.map(([id]) => id);
|
||||
return [primaryId, ...rest];
|
||||
}
|
||||
|
||||
// Fetch every FileNode the logged-in user can see across all accessible
|
||||
// accounts. Nodes owned by another principal (shared with the user) are
|
||||
// tagged `isShared: true` with the owning accountId/accountName, and their
|
||||
// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's
|
||||
// listAllFileNodesAcrossAccounts.
|
||||
export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||||
const primaryId = filesAccountId();
|
||||
const accounts = jmapClient.currentSession?.accounts ?? {};
|
||||
const all: FileNode[] = [];
|
||||
|
||||
for (const accountId of filesCapableAccountIds()) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0];
|
||||
if (!result || result[0] === 'error') continue;
|
||||
for (const node of (result[1].list ?? []) as FileNode[]) {
|
||||
all.push({
|
||||
...node,
|
||||
id: isPrimary ? node.id : `${accountId}:${node.id}`,
|
||||
parentId: node.parentId == null
|
||||
? null
|
||||
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
|
||||
accountId,
|
||||
accountName: accounts[accountId]?.name || accountId,
|
||||
isShared: !isPrimary,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// A single unreachable shared account shouldn't hide the user's own
|
||||
// files; skip it and keep aggregating.
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
const accountId = filesAccountId();
|
||||
const props: Record<string, unknown> = { name };
|
||||
if (parentId !== null) props.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
const created = result.created?.['new-dir'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-dir'];
|
||||
throw new Error(err?.description || 'Create folder failed');
|
||||
}
|
||||
return { ...props, ...created } as FileNode;
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
export async function renameFileNode(id: string, newName: string): Promise<void> {
|
||||
await updateFileNode(id, { name: newName });
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
if (ids.length === 0) return;
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', {
|
||||
accountId,
|
||||
destroy: ids,
|
||||
// The server removes descendant nodes of destroyed folders.
|
||||
onDestroyRemoveChildren: true,
|
||||
}, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const notDestroyed = res.methodResponses[0][1].notDestroyed as
|
||||
| Record<string, { description?: string }>
|
||||
| undefined;
|
||||
const failedIds = Object.keys(notDestroyed ?? {});
|
||||
if (failedIds.length > 0) {
|
||||
throw new Error(
|
||||
notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
const accountId = filesAccountId();
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const type = blob.type || mimeType || 'application/octet-stream';
|
||||
const props: Record<string, unknown> = {
|
||||
name,
|
||||
// Stalwart caps the stored MIME type; very long types fail the create.
|
||||
type: type.length > 30 ? 'application/octet-stream' : type,
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
};
|
||||
if (parentId !== null) props.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
const created = result.created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Upload failed');
|
||||
}
|
||||
return { ...props, ...created } as FileNode;
|
||||
}
|
||||
|
||||
// ── Sharing (RFC 9670) ────────────────────────────────────
|
||||
|
||||
// Add, update, or remove a principal's rights on an owned FileNode (file or
|
||||
// folder). Pass rights: null to revoke. Stalwart applies it via a
|
||||
// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares
|
||||
// its whole subtree — which is why this app must store real parentId
|
||||
// hierarchy rather than the legacy flat-name encoding.
|
||||
export async function setFileNodeShare(
|
||||
fileNodeId: string,
|
||||
principalId: string,
|
||||
rights: FileNodeRights | null,
|
||||
): Promise<void> {
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', {
|
||||
accountId,
|
||||
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
if (result.notUpdated?.[fileNodeId]) {
|
||||
throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share');
|
||||
}
|
||||
if (!result.updated || !(fileNodeId in result.updated)) {
|
||||
throw new Error('Server did not confirm the share update');
|
||||
}
|
||||
}
|
||||
|
||||
// List all principals visible to the user. Stalwart returns the full
|
||||
// directory regardless of `filter`, so callers filter client-side.
|
||||
export async function getPrincipals(): Promise<Principal[]> {
|
||||
if (!supportsSharing()) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['Principal/query', { accountId }, '0'],
|
||||
['Principal/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
|
||||
}, '1'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
|
||||
);
|
||||
const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get');
|
||||
return (getResp?.[1].list ?? []) as Principal[];
|
||||
}
|
||||
|
||||
// The principal id that represents the logged-in user (excluded from the
|
||||
// share picker — sharing with yourself is a no-op the server may reject).
|
||||
export function ownPrincipalId(): string {
|
||||
return filesAccountId();
|
||||
}
|
||||
|
||||
+266
-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 {
|
||||
@@ -47,13 +60,23 @@ export class JMAPClient {
|
||||
return this.credentials?.serverUrl ?? null;
|
||||
}
|
||||
|
||||
// True when the session authenticates with a Bearer token (OAuth handoff or
|
||||
// token login) rather than a username/password. Used by the security screen
|
||||
// to hide password/TOTP management, which only applies to password accounts.
|
||||
get usesBearerAuth(): boolean {
|
||||
return !!this.credentials?.accessToken;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
|
||||
// ── 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 +112,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 +244,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 +311,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 +333,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 +361,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 +412,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 +458,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 +527,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 +548,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) {
|
||||
|
||||
+66
-18
@@ -24,34 +24,60 @@ export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
|
||||
// want and accept whatever Stalwart returns.
|
||||
expires?: string;
|
||||
}): Promise<string> {
|
||||
const created: Record<string, unknown> = {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
};
|
||||
if (params.expires) created.expires = params.expires;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{
|
||||
create: {
|
||||
new: {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ create: { new: created } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
const created = body?.created?.new as { id?: string } | undefined;
|
||||
if (!created?.id) {
|
||||
const result = body?.created?.new as { id?: string } | undefined;
|
||||
if (!result?.id) {
|
||||
const notCreated = body?.notCreated?.new;
|
||||
throw new Error(
|
||||
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
|
||||
);
|
||||
}
|
||||
return created.id;
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if
|
||||
* the server rejected the update (e.g. the subscription no longer exists),
|
||||
* which the caller treats as a signal to recreate.
|
||||
*/
|
||||
export async function updatePushSubscription(
|
||||
id: string,
|
||||
patch: { expires?: string; types?: string[] },
|
||||
): Promise<boolean> {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{ update: { [id]: patch } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
if (body?.notUpdated?.[id]) return false;
|
||||
return body?.updated?.[id] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,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) => {
|
||||
@@ -175,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import type { SieveScript, SieveCapabilities } from '../lib/sieve/types';
|
||||
|
||||
// JMAP Sieve (RFC 9661) bindings. Mirrors the webmail JMAPClient Sieve methods
|
||||
// but follows the mobile convention of a functional api/* module driving the
|
||||
// shared `jmapClient` singleton (see api/vacation.ts, api/blob.ts).
|
||||
|
||||
const SIEVE_USING = [CAPABILITIES.CORE, CAPABILITIES.SIEVE];
|
||||
|
||||
function requireSession() {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
return session;
|
||||
}
|
||||
|
||||
// Sieve lives on its own JMAP account (RFC 9661). Fall back to the mail account
|
||||
// when the server does not advertise a dedicated one (Stalwart uses the same id).
|
||||
export function getSieveAccountId(): string {
|
||||
const session = jmapClient.currentSession;
|
||||
return session?.primaryAccounts?.[CAPABILITIES.SIEVE] ?? jmapClient.accountId;
|
||||
}
|
||||
|
||||
export function isSieveSupported(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
return CAPABILITIES.SIEVE in (session.capabilities ?? {});
|
||||
}
|
||||
|
||||
export function getSieveCapabilities(): SieveCapabilities | null {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return null;
|
||||
const info = session.accounts?.[getSieveAccountId()];
|
||||
const caps = info?.accountCapabilities?.[CAPABILITIES.SIEVE];
|
||||
return (caps as SieveCapabilities) ?? null;
|
||||
}
|
||||
|
||||
export async function getSieveScripts(): Promise<SieveScript[]> {
|
||||
const res = await jmapClient.request(
|
||||
[['SieveScript/get', { accountId: getSieveAccountId() }, '0']],
|
||||
SIEVE_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/get') {
|
||||
return ((resp[1] as { list?: SieveScript[] }).list ?? []) as SieveScript[];
|
||||
}
|
||||
throw new Error('Failed to fetch Sieve scripts');
|
||||
}
|
||||
|
||||
export async function getSieveScriptContent(blobId: string): Promise<string> {
|
||||
const session = requireSession();
|
||||
const url = session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(getSieveAccountId()))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent('script.sieve'))
|
||||
.replace('{type}', encodeURIComponent('application/sieve'));
|
||||
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: jmapClient.authHeader },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function uploadSieveBlob(content: string): Promise<string> {
|
||||
const session = requireSession();
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(getSieveAccountId()),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sieve',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
// A plain string body is encoded as UTF-8 by RN's fetch. (api/blob.ts uses
|
||||
// an ArrayBuffer only because typed-array bodies get stringified there.)
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Failed to upload Sieve script: ${response.status}${detail ? ` ${detail.substring(0, 200)}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string };
|
||||
if (typeof direct.blobId === 'string') return direct.blobId;
|
||||
const nested = raw[getSieveAccountId()] as { blobId?: string } | undefined;
|
||||
if (nested?.blobId) return nested.blobId;
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
export async function createSieveScript(
|
||||
name: string,
|
||||
content: string,
|
||||
activate = true,
|
||||
): Promise<SieveScript> {
|
||||
const blobId = await uploadSieveBlob(content);
|
||||
const accountId = getSieveAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
create: { 'new-script': { name, blobId } },
|
||||
};
|
||||
if (activate) setArgs.onSuccessActivateScript = '#new-script';
|
||||
|
||||
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/set') {
|
||||
const result = resp[1] as {
|
||||
notCreated?: Record<string, { description?: string }>;
|
||||
created?: Record<string, { id?: string }>;
|
||||
};
|
||||
if (result.notCreated?.['new-script']) {
|
||||
throw new Error(result.notCreated['new-script'].description ?? 'Failed to create Sieve script');
|
||||
}
|
||||
const createdId = result.created?.['new-script']?.id;
|
||||
if (createdId) {
|
||||
const scripts = await getSieveScripts();
|
||||
const script = scripts.find((s) => s.id === createdId);
|
||||
if (script) return script;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to create Sieve script');
|
||||
}
|
||||
|
||||
export async function updateSieveScript(
|
||||
scriptId: string,
|
||||
content: string,
|
||||
activate = true,
|
||||
): Promise<void> {
|
||||
const blobId = await uploadSieveBlob(content);
|
||||
const accountId = getSieveAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
update: { [scriptId]: { blobId } },
|
||||
};
|
||||
if (activate) setArgs.onSuccessActivateScript = scriptId;
|
||||
|
||||
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/set') {
|
||||
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
|
||||
if (result.notUpdated?.[scriptId]) {
|
||||
throw new Error(result.notUpdated[scriptId].description ?? 'Failed to update Sieve script');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to update Sieve script');
|
||||
}
|
||||
|
||||
export async function deleteSieveScript(scriptId: string): Promise<void> {
|
||||
const res = await jmapClient.request(
|
||||
[['SieveScript/set', { accountId: getSieveAccountId(), destroy: [scriptId] }, '0']],
|
||||
SIEVE_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/set') {
|
||||
const result = resp[1] as { notDestroyed?: Record<string, { description?: string }> };
|
||||
if (result.notDestroyed?.[scriptId]) {
|
||||
throw new Error(result.notDestroyed[scriptId].description ?? 'Failed to delete Sieve script');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to delete Sieve script');
|
||||
}
|
||||
|
||||
export async function validateSieveScript(
|
||||
content: string,
|
||||
): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
const blobId = await uploadSieveBlob(content);
|
||||
const res = await jmapClient.request(
|
||||
[['SieveScript/validate', { accountId: getSieveAccountId(), blobId }, '0']],
|
||||
SIEVE_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/validate') {
|
||||
const result = resp[1] as { error?: { description?: string } };
|
||||
if (result.error) {
|
||||
return { isValid: false, errors: [result.error.description ?? 'Validation failed'] };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
if (resp && typeof resp[0] === 'string' && resp[0].endsWith('error')) {
|
||||
const error = resp[1] as { description?: string };
|
||||
return { isValid: false, errors: [error.description ?? 'Validation failed'] };
|
||||
}
|
||||
return { isValid: false, errors: ['Unexpected validation response'] };
|
||||
}
|
||||
+164
-4
@@ -9,12 +9,19 @@ 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 {
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
isReadOnly: boolean;
|
||||
// Per-account capability objects (RFC 8620 §2). Optional because not every
|
||||
// server populates it; the Sieve panel reads its limits/extensions from here.
|
||||
accountCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type JMAPMethodCall = [string, Record<string, unknown>, string];
|
||||
@@ -191,6 +198,8 @@ export interface ContactAddress {
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
fullAddress?: string;
|
||||
coordinates?: string;
|
||||
timeZone?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
pref?: number;
|
||||
@@ -276,6 +285,30 @@ export interface ContactLink {
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactCryptoKey {
|
||||
uri: string;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactDirectory {
|
||||
uri: string;
|
||||
kind?: 'directory' | 'entry' | string;
|
||||
mediaType?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactPronouns {
|
||||
pronouns: string;
|
||||
pref?: number;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactSpeakToAs {
|
||||
grammaticalGender?: string;
|
||||
pronouns?: Record<string, ContactPronouns>;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
id: string;
|
||||
originalId?: string;
|
||||
@@ -298,8 +331,16 @@ export interface ContactCard {
|
||||
media?: Record<string, ContactMedia>;
|
||||
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;
|
||||
}
|
||||
@@ -328,16 +369,43 @@ 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 {
|
||||
'@type'?: 'RecurrenceRule';
|
||||
frequency: string;
|
||||
interval?: number;
|
||||
until?: string;
|
||||
@@ -352,6 +420,8 @@ export interface RecurrenceRule {
|
||||
bySecond?: number[];
|
||||
bySetPosition?: number[];
|
||||
firstDayOfWeek?: string;
|
||||
rscale?: string;
|
||||
skip?: string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
@@ -375,27 +445,80 @@ 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;
|
||||
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
|
||||
// when `calendarIds` is remapped to namespaced store ids for shared events.
|
||||
originalCalendarIds?: Record<string, boolean>;
|
||||
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;
|
||||
// Client-only: JMAP account the event was fetched from. Absent for the
|
||||
// primary account; set for events on calendars shared with the user so
|
||||
// mutations can be routed to the owning account.
|
||||
accountId?: string;
|
||||
// Client-only: true when the event lives on a calendar shared with the user.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
// Client-only: JMAP account the calendar was fetched from. Absent for the
|
||||
// primary account; set for calendars shared with the user.
|
||||
accountId?: string;
|
||||
// Client-only: the real JMAP id of a shared calendar, before its `id` was
|
||||
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
|
||||
// and event queries must use this raw id against the owning account.
|
||||
originalId?: string;
|
||||
// Client-only: true when the calendar lives in another account (shared
|
||||
// with the user by its owner).
|
||||
isShared?: boolean;
|
||||
// Client-only: set when `color` has been replaced by the viewer's local
|
||||
// override for a shared calendar (see lib/calendar-utils). When true, the
|
||||
// override wins over per-event colors so the whole shared calendar paints
|
||||
// uniformly.
|
||||
colorIsLocalOverride?: boolean;
|
||||
}
|
||||
|
||||
// ─── Files (FileNode) ───────────────────────────────────
|
||||
@@ -405,10 +528,44 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||
// principals capability and the properties are requested explicitly. A node
|
||||
// is shared-out when `shareWith` has entries; `myRights` describes what the
|
||||
// viewer may do (always full rights on owned nodes).
|
||||
myRights?: FileNodeRights;
|
||||
shareWith?: Record<string, FileNodeRights> | null;
|
||||
// True when this node was fetched from another principal's account that was
|
||||
// shared with the logged-in user ("Shared with me").
|
||||
isShared?: boolean;
|
||||
// Owning account's JMAP id and display name, set when aggregating nodes
|
||||
// across accounts so blob downloads route to the right account.
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
}
|
||||
|
||||
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
|
||||
export interface FileNodeRights {
|
||||
mayRead: boolean;
|
||||
mayAddChildren: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
mayModifyContent: boolean;
|
||||
mayShare: boolean;
|
||||
}
|
||||
|
||||
// RFC 9670 principal — an entity (user, group, resource) that files and
|
||||
// calendars can be shared with. On Stalwart the principal id doubles as the
|
||||
// account id of that user/group.
|
||||
export interface Principal {
|
||||
id: string;
|
||||
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
// ─── Push / SSE ─────────────────────────────────────────
|
||||
@@ -441,4 +598,7 @@ 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',
|
||||
PRINCIPALS: 'urn:ietf:params:jmap:principals',
|
||||
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
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;
|
||||
/**
|
||||
* JMAP account id the message lives under. Equals the registry account's
|
||||
* primary JMAP account for own mail, or a group/shared owner account when
|
||||
* the message came from a group inbox. Carried so opening can target the
|
||||
* right account via an Email/get override.
|
||||
*/
|
||||
jmapAccountId: string;
|
||||
/** True when this message belongs to a group/shared inbox, not the user's own. */
|
||||
isShared: boolean;
|
||||
/** Human label for the owning account when shared (e.g. "Support"). */
|
||||
sharedLabel?: 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 ?? [];
|
||||
}
|
||||
|
||||
// Fetch the most recent inbox messages for a single JMAP account reachable via
|
||||
// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates
|
||||
// them with registry/sharing metadata.
|
||||
async function fetchInboxEmailsForJmapAccount(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
jmapAccountId: string,
|
||||
limit: number,
|
||||
): Promise<Email[]> {
|
||||
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 [];
|
||||
|
||||
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],
|
||||
);
|
||||
return (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
limit: number,
|
||||
includeGroup: boolean,
|
||||
): 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 primaryJmapId = resolveJmapAccountId(session);
|
||||
if (!primaryJmapId) throw new Error('No mail account in session');
|
||||
|
||||
// Always include the user's own primary inbox. When group inboxes are on,
|
||||
// also scan every other (non-personal / shared) account reachable in the
|
||||
// session — these are the group/shared mailboxes the user belongs to.
|
||||
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
|
||||
{ jmapId: primaryJmapId, isShared: false },
|
||||
];
|
||||
if (includeGroup) {
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryJmapId) continue;
|
||||
// Only accounts that expose the Mail capability can have an inbox.
|
||||
const hasMail =
|
||||
info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {});
|
||||
if (!hasMail) continue;
|
||||
targets.push({ jmapId: id, isShared: true, label: info.name });
|
||||
}
|
||||
}
|
||||
|
||||
const perTarget = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit);
|
||||
return list.map<UnifiedEmail>((e) => ({
|
||||
...e,
|
||||
sourceAccountId: accountId,
|
||||
jmapAccountId: target.jmapId,
|
||||
isShared: target.isShared,
|
||||
sharedLabel: target.isShared ? target.label : undefined,
|
||||
}));
|
||||
} catch {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
// account's inbox; skip it and keep the others.
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return perTarget.flat();
|
||||
}
|
||||
|
||||
export async function fetchUnifiedInbox(
|
||||
accountIds: string[],
|
||||
perAccountLimit = 25,
|
||||
opts: { includeGroup?: boolean } = {},
|
||||
): Promise<UnifiedInboxResult> {
|
||||
const { includeGroup = false } = opts;
|
||||
const errors: Record<string, string> = {};
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
try {
|
||||
return await fetchInboxForAccount(id, perAccountLimit, includeGroup);
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
interface ButtonProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
|
||||
@@ -3,11 +3,12 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||||
import type { Email } from '../api/types';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } 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.
|
||||
@@ -26,6 +27,15 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
interface EmailBodyViewProps {
|
||||
email: Email;
|
||||
senderEmail?: string;
|
||||
// The body is a native WebView, which on Android swallows horizontal touches
|
||||
// before the surrounding pager can see them. We detect a clear horizontal
|
||||
// swipe inside the page and report it here so the pager can change messages.
|
||||
onSwipe?: (direction: 'prev' | 'next') => void;
|
||||
// Pinch-zoom state from inside the page: `pinching` while two fingers are
|
||||
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
|
||||
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
|
||||
// scroll the pane away or switch mails.
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
}
|
||||
|
||||
function extractHtmlBody(email: Email): string | null {
|
||||
@@ -44,29 +54,188 @@ function extractTextBody(email: Email): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
|
||||
// heart -> cyan) and stylesheet-defined background images, which the static CSS
|
||||
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
|
||||
// those so they keep their original colors. Only injected when inversion is on.
|
||||
const DARK_REINVERT_SCRIPT = `
|
||||
(function () {
|
||||
if (document.__rnDarkInvertDone) return;
|
||||
var doc = document;
|
||||
var win = window;
|
||||
if (!doc.body) return;
|
||||
document.__rnDarkInvertDone = true;
|
||||
|
||||
// Re-invert elements with stylesheet-defined background images (CSS attribute
|
||||
// selectors only catch inline styles, not rules that survive in <style>).
|
||||
doc.body.querySelectorAll('*').forEach(function (el) {
|
||||
// Skip elements already handled by CSS attribute selectors.
|
||||
if (el.style.backgroundImage || el.style.background ||
|
||||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
|
||||
// Skip leaf media elements (already re-inverted by CSS).
|
||||
var tag = el.tagName;
|
||||
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
|
||||
var computed = win.getComputedStyle(el);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Only re-invert if this container doesn't have media children.
|
||||
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
|
||||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
|
||||
// run in a span that re-inverts. Only act when the ancestor invert depth is
|
||||
// odd - emojis inside a double-inverted bgcolor container already render at
|
||||
// their original colors.
|
||||
var emojiRe;
|
||||
try {
|
||||
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
|
||||
} catch (e) {
|
||||
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
|
||||
}
|
||||
var emojiTestRe = /\\p{Extended_Pictographic}/u;
|
||||
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
|
||||
|
||||
function isOddInvertDepth(start) {
|
||||
var count = 0;
|
||||
var n = start;
|
||||
while (n) {
|
||||
if (n === doc.body) { count++; break; }
|
||||
var cs = win.getComputedStyle(n);
|
||||
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
var p = node.parentElement;
|
||||
while (p) {
|
||||
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return emojiTestRe.test(node.nodeValue || '')
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
|
||||
var emojiTextNodes = [];
|
||||
var cur;
|
||||
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
|
||||
|
||||
emojiTextNodes.forEach(function (textNode) {
|
||||
var parent = textNode.parentElement;
|
||||
if (!parent || !isOddInvertDepth(parent)) return;
|
||||
var text = textNode.nodeValue || '';
|
||||
emojiRe.lastIndex = 0;
|
||||
var frag = doc.createDocumentFragment();
|
||||
var lastIndex = 0;
|
||||
var m;
|
||||
while ((m = emojiRe.exec(text)) !== null) {
|
||||
if (m.index > lastIndex) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
var span = doc.createElement('span');
|
||||
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
|
||||
span.textContent = m[0];
|
||||
frag.appendChild(span);
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
if (lastIndex === 0) return;
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
parent.replaceChild(frag, textNode);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const HEIGHT_REPORTER = `
|
||||
(function () {
|
||||
// One-time: wrap body contents in a block-level div we can measure reliably.
|
||||
// body.scrollHeight on Android WebView is pinned to the container viewport
|
||||
// height, so we can't trust it. A dedicated wrapper gives a deterministic
|
||||
// bounding box.
|
||||
var wrapperEl = null;
|
||||
function ensureWrapper() {
|
||||
var b = document.body;
|
||||
if (!b) return null;
|
||||
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
|
||||
var wrap = document.getElementById('__rn_email_body_wrap__');
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.id = '__rn_email_body_wrap__';
|
||||
wrap.style.display = 'block';
|
||||
wrap.style.width = '100%';
|
||||
wrap.style.transformOrigin = 'top left';
|
||||
while (b.firstChild) wrap.appendChild(b.firstChild);
|
||||
b.appendChild(wrap);
|
||||
}
|
||||
wrapperEl = wrap;
|
||||
return wrap;
|
||||
}
|
||||
// The width available for the email content (body content-box).
|
||||
function availWidth() {
|
||||
var b = document.body;
|
||||
var cs = window.getComputedStyle(b);
|
||||
var padL = parseFloat(cs.paddingLeft) || 0;
|
||||
var padR = parseFloat(cs.paddingRight) || 0;
|
||||
return b.clientWidth - padL - padR;
|
||||
}
|
||||
// Shared zoom state, mutated by the pinch-zoom handler injected after this
|
||||
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
|
||||
// on top of it, "tx" pans the zoomed overflow into view horizontally.
|
||||
// Vertical panning needs no state here: the height reports below carry the
|
||||
// scaled height, so the surrounding native ScrollView grows and scrolls.
|
||||
var Z = window.__rnZoom = window.__rnZoom || {
|
||||
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
|
||||
};
|
||||
// Apply the combined fit × pinch transform using the cached layout numbers
|
||||
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
|
||||
function applyTransform(w) {
|
||||
var s = Z.fit * Z.scale;
|
||||
if (Z.scale > 1.001 || Z.fit < 0.999) {
|
||||
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
|
||||
if (Z.tx < minTx) Z.tx = minTx;
|
||||
if (Z.tx > 0) Z.tx = 0;
|
||||
w.style.width = Z.layoutW + 'px';
|
||||
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
|
||||
} else {
|
||||
Z.tx = 0;
|
||||
w.style.width = '100%';
|
||||
w.style.transform = 'none';
|
||||
}
|
||||
}
|
||||
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
|
||||
// a phone viewport and force horizontal scrolling. Lay the content out at its
|
||||
// natural width, then scale the whole block down so its widest element fits
|
||||
// the screen — the way native mail clients do. No sideways scrolling.
|
||||
function fitToWidth(w) {
|
||||
if (!w) return;
|
||||
var avail = availWidth();
|
||||
if (avail <= 0) return;
|
||||
// Reset to full width so wrapping content reports its true overflow extent
|
||||
// (the widest unbreakable element) rather than a previously-scaled box.
|
||||
w.style.width = '100%';
|
||||
var content = w.scrollWidth;
|
||||
Z.avail = avail;
|
||||
Z.layoutW = content > avail + 1 ? content : avail;
|
||||
Z.fit = avail / Z.layoutW;
|
||||
applyTransform(w);
|
||||
}
|
||||
function measure() {
|
||||
var w = ensureWrapper();
|
||||
if (!w) return 0;
|
||||
// While a pinch is in flight only re-apply the transform — a full re-fit
|
||||
// resets the wrapper width and would thrash layout on every frame.
|
||||
if (Z.pinching) applyTransform(w);
|
||||
else fitToWidth(w);
|
||||
// getBoundingClientRect reflects the applied transform, so r.height is the
|
||||
// already-scaled visual height of the content.
|
||||
var r = w.getBoundingClientRect();
|
||||
var b = document.body;
|
||||
var cs = window.getComputedStyle(b);
|
||||
@@ -116,7 +285,16 @@ const HEIGHT_REPORTER = `
|
||||
if (document.body) ro.observe(document.body);
|
||||
} catch (e) {}
|
||||
try {
|
||||
var mo = new MutationObserver(report);
|
||||
var mo = new MutationObserver(function (records) {
|
||||
for (var i = 0; i < records.length; i++) {
|
||||
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
|
||||
// otherwise scaling would re-trigger a measure and loop indefinitely.
|
||||
var rec = records[i];
|
||||
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
|
||||
report();
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||
} catch (e) {}
|
||||
try {
|
||||
@@ -125,16 +303,199 @@ const HEIGHT_REPORTER = `
|
||||
img.addEventListener('error', report);
|
||||
});
|
||||
} catch (e) {}
|
||||
// Hooks for the pinch-zoom handler script.
|
||||
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
|
||||
window.__rnReport = report;
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
|
||||
// Detect a clear horizontal flick inside the WebView and report it. Uses
|
||||
// capture-phase, passive listeners so it never blocks vertical scrolling, text
|
||||
// selection, or link taps — it only reacts to a quick, horizontal-dominant
|
||||
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
|
||||
const SWIPE_DETECTOR = `
|
||||
(function () {
|
||||
if (window.__rnSwipeInit) return;
|
||||
window.__rnSwipeInit = true;
|
||||
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
|
||||
document.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1) { tracking = false; return; }
|
||||
var t = e.touches[0];
|
||||
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
|
||||
}, { passive: true, capture: true });
|
||||
document.addEventListener('touchend', function (e) {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
|
||||
// content — never switch mails.
|
||||
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
|
||||
var t = e.changedTouches[0];
|
||||
if (!t) return;
|
||||
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
|
||||
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
|
||||
}
|
||||
}
|
||||
}, { passive: true, capture: true });
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
// Pinch zoom + pan for the email content, driven entirely in the page: native
|
||||
// WebView zoom can't work here because the WebView is auto-height inside a
|
||||
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
|
||||
// scales the measured wrapper (on top of the shrink-to-fit scale the height
|
||||
// reporter maintains) anchored at the fingers' horizontal midpoint; while
|
||||
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
|
||||
// still scroll the surrounding ScrollView, which sees the scaled height
|
||||
// through the regular height reports. Pinch moves call preventDefault so the
|
||||
// engine consumes the gesture and Android parents don't intercept it; `zoom`
|
||||
// messages let the RN side freeze the pane scroll / pager as a second guard.
|
||||
const PINCH_ZOOM = `
|
||||
(function () {
|
||||
if (window.__rnPinchInit) return;
|
||||
window.__rnPinchInit = true;
|
||||
var Z = window.__rnZoom = window.__rnZoom || {
|
||||
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
|
||||
};
|
||||
var MAX_ZOOM = 4;
|
||||
var pinch = null; // { d0, s0, cx0, tx0 }
|
||||
var pan = null; // { x0, tx0 }
|
||||
var rafPending = false;
|
||||
|
||||
function post(msg) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
function apply() {
|
||||
if (window.__rnApplyZoom) window.__rnApplyZoom();
|
||||
}
|
||||
// Height reports are rAF-throttled during the gesture so the RN container
|
||||
// keeps up with the growing content without re-measuring on every move.
|
||||
function scheduleReport() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
requestAnimationFrame(function () {
|
||||
rafPending = false;
|
||||
if (window.__rnReport) window.__rnReport();
|
||||
});
|
||||
}
|
||||
function dist(t0, t1) {
|
||||
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length === 2) {
|
||||
var t0 = e.touches[0], t1 = e.touches[1];
|
||||
pinch = {
|
||||
d0: dist(t0, t1) || 1,
|
||||
s0: Z.scale,
|
||||
cx0: (t0.clientX + t1.clientX) / 2,
|
||||
tx0: Z.tx,
|
||||
};
|
||||
pan = null;
|
||||
if (!Z.pinching) {
|
||||
Z.pinching = true;
|
||||
post({ type: 'zoom', pinching: true, scale: Z.scale });
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.touches.length === 1 && Z.scale > 1.001) {
|
||||
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
|
||||
} else {
|
||||
pan = null;
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
if (pinch && e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
var t0 = e.touches[0], t1 = e.touches[1];
|
||||
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
|
||||
if (z < 1) z = 1;
|
||||
if (z > MAX_ZOOM) z = MAX_ZOOM;
|
||||
// Keep the content under the fingers' midpoint stationary horizontally:
|
||||
// the wrapper-local point that started under cx0 must map back to the
|
||||
// current midpoint at the new scale. (applyTransform clamps the result.)
|
||||
var cx = (t0.clientX + t1.clientX) / 2;
|
||||
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
|
||||
Z.scale = z;
|
||||
Z.tx = cx - local * (Z.fit * z);
|
||||
apply();
|
||||
scheduleReport();
|
||||
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
|
||||
// No preventDefault: vertical-dominant drags must keep scrolling the
|
||||
// native ScrollView around the WebView. Horizontal-dominant drags stay
|
||||
// inside the WebView and pan here.
|
||||
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
|
||||
apply();
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (pinch && e.touches.length < 2) {
|
||||
pinch = null;
|
||||
// Snap back to the fitted layout when the pinch ends near scale 1.
|
||||
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
|
||||
Z.pinching = false;
|
||||
apply();
|
||||
if (window.__rnReport) window.__rnReport();
|
||||
post({ type: 'zoom', pinching: false, scale: Z.scale });
|
||||
// A finger left on screen continues as a pan.
|
||||
if (e.touches.length === 1 && Z.scale > 1.001) {
|
||||
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
|
||||
}
|
||||
}
|
||||
if (e.touches.length === 0) pan = null;
|
||||
}
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
|
||||
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
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 +506,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,20 +583,53 @@ 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]);
|
||||
|
||||
// Inversion is applied for HTML bodies in dark mode unless the email ships its
|
||||
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
|
||||
// stylesheet backgrounds) so it runs before the height reporter measures.
|
||||
const applyInversion =
|
||||
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
|
||||
const injectedJs = React.useMemo(
|
||||
() =>
|
||||
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
|
||||
+ SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[applyInversion],
|
||||
);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
const parsed = parseInt(e.nativeEvent.data, 10);
|
||||
const data = e.nativeEvent.data;
|
||||
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
|
||||
if (data.charCodeAt(0) === 123 /* '{' */) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
|
||||
onSwipe?.(msg.dir);
|
||||
} else if (msg.type === 'zoom') {
|
||||
onZoomChange?.({
|
||||
pinching: !!msg.pinching,
|
||||
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
|
||||
});
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(data, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) return;
|
||||
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
|
||||
};
|
||||
@@ -239,7 +637,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,19 +668,28 @@ 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}
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={() => {
|
||||
// A (re)load resets the page's zoom state to 1 — drop any host-side
|
||||
// scroll locks so they can't go stale (e.g. cid images arriving
|
||||
// reload the source mid-zoom).
|
||||
onZoomChange?.({ pinching: false, zoomed: false });
|
||||
// Re-inject after load to cover Android cases where
|
||||
// `injectedJavaScript` runs too early to see the final layout.
|
||||
// `injectedJavaScript` runs too early to see the final layout. The
|
||||
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
|
||||
// again here is a no-op once the body has been processed.
|
||||
setTimeout(() => {
|
||||
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
|
||||
webviewRef.current?.injectJavaScript(injectedJs);
|
||||
}, 50);
|
||||
}}
|
||||
scrollEnabled={false}
|
||||
// Native Android zoom would scale the viewport inside the fixed-size
|
||||
// container; pinch zoom is implemented in the page instead.
|
||||
setBuiltInZoomControls={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
setSupportMultipleWindows={false}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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 {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
|
||||
X, Check, type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Mailbox } from '../api/types';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
||||
return Folder;
|
||||
}
|
||||
|
||||
interface MoveSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
mailboxes: Mailbox[];
|
||||
/** Folder the email is currently in - shown with a check, not selectable. */
|
||||
currentMailboxId: string | null;
|
||||
onPick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MoveSheet({
|
||||
visible, onClose, mailboxes, currentMailboxId, onPick,
|
||||
}: MoveSheetProps) {
|
||||
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]);
|
||||
|
||||
const visibleNodes = React.useMemo(() => {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const expanded = new Set<string>();
|
||||
const collect = (nodes: MailboxNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.children.length > 0) {
|
||||
expanded.add(n.id);
|
||||
collect(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(tree);
|
||||
return flattenVisible(tree, expanded);
|
||||
}, [mailboxes]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>Move to folder</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={node.id}
|
||||
onPress={canTarget ? () => onPick(node.id) : undefined}
|
||||
disabled={!canTarget}
|
||||
style={({ pressed }) => [
|
||||
styles.moveRow,
|
||||
pressed && canTarget && styles.moveRowPressed,
|
||||
{ paddingLeft: spacing.lg + node.depth * 16 },
|
||||
]}
|
||||
>
|
||||
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
|
||||
<Text
|
||||
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{node.name}
|
||||
</Text>
|
||||
{isCurrent && <Check size={14} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { 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,
|
||||
},
|
||||
sheetHandleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: c.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
moveRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
moveRowPressed: { backgroundColor: c.surfaceHover },
|
||||
moveRowLabel: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
flex: 1,
|
||||
},
|
||||
moveRowLabelDisabled: {
|
||||
color: c.textMuted,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { CloudOff } from 'lucide-react-native';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useOutboxStore } from '../stores/outbox-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
@@ -14,12 +15,18 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const online = useNetworkStore((s) => s.online);
|
||||
// Pending offline mutations that will replay once we're back online.
|
||||
const queued = useOutboxStore((s) => s.entries.length);
|
||||
if (online) return null;
|
||||
const queuedHint = queued > 0
|
||||
? `${queued} change${queued === 1 ? '' : 's'} will sync when you reconnect`
|
||||
: null;
|
||||
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<CloudOff size={14} color={c.primaryForeground} />
|
||||
<Text style={styles.text} numberOfLines={1}>
|
||||
You are offline{hint ? ` — ${hint}` : ''}
|
||||
You are offline{suffix ? ` — ${suffix}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
// Fires once with the raw decoded QR string. The parent is responsible for
|
||||
// closing the modal and interpreting the payload.
|
||||
onScanned: (data: string) => void;
|
||||
}
|
||||
|
||||
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// Guards against the camera firing onBarcodeScanned dozens of times for the
|
||||
// same code before the modal tears down.
|
||||
const handled = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) handled.current = false;
|
||||
}, [visible]);
|
||||
|
||||
const handleBarcode = React.useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (handled.current) return;
|
||||
handled.current = true;
|
||||
onScanned(result.data);
|
||||
},
|
||||
[onScanned],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.container}>
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarcode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SafeAreaView style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
|
||||
<X size={24} color="#ffffff" />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Scan QR code</Text>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
|
||||
{permission?.granted ? (
|
||||
<View style={styles.frameWrap}>
|
||||
<View style={styles.frame} />
|
||||
<Text style={styles.hint}>
|
||||
Point your camera at the QR code shown on the webmail settings screen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.permissionWrap}>
|
||||
<Text style={styles.permissionText}>
|
||||
{permission && !permission.canAskAgain
|
||||
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
|
||||
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
|
||||
</Text>
|
||||
<Button variant="default" size="md" onPress={() => void requestPermission()}>
|
||||
Allow camera access
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
overlay: { flex: 1, justifyContent: 'flex-start' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
title: { ...typography.h3, color: '#ffffff' },
|
||||
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
|
||||
frame: {
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
permissionWrap: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.lg,
|
||||
paddingHorizontal: spacing.xxl,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
const MIN_HEIGHT = 220;
|
||||
|
||||
export type RichTextCommand =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strikeThrough'
|
||||
| 'insertUnorderedList'
|
||||
| 'insertOrderedList'
|
||||
| 'formatBlock:H1'
|
||||
| 'formatBlock:H2'
|
||||
| 'formatBlock:BLOCKQUOTE'
|
||||
| 'formatBlock:P'
|
||||
| 'justifyLeft'
|
||||
| 'justifyCenter'
|
||||
| 'justifyRight'
|
||||
| 'removeFormat'
|
||||
| 'undo'
|
||||
| 'redo';
|
||||
|
||||
export interface RichTextSelectionState {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikeThrough: boolean;
|
||||
ul: boolean;
|
||||
ol: boolean;
|
||||
blockquote: boolean;
|
||||
h1: boolean;
|
||||
h2: boolean;
|
||||
alignLeft: boolean;
|
||||
alignCenter: boolean;
|
||||
alignRight: boolean;
|
||||
link: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: RichTextSelectionState = {
|
||||
bold: false, italic: false, underline: false, strikeThrough: false,
|
||||
ul: false, ol: false, blockquote: false, h1: false, h2: false,
|
||||
alignLeft: false, alignCenter: false, alignRight: false, link: false,
|
||||
};
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
exec(command: RichTextCommand): void;
|
||||
insertLink(url: string, label?: string): void;
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
setHtml(html: string): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialHtml?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (html: string) => void;
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
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, 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') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').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) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [height, setHeight] = React.useState(MIN_HEIGHT);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
onBlurRef.current = onBlur;
|
||||
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c }),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const call = React.useCallback((script: string) => {
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
||||
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
||||
},
|
||||
insertLink: (url, label) => {
|
||||
call(`window.__rne && window.__rne.insertLink(${JSON.stringify(url)}, ${JSON.stringify(label ?? '')})`);
|
||||
},
|
||||
unsetLink: () => {
|
||||
call(`window.__rne && window.__rne.unsetLink()`);
|
||||
},
|
||||
insertImage: (src, cid, alt) => {
|
||||
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
||||
},
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
}), [call]);
|
||||
|
||||
const onMessage = (event: WebViewMessageEvent) => {
|
||||
let data: { type: string; payload: unknown };
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (data.type) {
|
||||
case 'change':
|
||||
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
||||
break;
|
||||
case 'height': {
|
||||
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
|
||||
break;
|
||||
case 'focus':
|
||||
onFocusRef.current?.();
|
||||
break;
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { height }]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['about:blank']}
|
||||
source={{ html }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled={false}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardDisplayRequiresUserAction={false}
|
||||
hideKeyboardAccessoryView
|
||||
setSupportMultipleWindows={false}
|
||||
thirdPartyCookiesEnabled={false}
|
||||
sharedCookiesEnabled={false}
|
||||
allowFileAccess={false}
|
||||
allowFileAccessFromFileURLs={false}
|
||||
allowUniversalAccessFromFileURLs={false}
|
||||
allowsBackForwardNavigationGestures={false}
|
||||
incognito
|
||||
cacheEnabled={false}
|
||||
mixedContentMode="never"
|
||||
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
||||
style={styles.webview}
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
// 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:');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default RichTextEditor;
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
webview: {
|
||||
backgroundColor: 'transparent',
|
||||
flex: 1,
|
||||
},
|
||||
webviewContainer: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -49,17 +51,17 @@ function iconFor(
|
||||
return Folder;
|
||||
}
|
||||
|
||||
const ROLE_COLOR: Record<string, string> = {
|
||||
const ROLE_COLOR_FIXED: Record<string, string> = {
|
||||
inbox: '#60a5fa',
|
||||
sent: '#4ade80',
|
||||
drafts: '#a78bfa',
|
||||
trash: c.textMuted,
|
||||
junk: '#f87171',
|
||||
archive: '#fbbf24',
|
||||
};
|
||||
|
||||
function iconColor(role: string | null | undefined, isSelected: boolean): string {
|
||||
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
|
||||
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
|
||||
if (role === 'trash') return c.textMuted;
|
||||
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
|
||||
return isSelected ? c.text : c.textSecondary;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
@@ -450,7 +472,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
return (
|
||||
<SidebarRow
|
||||
key={node.id}
|
||||
icon={<Icon size={16} color={iconColor(node.role, isSelected)} />}
|
||||
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
|
||||
label={node.name}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
@@ -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,
|
||||
|
||||
+216
-79
@@ -3,12 +3,12 @@ import {
|
||||
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
|
||||
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
|
||||
type LucideIcon,
|
||||
} 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;
|
||||
@@ -17,15 +17,28 @@ interface SwipeableRowProps {
|
||||
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
|
||||
rightAction: SwipeAction;
|
||||
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
|
||||
context: { unread: boolean; starred: boolean };
|
||||
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;
|
||||
}
|
||||
|
||||
const REVEAL_WIDTH = 88; // px shown when fully revealed
|
||||
const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing
|
||||
// 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 = REVEAL_WIDTH * 1.4;
|
||||
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;
|
||||
|
||||
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
|
||||
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
|
||||
@@ -33,23 +46,34 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg:
|
||||
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
|
||||
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
|
||||
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
|
||||
pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' },
|
||||
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
|
||||
};
|
||||
|
||||
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
|
||||
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
|
||||
if (action === 'read') return context.unread ? 'Read' : 'Unread';
|
||||
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
|
||||
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
|
||||
if (action === 'none') return '';
|
||||
return ACTION_META[action].defaultLabel;
|
||||
}
|
||||
|
||||
// Actions that visibly remove the row from the list (so the row should fly off
|
||||
// instead of snapping back). Toggle-style actions stay in place.
|
||||
function exitsRow(action: SwipeAction): boolean {
|
||||
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
|
||||
}
|
||||
|
||||
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]);
|
||||
const dx = useRef(new Animated.Value(0)).current;
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -59,16 +83,45 @@ export function SwipeableRow({
|
||||
setOpenSide(null);
|
||||
};
|
||||
|
||||
const openRight = () => {
|
||||
Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = 'right';
|
||||
setOpenSide('right');
|
||||
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 openLeft = () => {
|
||||
Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
openSideRef.current = 'left';
|
||||
setOpenSide('left');
|
||||
const fly = (toValue: number, action: SwipeAction) => {
|
||||
// For destructive/move actions: race the row off-screen and fire the
|
||||
// action - the parent will remove the row from the list. For toggle
|
||||
// actions: fire immediately and snap back so the same row can update in
|
||||
// place.
|
||||
if (exitsRow(action)) {
|
||||
Animated.timing(dx, {
|
||||
toValue,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
// The row is about to be removed; reset translation in case it isn't
|
||||
// (e.g. the action failed silently) so we don't leave it off-screen.
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
onAction(action);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const responder = useRef(
|
||||
@@ -77,8 +130,12 @@ 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 a side is already open, allow the gesture so the user can drag it closed.
|
||||
if (openSideRef.current === null) {
|
||||
// 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;
|
||||
}
|
||||
@@ -86,77 +143,151 @@ export function SwipeableRow({
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
|
||||
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(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next));
|
||||
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
|
||||
const finalDx = base + g.dx;
|
||||
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
openRight();
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
openLeft();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
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 {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
close();
|
||||
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 fireAction = (action: SwipeAction) => {
|
||||
close();
|
||||
if (action !== 'none') onAction(action);
|
||||
};
|
||||
|
||||
const renderBand = (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={() => fireAction(action)}
|
||||
style={[
|
||||
styles.band,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
|
||||
const iconScale = dx.interpolate({
|
||||
inputRange,
|
||||
outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.bandInstant,
|
||||
{ backgroundColor: meta.bg },
|
||||
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
|
||||
<Icon size={22} color="#fff" />
|
||||
<Text style={styles.bandLabel}>{label}</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const rightBandOpacity = dx.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [0, 0, 1],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
const leftBandOpacity = dx.interpolate({
|
||||
inputRange: [-1, 0, 1],
|
||||
outputRange: [1, 0, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
|
||||
{renderBand(rightAction, 'left')}
|
||||
{renderBand(leftAction, 'right')}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
|
||||
{renderInstantBand(rightAction, 'left')}
|
||||
</Animated.View>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
|
||||
{renderInstantBand(leftAction, 'right')}
|
||||
</Animated.View>
|
||||
<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>
|
||||
);
|
||||
@@ -164,16 +295,22 @@ export function SwipeableRow({
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { position: 'relative', backgroundColor: c.background },
|
||||
content: { backgroundColor: c.background },
|
||||
band: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
bandInner: { alignItems: 'center', gap: 4 },
|
||||
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
|
||||
const VISIBLE_MS = 5000;
|
||||
|
||||
export function UndoSnackbar() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const entry = useEmailStore((s) => s.pendingUndo);
|
||||
const undoLast = useEmailStore((s) => s.undoLast);
|
||||
const clearUndo = useEmailStore((s) => s.clearUndo);
|
||||
const slideY = React.useRef(new Animated.Value(120)).current;
|
||||
const opacity = React.useRef(new Animated.Value(0)).current;
|
||||
// Remember the last non-null entry so the bar's text/handlers stay valid
|
||||
// while it animates out after pendingUndo flips to null.
|
||||
const [shown, setShown] = React.useState(entry);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (entry) setShown(entry);
|
||||
}, [entry]);
|
||||
|
||||
// Drive the auto-dismiss off the entry's createdAt so re-renders during
|
||||
// animation don't reset the timer, and so consecutive actions reset it.
|
||||
React.useEffect(() => {
|
||||
if (!entry) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) setShown(null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
|
||||
const elapsed = Date.now() - entry.createdAt;
|
||||
const remaining = Math.max(0, VISIBLE_MS - elapsed);
|
||||
const id = setTimeout(() => {
|
||||
// Only clear if the entry is still the same one we scheduled for.
|
||||
if (useEmailStore.getState().pendingUndo === entry) {
|
||||
clearUndo();
|
||||
}
|
||||
}, remaining);
|
||||
return () => clearTimeout(id);
|
||||
}, [entry, slideY, opacity, clearUndo]);
|
||||
|
||||
if (!shown) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.wrap,
|
||||
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
||||
<Pressable
|
||||
onPress={() => { void undoLast(); }}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||
>
|
||||
<Text style={styles.undoText}>UNDO</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm + 2,
|
||||
paddingLeft: spacing.lg,
|
||||
paddingRight: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.text,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 8,
|
||||
},
|
||||
label: {
|
||||
...typography.body,
|
||||
color: c.background,
|
||||
flex: 1,
|
||||
},
|
||||
undoBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
undoBtnPressed: {
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
undoText: {
|
||||
...typography.bodySemibold,
|
||||
color: c.primary,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Download, X } from 'lucide-react-native';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
@@ -8,6 +9,7 @@ import { useColors } from '../theme/colors';
|
||||
export function UpdateBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const installing = useUpdatesStore((s) => s.installing);
|
||||
@@ -20,7 +22,7 @@ export function UpdateBanner(): React.ReactElement | null {
|
||||
if (!cachedLatest?.apkAsset) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
|
||||
<Download size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>Update available</Text>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
||||
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
|
||||
import { CalendarDays } from 'lucide-react-native';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
dayKey,
|
||||
eventsOnDayFromIndex,
|
||||
getEventStartDate,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { EventCard } from './EventCard';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface AgendaViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
eventsByDay?: EventDayIndex;
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export function AgendaView({
|
||||
events,
|
||||
calendars,
|
||||
eventsByDay,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
@@ -50,32 +53,35 @@ export function AgendaView({
|
||||
);
|
||||
|
||||
const sections = React.useMemo<DaySection[]>(() => {
|
||||
const result: DaySection[] = [];
|
||||
const start = new Date(fromDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
|
||||
// every day that actually has events, always include a "Today" anchor, and
|
||||
// render them sorted ascending. Driving the list off the events (rather than
|
||||
// a fixed day-counter) guarantees every upcoming loaded event appears - not
|
||||
// just the ones inside a brittle N-day window from the anchor.
|
||||
const anchor = startOfDay(fromDate);
|
||||
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
|
||||
|
||||
for (let i = 0; i < daysAhead; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
}
|
||||
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
});
|
||||
if (dayEvents.length === 0 && !isToday(day)) continue;
|
||||
result.push({
|
||||
const days = new Map<string, Date>();
|
||||
for (const key of index.keys()) {
|
||||
const day = parseISO(key); // index keys are local yyyy-MM-dd
|
||||
if (isNaN(day.getTime())) continue;
|
||||
if (day < anchor || day >= horizonEnd) continue;
|
||||
days.set(key, day);
|
||||
}
|
||||
// Always anchor "Today" so the view has a stable starting point even when
|
||||
// the current day has no events (webmail injects the same anchor).
|
||||
const today = startOfDay(new Date());
|
||||
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
|
||||
|
||||
return [...days.values()]
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((day) => ({
|
||||
title: formatDayHeader(day),
|
||||
date: day,
|
||||
data: dayEvents,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
|
||||
// start, then title), so no extra sort is needed here.
|
||||
data: eventsOnDayFromIndex(index, day),
|
||||
}));
|
||||
}, [fromDate, daysAhead, index]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
@@ -107,7 +113,7 @@ export function AgendaView({
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
|
||||
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
|
||||
</View>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
|
||||
@@ -11,11 +11,13 @@ 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, Shuffle, Star } 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 { getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
visible: boolean;
|
||||
@@ -23,6 +25,13 @@ interface CalendarSidebarDrawerProps {
|
||||
hiddenCalendarIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onClose: () => void;
|
||||
onImport?: () => void;
|
||||
onManageSubscriptions?: () => void;
|
||||
// Long-press actions. Set-default applies to the user's own calendars;
|
||||
// color change/reset applies to shared calendars (per-viewer recolor).
|
||||
onSetDefault?: (calendar: Calendar) => void;
|
||||
onSetColor?: (calendar: Calendar, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
@@ -31,48 +40,72 @@ export function CalendarSidebarDrawer({
|
||||
hiddenCalendarIds,
|
||||
onToggle,
|
||||
onClose,
|
||||
onImport,
|
||||
onManageSubscriptions,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
}: 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);
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
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();
|
||||
} else {
|
||||
setExpandedId(null);
|
||||
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]);
|
||||
|
||||
const myCalendars = calendars.filter((c) => !c.myRights || c.myRights.mayWrite !== false);
|
||||
const subscribed = calendars.filter((c) => c.myRights && c.myRights.mayWrite === false);
|
||||
const sharedCalendars = calendars.filter((cal) => cal.isShared);
|
||||
const myCalendars = calendars.filter(
|
||||
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
|
||||
);
|
||||
const subscribed = calendars.filter(
|
||||
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
|
||||
);
|
||||
|
||||
const sectionProps = {
|
||||
hiddenSet,
|
||||
onToggle,
|
||||
expandedId,
|
||||
onExpand: setExpandedId,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -103,21 +136,38 @@ export function CalendarSidebarDrawer({
|
||||
)}
|
||||
|
||||
{myCalendars.length > 0 && (
|
||||
<Section
|
||||
title="My calendars"
|
||||
calendars={myCalendars}
|
||||
hiddenSet={hiddenSet}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<Section title="My calendars" calendars={myCalendars} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{sharedCalendars.length > 0 && (
|
||||
<Section title="Shared with me" calendars={sharedCalendars} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{subscribed.length > 0 && (
|
||||
<Section
|
||||
title="Subscribed"
|
||||
calendars={subscribed}
|
||||
hiddenSet={hiddenSet}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{(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>
|
||||
@@ -131,11 +181,21 @@ function Section({
|
||||
calendars,
|
||||
hiddenSet,
|
||||
onToggle,
|
||||
expandedId,
|
||||
onExpand,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
}: {
|
||||
title: string;
|
||||
calendars: Calendar[];
|
||||
hiddenSet: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
expandedId: string | null;
|
||||
onExpand: (id: string | null) => void;
|
||||
onSetDefault?: (calendar: Calendar) => void;
|
||||
onSetColor?: (calendar: Calendar, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -144,30 +204,85 @@ function Section({
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{calendars.map((cal) => {
|
||||
const visible = !hiddenSet.has(cal.id);
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault;
|
||||
const canRecolor = !!onSetColor && !!cal.isShared;
|
||||
const hasActions = canSetDefault || canRecolor;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => onToggle(cal.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.swatch,
|
||||
{
|
||||
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
|
||||
borderColor: getCalendarColor(cal),
|
||||
},
|
||||
<View key={cal.id}>
|
||||
<Pressable
|
||||
onPress={() => onToggle(cal.id)}
|
||||
onLongPress={hasActions ? () => onExpand(expanded ? null : cal.id) : undefined}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
>
|
||||
{visible && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
|
||||
{cal.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View
|
||||
style={[
|
||||
styles.swatch,
|
||||
{
|
||||
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
|
||||
borderColor: getCalendarColor(cal),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{visible && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<Text style={[styles.rowName, !visible && styles.rowNameMuted]} numberOfLines={1}>
|
||||
{cal.name}
|
||||
</Text>
|
||||
{cal.isDefault && !cal.isShared && (
|
||||
<Star size={14} color={c.textMuted} fill={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.actionsPanel}>
|
||||
{canSetDefault && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onSetDefault!(cal); }}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Star size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>Set as default calendar</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canRecolor && (
|
||||
<>
|
||||
<View style={styles.paletteRow}>
|
||||
{CALENDAR_COLOR_PALETTE.map((color) => {
|
||||
const active = cal.color?.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
<Pressable
|
||||
key={color}
|
||||
onPress={() => { onExpand(null); onSetColor!(cal, color); }}
|
||||
style={[
|
||||
styles.paletteSwatch,
|
||||
{ backgroundColor: color },
|
||||
active && styles.paletteSwatchActive,
|
||||
]}
|
||||
>
|
||||
{active && <Check size={12} color={c.textInverse} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{!!onResetColor && cal.colorIsLocalOverride && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onResetColor(cal); }}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Shuffle size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>Random color</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -188,7 +303,7 @@ function makeStyles(c: ThemePalette) {
|
||||
left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
@@ -248,5 +363,58 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
rowName: { flex: 1, ...typography.body, color: c.text },
|
||||
rowNameMuted: { color: c.textMuted },
|
||||
|
||||
actionsPanel: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
panelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 40,
|
||||
},
|
||||
panelRowText: { ...typography.body, color: c.text },
|
||||
paletteRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
paletteSwatch: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: radius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
paletteSwatchActive: {
|
||||
borderWidth: 2,
|
||||
borderColor: c.text,
|
||||
},
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import { format } 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 { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onPress?: (event: CalendarEvent) => void;
|
||||
onLongPress?: (event: CalendarEvent) => void;
|
||||
}
|
||||
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
|
||||
return event.participants ? Object.keys(event.participants).length : 0;
|
||||
}
|
||||
|
||||
function formatTimeRange(event: CalendarEvent): string {
|
||||
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) return 'All day';
|
||||
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
const fmt = timePattern(timeFormat);
|
||||
return `${format(start, fmt)} – ${format(end, fmt)}`;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
||||
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const color = getEventColor(event, calendars);
|
||||
const time = formatTimeRange(event);
|
||||
const time = formatTimeRange(event, timeFormat);
|
||||
const count = participantCount(event);
|
||||
|
||||
return (
|
||||
@@ -79,7 +86,8 @@ function makeStyles(c: ThemePalette) {
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderBottomRightRadius: radius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -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';
|
||||
@@ -32,19 +36,31 @@ import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} 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[];
|
||||
timeFormat?: TimeFormat;
|
||||
onClose: () => void;
|
||||
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 {
|
||||
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) {
|
||||
if (
|
||||
@@ -56,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
const t = timePattern(timeFormat);
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
if (sameDay) {
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
|
||||
}
|
||||
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
|
||||
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
|
||||
}
|
||||
|
||||
function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
@@ -84,31 +101,19 @@ 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,
|
||||
timeFormat,
|
||||
onClose,
|
||||
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;
|
||||
@@ -156,10 +161,28 @@ export function EventDetailSheet({
|
||||
const color = getEventColor(event, calendars);
|
||||
const calendarId = getPrimaryCalendarId(event);
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const range = formatRange(event, timeFormat);
|
||||
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 +221,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 +250,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 +263,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}>
|
||||
@@ -238,7 +309,7 @@ export function EventDetailSheet({
|
||||
style={[
|
||||
styles.participantDot,
|
||||
{
|
||||
backgroundColor: statusColor(p.participationStatus),
|
||||
backgroundColor: statusColor(c, p.participationStatus),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -334,7 +405,40 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status?: string): string {
|
||||
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':
|
||||
return c.success;
|
||||
@@ -417,6 +521,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,23 @@ 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,
|
||||
RecurrenceRule,
|
||||
VirtualLocation,
|
||||
} from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
@@ -33,11 +43,19 @@ import {
|
||||
getEventStartDate,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
type Reminder,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
|
||||
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'none', label: 'Does not repeat' },
|
||||
@@ -45,16 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ 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' },
|
||||
{ value: 'custom', label: 'Custom…' },
|
||||
];
|
||||
|
||||
interface EventModalProps {
|
||||
@@ -94,46 +103,41 @@ function buildDuration(start: Date, end: Date): string {
|
||||
function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption {
|
||||
const rule = event?.recurrenceRules?.[0];
|
||||
if (!rule) return 'none';
|
||||
const f = rule.frequency.toLowerCase();
|
||||
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
|
||||
return f as RecurrenceOption;
|
||||
if (isSimpleRecurrenceRule(rule)) {
|
||||
const f = rule.frequency.toLowerCase();
|
||||
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
|
||||
return f as RecurrenceOption;
|
||||
}
|
||||
}
|
||||
return 'none';
|
||||
// Anything the simple presets can't represent is edited via the custom
|
||||
// recurrence editor.
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -160,12 +164,23 @@ export function EventModal({
|
||||
const [allDay, setAllDay] = React.useState(false);
|
||||
const [start, setStart] = React.useState<Date>(() => nextHalfHour(defaultDate ?? new Date()));
|
||||
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
|
||||
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
|
||||
// New events land in the explicitly requested calendar, else the account's
|
||||
// default calendar, else the first one.
|
||||
const fallbackCalendarId =
|
||||
defaultCalendarId
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
||||
|| calendars[0]?.id
|
||||
|| '';
|
||||
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
||||
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
|
||||
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
||||
const [alert, setAlert] = React.useState<AlertOption>('none');
|
||||
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
||||
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
||||
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);
|
||||
@@ -183,8 +198,13 @@ export function EventModal({
|
||||
setEnd(getEventEndDate(event));
|
||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||
setParticipants(event.participants || {});
|
||||
setRecurrence(detectRecurrence(event));
|
||||
setAlert(detectAlert(event));
|
||||
const detected = detectRecurrence(event);
|
||||
setRecurrence(detected);
|
||||
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setLocation(detectLocation(event));
|
||||
setVideoUrl(detectVideoUrl(event));
|
||||
} else {
|
||||
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
|
||||
setTitle('');
|
||||
@@ -192,10 +212,19 @@ export function EventModal({
|
||||
setAllDay(false);
|
||||
setStart(d);
|
||||
setEnd(addHours(d, 1));
|
||||
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
|
||||
setCalendarId(
|
||||
defaultCalendarId
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
||||
|| calendars[0]?.id
|
||||
|| '',
|
||||
);
|
||||
setParticipants({});
|
||||
setRecurrence('none');
|
||||
setAlert('none');
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders([]);
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
|
||||
|
||||
@@ -239,8 +268,16 @@ export function EventModal({
|
||||
: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
|
||||
participants: Object.keys(participants).length > 0 ? participants : undefined,
|
||||
recurrenceRules: recurrenceFromOption(recurrence),
|
||||
alerts: alertFromOption(alert),
|
||||
recurrenceRules:
|
||||
recurrence === 'custom'
|
||||
? customRule
|
||||
? [customRule]
|
||||
: undefined
|
||||
: recurrenceFromOption(recurrence),
|
||||
alerts: remindersToAlerts(reminders),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: buildLocations(location),
|
||||
virtualLocations: buildVirtualLocations(videoUrl),
|
||||
};
|
||||
await onSave(data, calendarId);
|
||||
onClose();
|
||||
@@ -254,8 +291,25 @@ 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 recurrenceLabel =
|
||||
recurrence === 'custom'
|
||||
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
|
||||
: RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.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
|
||||
@@ -412,8 +466,14 @@ export function EventModal({
|
||||
opt.value === recurrence && styles.popoverRowActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRecurrence(opt.value);
|
||||
setRecurrenceOpen(false);
|
||||
if (opt.value === 'custom') {
|
||||
setRecurrenceEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
setRecurrence(opt.value);
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
@@ -421,44 +481,99 @@ export function EventModal({
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{recurrenceEditorOpen && (
|
||||
<RecurrenceEditor
|
||||
rule={customRule}
|
||||
eventStart={start}
|
||||
onSave={(rule) => {
|
||||
setCustomRule(rule);
|
||||
setRecurrence('custom');
|
||||
setRecurrenceEditorOpen(false);
|
||||
}}
|
||||
onCancel={() => setRecurrenceEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
|
||||
<Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}>
|
||||
<Text style={styles.addRowText}>Edit custom recurrence</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<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}>
|
||||
{availablePresets.map((preset) => (
|
||||
<Pressable
|
||||
key={preset}
|
||||
style={styles.popoverRow}
|
||||
onPress={() => addReminder(preset)}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
|
||||
<Pressable
|
||||
<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}
|
||||
onPress={() => setAlertOpen((v) => !v)}
|
||||
>
|
||||
<Text style={styles.fieldText}>{alertLabel}</Text>
|
||||
</Pressable>
|
||||
{alertOpen && (
|
||||
<View style={styles.popover}>
|
||||
{ALERT_OPTIONS.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
style={[
|
||||
styles.popoverRow,
|
||||
opt.value === alert && styles.popoverRowActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setAlert(opt.value);
|
||||
setAlertOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</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 +744,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,370 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { RecurrenceRule } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button } from '..';
|
||||
import {
|
||||
EDITOR_FREQUENCIES,
|
||||
UNIT_LABELS,
|
||||
WEEKDAYS,
|
||||
buildRecurrenceSummary,
|
||||
buildRuleFromEditorValue,
|
||||
capitalize,
|
||||
editorValueFromRule,
|
||||
monthName,
|
||||
nthLabel,
|
||||
weekdayName,
|
||||
type EditorFrequency,
|
||||
type EndsMode,
|
||||
type MonthlyMode,
|
||||
type RecurrenceEditorValue,
|
||||
} from '../../lib/recurrence';
|
||||
|
||||
interface RecurrenceEditorProps {
|
||||
rule: RecurrenceRule | null;
|
||||
eventStart: Date;
|
||||
onSave: (rule: RecurrenceRule) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Small popover-style select, matching the EventModal field pattern.
|
||||
function InlineSelect<T extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
flex,
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (value: T) => void;
|
||||
flex?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
return (
|
||||
<View style={flex ? styles.selectFlex : undefined}>
|
||||
<Pressable style={styles.selectButton} onPress={() => setOpen((v) => !v)}>
|
||||
<Text style={styles.selectText} numberOfLines={1}>
|
||||
{selected?.label ?? String(value)}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
{open && (
|
||||
<View style={styles.popover}>
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={String(opt.value)}
|
||||
style={[styles.popoverRow, opt.value === value && styles.popoverRowActive]}
|
||||
onPress={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [text, setText] = React.useState(String(value));
|
||||
React.useEffect(() => setText(String(value)), [value]);
|
||||
return (
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
onEndEditing={() => {
|
||||
const n = parseInt(text, 10);
|
||||
const clamped = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
|
||||
setText(String(clamped));
|
||||
onChange(clamped);
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
style={styles.numberInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
|
||||
editorValueFromRule(rule, eventStart),
|
||||
);
|
||||
const [showUntilPicker, setShowUntilPicker] = React.useState(false);
|
||||
|
||||
const update = (patch: Partial<RecurrenceEditorValue>) =>
|
||||
setValue((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const toggleWeekDay = (day: string) => {
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
weekDays: prev.weekDays.includes(day)
|
||||
? prev.weekDays.length > 1
|
||||
? prev.weekDays.filter((d) => d !== day)
|
||||
: prev.weekDays
|
||||
: [...prev.weekDays, day],
|
||||
}));
|
||||
};
|
||||
|
||||
const builtRule = buildRuleFromEditorValue(value, eventStart);
|
||||
const summary = buildRecurrenceSummary(builtRule);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Every</Text>
|
||||
<NumberInput
|
||||
value={value.interval}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(interval) => update({ interval })}
|
||||
/>
|
||||
<InlineSelect<EditorFrequency>
|
||||
flex
|
||||
value={value.frequency}
|
||||
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))}
|
||||
onChange={(frequency) => update({ frequency })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{value.frequency === 'weekly' && (
|
||||
<View style={styles.weekdayRow}>
|
||||
{WEEKDAYS.map((day) => {
|
||||
const active = value.weekDays.includes(day);
|
||||
return (
|
||||
<Pressable
|
||||
key={day}
|
||||
onPress={() => toggleWeekDay(day)}
|
||||
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
|
||||
>
|
||||
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
|
||||
{weekdayName(day, 'short')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{value.frequency === 'yearly' && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>In</Text>
|
||||
<InlineSelect<number>
|
||||
flex
|
||||
value={value.month}
|
||||
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
|
||||
value: m,
|
||||
label: capitalize(monthName(m)),
|
||||
}))}
|
||||
onChange={(month) => update({ month })}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(value.frequency === 'monthly' || value.frequency === 'yearly') && (
|
||||
<View style={styles.row}>
|
||||
<InlineSelect<MonthlyMode>
|
||||
value={value.monthlyMode}
|
||||
options={[
|
||||
{ value: 'day', label: 'On day' },
|
||||
{ value: 'nth', label: 'On the' },
|
||||
]}
|
||||
onChange={(monthlyMode) => update({ monthlyMode })}
|
||||
/>
|
||||
{value.monthlyMode === 'day' ? (
|
||||
<NumberInput
|
||||
value={value.monthDay}
|
||||
min={1}
|
||||
max={31}
|
||||
onChange={(monthDay) => update({ monthDay })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<InlineSelect<number>
|
||||
flex
|
||||
value={value.nth}
|
||||
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))}
|
||||
onChange={(nth) => update({ nth })}
|
||||
/>
|
||||
<InlineSelect<string>
|
||||
flex
|
||||
value={value.nthDay}
|
||||
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))}
|
||||
onChange={(nthDay) => update({ nthDay })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Ends</Text>
|
||||
<InlineSelect<EndsMode>
|
||||
flex={value.endsMode === 'never'}
|
||||
value={value.endsMode}
|
||||
options={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: 'on', label: 'On date' },
|
||||
{ value: 'after', label: 'After' },
|
||||
]}
|
||||
onChange={(endsMode) => update({ endsMode })}
|
||||
/>
|
||||
{value.endsMode === 'on' && (
|
||||
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
|
||||
<Text style={styles.selectText}>
|
||||
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{value.endsMode === 'after' && (
|
||||
<>
|
||||
<NumberInput
|
||||
value={value.count}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(count) => update({ count })}
|
||||
/>
|
||||
<Text style={styles.muted}>occurrences</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.summary} numberOfLines={2}>
|
||||
{summary}
|
||||
</Text>
|
||||
<View style={styles.footerButtons}>
|
||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
|
||||
Done
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showUntilPicker && (
|
||||
<DateTimePicker
|
||||
value={parseISO(value.untilDate)}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={(_, d) => {
|
||||
setShowUntilPicker(false);
|
||||
if (d) update({ untilDate: format(d, 'yyyy-MM-dd') });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
rowLabel: { ...typography.body, color: c.textSecondary },
|
||||
muted: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
|
||||
|
||||
numberInput: {
|
||||
minWidth: 52,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
selectFlex: { flex: 1, minWidth: 0 },
|
||||
selectButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minHeight: 36,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
selectText: { ...typography.body, color: c.text, flexShrink: 1 },
|
||||
|
||||
popover: {
|
||||
marginTop: spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.background,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
popoverRowActive: { backgroundColor: c.primaryBg },
|
||||
popoverRowText: { ...typography.body, color: c.text },
|
||||
|
||||
weekdayRow: { flexDirection: 'row', gap: 4 },
|
||||
weekdayChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
},
|
||||
weekdayChipActive: {
|
||||
borderColor: c.primary,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
weekdayChipText: { ...typography.caption, color: c.textMuted },
|
||||
weekdayChipTextActive: { color: c.primary },
|
||||
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
summary: { ...typography.caption, color: c.textMuted },
|
||||
footerButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import type { ContactCard, Email, CalendarEvent } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { getEventColor } from '../../lib/calendar-utils';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
@@ -87,6 +88,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const calendarEvents = useCalendarStore((s) => s.events);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
|
||||
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
|
||||
const addressKey = addresses.join(',');
|
||||
@@ -145,74 +147,85 @@ export function ContactActivity({ contact }: Props) {
|
||||
<View style={styles.wrap}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MailIcon size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Recent emails</Text>
|
||||
</View>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<View style={styles.sectionIcon}>
|
||||
<MailIcon size={16} color={c.textMuted} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
<Text style={styles.sectionLabel}>Recent emails</Text>
|
||||
</View>
|
||||
<View style={styles.sectionBody}>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<CalendarDays size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
||||
<View style={styles.sectionIcon}>
|
||||
<CalendarDays size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.sectionLabel}>Upcoming events</Text>
|
||||
</View>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
<View style={styles.sectionBody}>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<View
|
||||
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -220,18 +233,18 @@ export function ContactActivity({ contact }: Props) {
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
||||
section: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: c.primary,
|
||||
wrap: { gap: spacing.lg },
|
||||
section: { gap: spacing.sm },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
sectionIcon: { width: 16 },
|
||||
sectionLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
|
||||
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
||||
emptyText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
@@ -251,10 +264,16 @@ function makeStyles(c: ThemePalette) {
|
||||
rowPreview: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
||||
eventDot: {
|
||||
width: componentSizes.eventDot,
|
||||
height: componentSizes.eventDot,
|
||||
borderRadius: componentSizes.eventDot / 2,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
eventTime: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
width: 64,
|
||||
width: 52,
|
||||
},
|
||||
eventInfo: { flex: 1, minWidth: 0 },
|
||||
eventTitle: { ...typography.body, color: c.text },
|
||||
|
||||
@@ -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,195 @@
|
||||
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();
|
||||
// Subscribe to the stable `contacts` array and derive the keyword list in a
|
||||
// memo. Passing `selectKeywordsUsed` straight to the store would return a
|
||||
// fresh array on every call, which trips useSyncExternalStore's snapshot
|
||||
// check and spins into an infinite render loop.
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const keywords = React.useMemo(() => selectKeywordsUsed(contacts), [contacts]);
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
|
||||
|
||||
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
|
||||
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
type RolePreset = 'read' | 'readWrite' | 'manager';
|
||||
|
||||
// Same presets the webmail's share dialog offers (#408). "custom" rights set
|
||||
// by another client are shown as-is and left untouched until the user picks a
|
||||
// preset for that principal.
|
||||
const FILE_PRESETS: Record<RolePreset, FileNodeRights> = {
|
||||
read: {
|
||||
mayRead: true, mayAddChildren: false, mayRename: false,
|
||||
mayDelete: false, mayModifyContent: false, mayShare: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayRead: true, mayAddChildren: true, mayRename: true,
|
||||
mayDelete: true, mayModifyContent: true, mayShare: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true, mayAddChildren: true, mayRename: true,
|
||||
mayDelete: true, mayModifyContent: true, mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
const PRESET_LABELS: Record<RolePreset, string> = {
|
||||
read: 'Viewer',
|
||||
readWrite: 'Editor',
|
||||
manager: 'Manager',
|
||||
};
|
||||
|
||||
const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager'];
|
||||
|
||||
function detectPreset(rights: FileNodeRights): RolePreset | 'custom' {
|
||||
for (const preset of PRESET_ORDER) {
|
||||
const expected = FILE_PRESETS[preset];
|
||||
const keys = Object.keys(expected) as (keyof FileNodeRights)[];
|
||||
if (keys.every((k) => expected[k] === (rights[k] ?? false))) return preset;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
interface ShareSheetProps {
|
||||
node: FileNode | null;
|
||||
onClose: () => void;
|
||||
/** Fired after any successful share change so the parent can refresh. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(false);
|
||||
const [shares, setShares] = useState<Record<string, FileNodeRights>>({});
|
||||
const [search, setSearch] = useState('');
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setShares(node.shareWith ?? {});
|
||||
setSearch('');
|
||||
setLoadingPrincipals(true);
|
||||
getPrincipals()
|
||||
.then(setPrincipals)
|
||||
.catch(() => setPrincipals([]))
|
||||
.finally(() => setLoadingPrincipals(false));
|
||||
}, [node]);
|
||||
|
||||
const principalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of principals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [principals]);
|
||||
|
||||
const sharedEntries = useMemo(
|
||||
() => Object.entries(shares).filter(([, rights]) => rights != null),
|
||||
[shares],
|
||||
);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const self = ownPrincipalId();
|
||||
const q = search.trim().toLowerCase();
|
||||
return principals
|
||||
.filter((p) => p.id !== self && shares[p.id] == null)
|
||||
.filter((p) =>
|
||||
!q ||
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.email?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q))
|
||||
.slice(0, 25);
|
||||
}, [principals, shares, search]);
|
||||
|
||||
const applyShare = useCallback(
|
||||
async (principalId: string, rights: FileNodeRights | null) => {
|
||||
if (!node || savingId) return;
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await setFileNodeShare(node.id, principalId, rights);
|
||||
setShares((prev) => {
|
||||
const next = { ...prev };
|
||||
if (rights == null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return next;
|
||||
});
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
},
|
||||
[node, savingId, onChanged],
|
||||
);
|
||||
|
||||
if (!node) return null;
|
||||
const Icon = isFolder(node) ? Folder : FileText;
|
||||
|
||||
const renderPrincipalLabel = (principalId: string) => {
|
||||
const p = principalsById.get(principalId);
|
||||
return (
|
||||
<View style={styles.principalInfo}>
|
||||
<Text style={styles.principalName} numberOfLines={1}>
|
||||
{p?.description || p?.name || principalId}
|
||||
</Text>
|
||||
{p?.email ? (
|
||||
<Text style={styles.principalEmail} numberOfLines={1}>
|
||||
{p.email}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.backdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Icon size={18} color={c.textMuted} />
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Share “{node.name}”
|
||||
</Text>
|
||||
</View>
|
||||
{isFolder(node) ? (
|
||||
<Text style={styles.subtitle}>
|
||||
Sharing a folder shares everything inside it.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{sharedEntries.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Shared with</Text>
|
||||
{sharedEntries.map(([principalId, rights]) => {
|
||||
const preset = detectPreset(rights);
|
||||
const busy = savingId === principalId;
|
||||
return (
|
||||
<View key={principalId} style={styles.shareRow}>
|
||||
{renderPrincipalLabel(principalId)}
|
||||
<View style={styles.roleChips}>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<Pressable
|
||||
key={p}
|
||||
onPress={() => void applyShare(principalId, FILE_PRESETS[p])}
|
||||
disabled={busy}
|
||||
style={[styles.chip, preset === p && styles.chipActive]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.chipText, preset === p && styles.chipTextActive]}
|
||||
>
|
||||
{PRESET_LABELS[p]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{preset === 'custom' ? (
|
||||
<Text style={styles.customLabel}>Custom</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => void applyShare(principalId, null)}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Remove access"
|
||||
>
|
||||
<Trash2 size={18} color={c.error} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
<Text style={styles.emptyText}>Not shared with anyone yet</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Add people</Text>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search by name or email"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{loadingPrincipals ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={c.textMuted}
|
||||
style={{ marginTop: spacing.sm }}
|
||||
/>
|
||||
) : candidates.length === 0 ? (
|
||||
<Text style={styles.noMatches}>
|
||||
{principals.length === 0 ? 'No other users found' : 'No matches'}
|
||||
</Text>
|
||||
) : (
|
||||
candidates.map((p) => (
|
||||
<Pressable
|
||||
key={p.id}
|
||||
onPress={() => void applyShare(p.id, FILE_PRESETS.read)}
|
||||
disabled={savingId != null}
|
||||
style={({ pressed }) => [
|
||||
styles.candidateRow,
|
||||
pressed && { backgroundColor: c.surfaceHover },
|
||||
]}
|
||||
>
|
||||
{renderPrincipalLabel(p.id)}
|
||||
{savingId === p.id ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<UserPlus size={18} color={c.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Pressable onPress={onClose} style={styles.doneBtn}>
|
||||
<Text style={styles.doneText}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xxl,
|
||||
paddingHorizontal: spacing.md,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
...typography.h3,
|
||||
color: c.text,
|
||||
flexShrink: 1,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
scroll: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
fontWeight: '600' as const,
|
||||
color: c.textMuted,
|
||||
textTransform: 'uppercase' as const,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
shareRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
principalInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
principalName: {
|
||||
fontSize: typography.body.fontSize,
|
||||
color: c.text,
|
||||
},
|
||||
principalEmail: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
roleChips: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: c.primaryBg,
|
||||
borderColor: c.primary,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
chipTextActive: {
|
||||
color: c.primary,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
customLabel: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
fontStyle: 'italic' as const,
|
||||
},
|
||||
emptyRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.body.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: c.text,
|
||||
fontSize: typography.body.fontSize,
|
||||
backgroundColor: c.surface,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
noMatches: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
candidateRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
doneBtn: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
doneText: {
|
||||
color: c.primaryForeground,
|
||||
fontWeight: '600' as const,
|
||||
fontSize: typography.body.fontSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, Plus, Trash2 } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Select, ToggleSwitch } from '../settings/settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
||||
import { buildMailboxTree, type MailboxNode } from '../../lib/mailbox-tree';
|
||||
import { generateUUID } from '../../lib/uuid';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
import type {
|
||||
FilterRule,
|
||||
FilterCondition,
|
||||
FilterAction,
|
||||
FilterConditionField,
|
||||
FilterComparator,
|
||||
FilterActionType,
|
||||
} from '../../lib/sieve/types';
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body'];
|
||||
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = ['move', 'copy', 'forward', 'mark_read', 'star', 'add_label', 'discard', 'reject', 'keep', 'stop'];
|
||||
|
||||
const ACTIONS_WITH_VALUE = new Set<FilterActionType>(['move', 'copy', 'forward', 'reject', 'add_label']);
|
||||
const ACTIONS_WITH_MAILBOX = new Set<FilterActionType>(['move', 'copy']);
|
||||
|
||||
function makeEmptyCondition(): FilterCondition {
|
||||
return { field: 'from', comparator: 'contains', value: '' };
|
||||
}
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: 'move', value: '' };
|
||||
}
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
visible: boolean;
|
||||
rule?: FilterRule;
|
||||
mailboxes: Mailbox[];
|
||||
onSave: (rule: FilterRule) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Flatten the mailbox tree into pickable options, building the Sieve-canonical
|
||||
// folder path for each (inbox -> "INBOX", not the localized display name).
|
||||
function buildMailboxOptions(mailboxes: Mailbox[]): { value: string; label: string }[] {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const walk = (nodes: MailboxNode[], parentPath: string) => {
|
||||
for (const node of nodes) {
|
||||
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
options.push({ value: fullPath, label: `${' '.repeat(node.depth * 3)}${node.name}` });
|
||||
if (node.children.length > 0) walk(node.children, fullPath);
|
||||
}
|
||||
};
|
||||
walk(tree, '');
|
||||
return options;
|
||||
}
|
||||
|
||||
export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: FilterRuleModalProps) {
|
||||
const c = useColors();
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const keywords = useKeywordsStore((s) => s.keywords);
|
||||
const isEdit = !!rule;
|
||||
|
||||
const [name, setName] = useState(rule?.name || '');
|
||||
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>(
|
||||
rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()],
|
||||
);
|
||||
const [actions, setActions] = useState<FilterAction[]>(
|
||||
rule?.actions.length ? [...rule.actions] : [makeEmptyAction()],
|
||||
);
|
||||
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false);
|
||||
|
||||
const mailboxOptions = useMemo(() => buildMailboxOptions(mailboxes), [mailboxes]);
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
|
||||
[t],
|
||||
);
|
||||
const actionTypeOptions = useMemo(
|
||||
() => ALL_ACTION_TYPES.map((a) => ({ value: a, label: t(`settings.filters.action_types.${a}`, a) })),
|
||||
[t],
|
||||
);
|
||||
const keywordOptions = useMemo(
|
||||
() => keywords.map((kw) => ({ value: kw.id, label: kw.label })),
|
||||
[keywords],
|
||||
);
|
||||
|
||||
const comparatorOptions = useCallback(
|
||||
(field: FilterConditionField) =>
|
||||
(field === 'size' ? SIZE_COMPARATORS : TEXT_COMPARATORS).map((cmp) => ({
|
||||
value: cmp,
|
||||
label: t(`settings.filters.comparators.${cmp}`, cmp),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const updateCondition = (index: number, updates: Partial<FilterCondition>) => {
|
||||
setConditions((prev) =>
|
||||
prev.map((cond, i) => {
|
||||
if (i !== index) return cond;
|
||||
const updated = { ...cond, ...updates };
|
||||
if (updates.field === 'size' && !SIZE_COMPARATORS.includes(cond.comparator)) {
|
||||
updated.comparator = 'greater_than';
|
||||
}
|
||||
if (updates.field && updates.field !== 'size' && SIZE_COMPARATORS.includes(cond.comparator)) {
|
||||
updated.comparator = 'contains';
|
||||
}
|
||||
if (updates.field && updates.field !== 'header') {
|
||||
delete updated.headerName;
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeCondition = (index: number) => {
|
||||
if (conditions.length <= 1) return;
|
||||
setConditions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateAction = (index: number, updates: Partial<FilterAction>) => {
|
||||
setActions((prev) =>
|
||||
prev.map((act, i) => {
|
||||
if (i !== index) return act;
|
||||
const updated = { ...act, ...updates };
|
||||
if (updates.type && !ACTIONS_WITH_VALUE.has(updates.type)) {
|
||||
delete updated.value;
|
||||
}
|
||||
if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) {
|
||||
updated.value = mailboxOptions[0]?.value ?? '';
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeAction = (index: number) => {
|
||||
if (actions.length <= 1) return;
|
||||
setActions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
Alert.alert(t('settings.filters.validation_empty_name', 'Rule name is required'));
|
||||
return;
|
||||
}
|
||||
const validConditions = conditions.filter((cond) => cond.value.trim());
|
||||
if (validConditions.length === 0) {
|
||||
Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required'));
|
||||
return;
|
||||
}
|
||||
const validActions = actions.filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim());
|
||||
if (validActions.length === 0) {
|
||||
Alert.alert(t('settings.filters.validation_empty_actions', 'At least one action is required'));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
id: rule?.id || generateUUID(),
|
||||
name: trimmedName,
|
||||
enabled: rule?.enabled ?? true,
|
||||
matchType,
|
||||
conditions: validConditions,
|
||||
actions: validActions,
|
||||
stopProcessing,
|
||||
});
|
||||
}, [name, conditions, actions, matchType, stopProcessing, rule, onSave, t]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerClose}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>
|
||||
{isEdit ? t('settings.filters.edit_rule', 'Edit Rule') : t('settings.filters.new_rule', 'New Rule')}
|
||||
</Text>
|
||||
<View style={styles.headerRightSpacer} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||
{/* Name */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.rule_name', 'Rule Name')}</Text>
|
||||
<Input
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t('settings.filters.rule_name_placeholder', 'e.g., Sort newsletters')}
|
||||
maxLength={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Match type */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.match_type', 'Match type')}</Text>
|
||||
<View style={styles.matchRow}>
|
||||
{(['all', 'any'] as const).map((mt) => {
|
||||
const selected = matchType === mt;
|
||||
return (
|
||||
<Pressable
|
||||
key={mt}
|
||||
onPress={() => setMatchType(mt)}
|
||||
style={[styles.matchBtn, selected ? styles.matchBtnOn : styles.matchBtnOff]}
|
||||
>
|
||||
<Text style={selected ? styles.matchTextOn : styles.matchTextOff}>
|
||||
{mt === 'all'
|
||||
? t('settings.filters.match_all', 'Match ALL conditions')
|
||||
: t('settings.filters.match_any', 'Match ANY condition')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Conditions */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.conditions', 'Conditions')}</Text>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{conditions.map((condition, index) => (
|
||||
<View key={index} style={styles.card}>
|
||||
<View style={styles.cardTopRow}>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onChange={(v) => updateCondition(index, { field: v as FilterConditionField })}
|
||||
options={fieldOptions}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => removeCondition(index)}
|
||||
disabled={conditions.length <= 1}
|
||||
hitSlop={8}
|
||||
style={[styles.removeBtn, conditions.length <= 1 && styles.removeBtnDisabled]}
|
||||
>
|
||||
<Trash2 size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{condition.field === 'header' && (
|
||||
<Input
|
||||
value={condition.headerName || ''}
|
||||
onChangeText={(v) => updateCondition(index, { headerName: v })}
|
||||
placeholder={t('settings.filters.header_name', 'Header name')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={condition.comparator}
|
||||
onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })}
|
||||
options={comparatorOptions(condition.field)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChangeText={(v) => updateCondition(index, { value: v })}
|
||||
placeholder={
|
||||
condition.field === 'size'
|
||||
? t('settings.filters.size_placeholder', 'e.g., 1000000')
|
||||
: t('settings.filters.header_placeholder', 'Value')
|
||||
}
|
||||
keyboardType={condition.field === 'size' ? 'numeric' : 'default'}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setConditions((prev) => [...prev, makeEmptyCondition()])}
|
||||
style={styles.addRow}
|
||||
>
|
||||
<Plus size={14} color={c.primary} />
|
||||
<Text style={styles.addText}>{t('settings.filters.add_condition', 'Add Condition')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.actions', 'Actions')}</Text>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{actions.map((action, index) => (
|
||||
<View key={index} style={styles.card}>
|
||||
<View style={styles.cardTopRow}>
|
||||
<Select
|
||||
value={action.type}
|
||||
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
|
||||
options={actionTypeOptions}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => removeAction(index)}
|
||||
disabled={actions.length <= 1}
|
||||
hitSlop={8}
|
||||
style={[styles.removeBtn, actions.length <= 1 && styles.removeBtnDisabled]}
|
||||
>
|
||||
<Trash2 size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{ACTIONS_WITH_MAILBOX.has(action.type) && (
|
||||
mailboxOptions.length > 0 ? (
|
||||
<Select
|
||||
value={action.value || ''}
|
||||
onChange={(v) => updateAction(index, { value: v })}
|
||||
options={mailboxOptions}
|
||||
style={{ alignSelf: 'stretch' }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.move_to_folder', 'Select folder')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{action.type === 'forward' && (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.forward_placeholder', 'email@example.com')}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{action.type === 'reject' && (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.reject_placeholder', 'Your email has been rejected')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{action.type === 'add_label' && (
|
||||
keywordOptions.length > 0 ? (
|
||||
<Select
|
||||
value={action.value || ''}
|
||||
onChange={(v) => updateAction(index, { value: v })}
|
||||
options={keywordOptions}
|
||||
style={{ alignSelf: 'stretch' }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.label_placeholder', 'Select tag')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setActions((prev) => [...prev, makeEmptyAction()])}
|
||||
style={styles.addRow}
|
||||
>
|
||||
<Plus size={14} color={c.primary} />
|
||||
<Text style={styles.addText}>{t('settings.filters.add_action', 'Add Action')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Stop processing */}
|
||||
<View style={styles.stopRow}>
|
||||
<Text style={styles.stopLabel}>
|
||||
{t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
|
||||
</Text>
|
||||
<ToggleSwitch checked={stopProcessing} onChange={setStopProcessing} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button variant="outline" onPress={onClose}>
|
||||
{t('settings.filters.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onPress={handleSave} disabled={!name.trim()}>
|
||||
{t('settings.filters.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
headerClose: {
|
||||
width: 40, height: 40,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1, textAlign: 'center' },
|
||||
headerRightSpacer: { width: 40 },
|
||||
|
||||
body: { padding: spacing.lg, gap: spacing.xl, paddingBottom: spacing.xxxl },
|
||||
label: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
|
||||
|
||||
matchRow: { flexDirection: 'row', gap: spacing.sm },
|
||||
matchBtn: { paddingHorizontal: spacing.md, paddingVertical: 8, borderRadius: radius.sm },
|
||||
matchBtnOn: { backgroundColor: c.primary },
|
||||
matchBtnOff: { backgroundColor: c.muted },
|
||||
matchTextOn: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
|
||||
matchTextOff: { ...typography.caption, color: c.text },
|
||||
|
||||
card: {
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
cardTopRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
removeBtn: {
|
||||
width: 36, height: 36,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
removeBtnDisabled: { opacity: 0.3 },
|
||||
|
||||
addRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: spacing.sm },
|
||||
addText: { ...typography.body, color: c.primary, fontWeight: '500' },
|
||||
|
||||
stopRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
stopLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, AlertTriangle, CheckCircle } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import Button from '../Button';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
interface SieveEditorSheetProps {
|
||||
visible: boolean;
|
||||
content: string;
|
||||
onSave: (content: string) => void;
|
||||
onClose: () => void;
|
||||
onValidate: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
}
|
||||
|
||||
export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate }: SieveEditorSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const [script, setScript] = useState(content);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{ isValid: boolean; errors?: string[] } | null>(null);
|
||||
const [showSaveWarning, setShowSaveWarning] = useState(false);
|
||||
|
||||
// Re-seed the editor whenever it's (re)opened with fresh content. Keying the
|
||||
// editor on `content` while closed avoids a stale draft on the next open.
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setScript(content);
|
||||
setValidationResult(null);
|
||||
setShowSaveWarning(false);
|
||||
}
|
||||
}, [visible, content]);
|
||||
|
||||
const lineCount = script.split('\n').length;
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
try {
|
||||
const result = await onValidate(script);
|
||||
setValidationResult(result);
|
||||
} catch {
|
||||
setValidationResult({ isValid: false, errors: [t('settings.filters.sieve_editor.validation_failed', 'Validation request failed')] });
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [script, onValidate, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!showSaveWarning) {
|
||||
setShowSaveWarning(true);
|
||||
return;
|
||||
}
|
||||
onSave(script);
|
||||
}, [script, showSaveWarning, onSave]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerClose}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{t('settings.filters.sieve_editor.title', 'Sieve Script Editor')}</Text>
|
||||
<View style={styles.headerRightSpacer} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<View style={styles.warningBanner}>
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
{t('settings.filters.sieve_editor.warning', 'Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.editorWrap} contentContainerStyle={styles.editorContent}>
|
||||
<TextInput
|
||||
value={script}
|
||||
onChangeText={(v) => {
|
||||
setScript(v);
|
||||
setValidationResult(null);
|
||||
setShowSaveWarning(false);
|
||||
}}
|
||||
style={styles.editor}
|
||||
multiline
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
textAlignVertical="top"
|
||||
placeholder={t('settings.filters.sieve_editor.script_content', 'Sieve script')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
accessibilityLabel={t('settings.filters.sieve_editor.script_content', 'Sieve script')}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<Text style={styles.lineCount}>{lineCount} {lineCount === 1 ? 'line' : 'lines'}</Text>
|
||||
|
||||
{validationResult && (
|
||||
<View style={[styles.resultBanner, validationResult.isValid ? styles.resultOk : styles.resultErr]}>
|
||||
{validationResult.isValid ? (
|
||||
<>
|
||||
<CheckCircle size={16} color={c.success} />
|
||||
<Text style={[styles.resultText, { color: c.success }]}>
|
||||
{t('settings.filters.sieve_editor.valid', 'Script is valid')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.resultText, { color: c.error, fontWeight: '600' }]}>
|
||||
{t('settings.filters.sieve_editor.invalid', 'Script has errors')}
|
||||
</Text>
|
||||
{validationResult.errors?.map((err, i) => (
|
||||
<Text key={i} style={styles.errorDetail}>{err}</Text>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showSaveWarning && (
|
||||
<View style={styles.warningBanner}>
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
{t('settings.filters.sieve_editor.save_warning', 'Saving will overwrite any visual rules. This cannot be undone. Tap Save again to confirm.')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={handleValidate}
|
||||
disabled={isValidating || !script.trim()}
|
||||
icon={isValidating ? <ActivityIndicator size="small" color={c.text} /> : undefined}
|
||||
>
|
||||
{isValidating
|
||||
? t('settings.filters.sieve_editor.validating', 'Validating...')
|
||||
: t('settings.filters.sieve_editor.validate', 'Validate')}
|
||||
</Button>
|
||||
<View style={styles.footerRight}>
|
||||
<Button variant="outline" onPress={onClose}>
|
||||
{t('settings.filters.sieve_editor.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onPress={handleSave} disabled={!script.trim()}>
|
||||
{showSaveWarning
|
||||
? t('settings.filters.sieve_editor.confirm_save', 'Confirm Save')
|
||||
: t('settings.filters.sieve_editor.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
headerClose: {
|
||||
width: 40, height: 40,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1, textAlign: 'center' },
|
||||
headerRightSpacer: { width: 40 },
|
||||
|
||||
body: { flex: 1, padding: spacing.lg, gap: spacing.md },
|
||||
|
||||
warningBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
warningText: { ...typography.caption, color: '#fde68a', flex: 1 },
|
||||
|
||||
editorWrap: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
editorContent: { flexGrow: 1 },
|
||||
editor: {
|
||||
flex: 1,
|
||||
minHeight: 240,
|
||||
padding: spacing.md,
|
||||
color: c.text,
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
lineCount: { ...typography.caption, color: c.mutedForeground, textAlign: 'right' },
|
||||
|
||||
resultBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
resultOk: { backgroundColor: c.successBg, borderColor: c.success },
|
||||
resultErr: { backgroundColor: c.errorBg, borderColor: c.errorBorder },
|
||||
resultText: { ...typography.caption },
|
||||
errorDetail: {
|
||||
...typography.caption,
|
||||
color: c.error,
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
footerRight: { flexDirection: 'row', gap: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,45 @@
|
||||
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 { useOutboxStore } from '../../stores/outbox-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' },
|
||||
];
|
||||
|
||||
const MAX_MB_OPTIONS = [
|
||||
{ value: '25', label: '25 MB' },
|
||||
{ value: '50', label: '50 MB' },
|
||||
{ value: '100', label: '100 MB' },
|
||||
{ value: '250', label: '250 MB' },
|
||||
{ value: '500', label: '500 MB' },
|
||||
];
|
||||
|
||||
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 +65,41 @@ 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 offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const queuedChanges = useOutboxStore((s) => s.entries.length);
|
||||
const flushOutbox = useOutboxStore((s) => s.flush);
|
||||
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, maxMB: offlineMaxMB });
|
||||
// Also drain any queued offline changes while we're at it.
|
||||
void flushOutbox();
|
||||
};
|
||||
|
||||
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 +133,95 @@ 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>
|
||||
<SettingItem
|
||||
label="Maximum size"
|
||||
description="Oldest messages are removed when the cache grows past this."
|
||||
>
|
||||
<Select
|
||||
value={String(offlineMaxMB)}
|
||||
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
|
||||
options={MAX_MB_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>
|
||||
{queuedChanges > 0 && (
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{`${queuedChanges} change${queuedChanges === 1 ? '' : 's'} waiting to sync`}
|
||||
</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 +320,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,25 +1,124 @@
|
||||
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, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import {
|
||||
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
|
||||
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
|
||||
} 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';
|
||||
import {
|
||||
isStalwartSupported,
|
||||
fetchAuthInfo,
|
||||
fetchEncryptionType,
|
||||
fetchPrincipal,
|
||||
changePassword,
|
||||
updateDisplayName,
|
||||
enableTotp,
|
||||
disableTotp,
|
||||
createAppPassword,
|
||||
removeAppPassword,
|
||||
createApiKey,
|
||||
removeApiKey,
|
||||
type AppCredentialInfo,
|
||||
type AppCredentialInput,
|
||||
type AuthInfo,
|
||||
type EncryptionType,
|
||||
} from '../../api/account-security';
|
||||
import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
device: string;
|
||||
location: string;
|
||||
current: boolean;
|
||||
// ── TLS client certificate (mobile-only) ──────────────────
|
||||
// Picks an installed Android cert for mTLS handshakes. Independent of the
|
||||
// Stalwart account features below, so it stays visible even on non-Stalwart
|
||||
// servers.
|
||||
function ClientCertSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, []);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
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 = () => {
|
||||
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 (
|
||||
<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.rowGap}>
|
||||
<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={onClearCert}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const MOCK_SESSIONS: Session[] = [
|
||||
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
|
||||
];
|
||||
|
||||
export function AccountSecuritySettings() {
|
||||
// ── Password change ───────────────────────────────────────
|
||||
function PasswordChangeSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [currentPwd, setCurrentPwd] = useState('');
|
||||
@@ -27,174 +126,608 @@ export function AccountSecuritySettings() {
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (newPwd.length < 8) { setError('New password must be at least 8 characters.'); return; }
|
||||
if (newPwd !== confirmPwd) { setError('Passwords do not match.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await changePassword(currentPwd, newPwd);
|
||||
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
|
||||
Alert.alert('Password changed', 'Your account password was updated.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Current password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input value={currentPwd} onChangeText={setCurrentPwd} secureTextEntry={!showCurrent} autoCapitalize="none" containerStyle={{ flex: 1 }} />
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
||||
{showCurrent ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>New password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input value={newPwd} onChangeText={setNewPwd} secureTextEntry={!showNew} autoCapitalize="none" containerStyle={{ flex: 1 }} />
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
||||
{showNew ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Confirm password</Text>
|
||||
<Input value={confirmPwd} onChangeText={setConfirmPwd} secureTextEntry={!showNew} autoCapitalize="none" />
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={saving}
|
||||
disabled={saving || !currentPwd || !newPwd || !confirmPwd}
|
||||
onPress={() => { void submit(); }}
|
||||
icon={<Key size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Display name ──────────────────────────────────────────
|
||||
function DisplayNameSection({ initial, onSaved }: { initial: string; onSaved: (name: string) => void }) {
|
||||
const c = useColors();
|
||||
const [name, setName] = useState(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => { setName(initial); }, [initial]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDisplayName(name);
|
||||
onSaved(name);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (err) {
|
||||
Alert.alert('Update failed', err instanceof Error ? err.message : 'Failed to update display name.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingItem label="Display name" description="The name shown on this account.">
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
||||
<Input value={name} onChangeText={setName} placeholder="Display name" containerStyle={{ width: 150 }} />
|
||||
<Button size="sm" loading={saving} disabled={saving || name === initial} onPress={() => { void save(); }}>
|
||||
{saved ? <Check size={14} color={c.primaryForeground} /> : 'Save'}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────
|
||||
function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () => void }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [enrolment, setEnrolment] = useState<TotpEnrolment | null>(null);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setEnrolment(null); setDisableOpen(false);
|
||||
setPassword(''); setOtpCode(''); setError(null);
|
||||
};
|
||||
|
||||
const handleToggle = (enable: boolean) => {
|
||||
setError(''); setPassword(''); setOtpCode('');
|
||||
if (enable) {
|
||||
setEnrolment(generateTotpEnrolment(jmapClient.username ?? 'account'));
|
||||
setDisableOpen(false);
|
||||
} else {
|
||||
setEnrolment(null);
|
||||
setDisableOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEnable = async () => {
|
||||
if (!enrolment) return;
|
||||
if (!password) { setError('Enter your current password.'); return; }
|
||||
if (!otpCode.trim()) { setError('Enter the 6-digit code from your app.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await enableTotp(password, enrolment.url, otpCode.trim());
|
||||
reset();
|
||||
onChanged();
|
||||
Alert.alert('Two-factor enabled', 'You will be asked for a code at next sign-in.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to enable two-factor authentication.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDisable = async () => {
|
||||
if (!password) { setError('Enter your current password.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await disableTotp(password);
|
||||
reset();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to disable two-factor authentication.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRow}>
|
||||
<Shield size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Two-Factor Authentication</Text>
|
||||
</View>
|
||||
|
||||
<SettingItem label="Authenticator app" description="Require a one-time code at login." noBorder>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
||||
<ToggleSwitch checked={enabled || !!enrolment} onChange={handleToggle} disabled={saving} />
|
||||
<Text style={[styles.statusText, { color: enabled ? c.success : c.mutedForeground }]}>
|
||||
{enabled ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{enrolment && (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelHint}>
|
||||
Add this secret to your authenticator app, then enter the 6-digit code to confirm.
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<ExternalLink size={14} color={c.text} />}
|
||||
onPress={() => { void Linking.openURL(enrolment.url).catch(() => Alert.alert('No authenticator app', 'Could not open an authenticator app. Add the secret below manually.')); }}
|
||||
>
|
||||
Add to authenticator app
|
||||
</Button>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Or enter this secret manually</Text>
|
||||
<Text selectable style={styles.secretText}>{enrolment.secretFormatted}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Current password</Text>
|
||||
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Verification code</Text>
|
||||
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} />
|
||||
</View>
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<View style={styles.rowGap}>
|
||||
<Button size="sm" loading={saving} disabled={saving || !password || !otpCode} onPress={() => { void confirmEnable(); }}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{disableOpen && (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelHint}>Enter your password to turn off two-factor authentication.</Text>
|
||||
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" placeholder="Current password" />
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<View style={styles.rowGap}>
|
||||
<Button variant="destructive" size="sm" loading={saving} disabled={saving || !password} onPress={() => { void confirmDisable(); }}>
|
||||
Disable
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App passwords / API keys ──────────────────────────────
|
||||
function parseIpList(raw: string): string[] {
|
||||
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
interface CredentialSectionProps {
|
||||
icon: typeof Smartphone;
|
||||
title: string;
|
||||
description: string;
|
||||
nameLabel: string;
|
||||
namePlaceholder: string;
|
||||
entries: AppCredentialInfo[];
|
||||
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CredentialSection({ icon: Icon, title, description, nameLabel, namePlaceholder, entries, onCreate, onRemove }: CredentialSectionProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [expiry, setExpiry] = useState<Date | null>(null);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const resetForm = () => { setName(''); setExpiry(null); setAllowedIpsRaw(''); };
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await onCreate({
|
||||
description: name.trim(),
|
||||
expiresAt: expiry ? expiry.toISOString() : null,
|
||||
allowedIps: parseIpList(allowedIpsRaw),
|
||||
});
|
||||
setCreatedSecret(result.secret);
|
||||
resetForm();
|
||||
setShowAdd(false);
|
||||
} catch (err) {
|
||||
Alert.alert('Could not create', err instanceof Error ? err.message : undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
Alert.alert('Remove credential?', 'Any client using it will stop working.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await onRemove(id);
|
||||
} catch (err) {
|
||||
Alert.alert('Could not remove', err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const onPickDate = (_e: DateTimePickerEvent, date?: Date) => {
|
||||
setShowPicker(false);
|
||||
if (date) setExpiry(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRowBetween}>
|
||||
<View style={styles.headerRow}>
|
||||
<Icon size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />} onPress={() => setShowAdd((v) => !v)}>
|
||||
Add
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.panelHint}>{description}</Text>
|
||||
|
||||
{createdSecret && (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelHint}>Copy this now — it is shown only once. Tap and hold to select.</Text>
|
||||
<Text selectable style={styles.secretText}>{createdSecret}</Text>
|
||||
<View style={{ alignItems: 'flex-start' }}>
|
||||
<Button variant="ghost" size="sm" onPress={() => setCreatedSecret(null)}>Done</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<View style={styles.panel}>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>{nameLabel}</Text>
|
||||
<Input value={name} onChangeText={setName} placeholder={namePlaceholder} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Expires (optional)</Text>
|
||||
<View style={styles.rowGap}>
|
||||
<Button variant="outline" size="sm" icon={<Calendar size={14} color={c.text} />} onPress={() => setShowPicker(true)}>
|
||||
{expiry ? expiry.toLocaleDateString() : 'No expiry'}
|
||||
</Button>
|
||||
{expiry && (
|
||||
<Pressable style={styles.clearExpiry} onPress={() => setExpiry(null)}>
|
||||
<X size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{showPicker && (
|
||||
<DateTimePicker value={expiry ?? new Date()} mode="date" minimumDate={new Date()} onChange={onPickDate} />
|
||||
)}
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Allowed IPs (optional)</Text>
|
||||
<Input value={allowedIpsRaw} onChangeText={setAllowedIpsRaw} placeholder="1.2.3.4, 10.0.0.0/8" autoCapitalize="none" />
|
||||
<Text style={styles.fieldHint}>Comma or space separated. Leave empty to allow any IP.</Text>
|
||||
</View>
|
||||
<View style={styles.rowGap}>
|
||||
<Button size="sm" loading={saving} disabled={saving || !name.trim()} onPress={() => { void handleAdd(); }}>Create</Button>
|
||||
<Button variant="ghost" size="sm" onPress={() => { setShowAdd(false); resetForm(); }}>Cancel</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{entries.map((entry) => (
|
||||
<View key={entry.id} style={styles.credRow}>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.credName} numberOfLines={1}>{entry.description || entry.id}</Text>
|
||||
{entry.createdAt && (
|
||||
<Text style={styles.credMeta}>
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{entry.allowedIps.length > 0 && (
|
||||
<View style={styles.ipWrap}>
|
||||
{entry.allowedIps.map((ip) => (
|
||||
<View key={ip} style={styles.ipPill}><Text style={styles.ipText}>{ip}</Text></View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Pressable style={styles.iconBtn} onPress={() => handleRemove(entry.id)}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>None yet.</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email client (OAuth accounts) ─────────────────────────
|
||||
function EmailClientSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const username = jmapClient.username ?? '';
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRow}>
|
||||
<Monitor size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Email client setup</Text>
|
||||
</View>
|
||||
<Text style={styles.panelHint}>
|
||||
To configure another mail client, use the username below and an app password (create one above) as the password.
|
||||
</Text>
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.pwLabel}>JMAP / IMAP username</Text>
|
||||
<Text selectable style={styles.secretText}>{username}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Encryption at rest (read-only status) ─────────────────
|
||||
function EncryptionSection({ type }: { type: EncryptionType }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const enabled = type !== 'Disabled';
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRow}>
|
||||
<Lock size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Encryption at rest</Text>
|
||||
</View>
|
||||
<SettingItem label="Stored message encryption" description="Server-side encryption of stored messages." noBorder>
|
||||
<Text style={[styles.statusText, { color: enabled ? c.success : c.mutedForeground }]}>
|
||||
{enabled ? `Active (${type})` : 'Disabled'}
|
||||
</Text>
|
||||
</SettingItem>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen ────────────────────────────────────────────────
|
||||
export function AccountSecuritySettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const certSupported = isClientCertSupported();
|
||||
const isOAuth = jmapClient.usesBearerAuth;
|
||||
|
||||
// null = still probing; false = server lacks the Stalwart extension.
|
||||
const [supported, setSupported] = useState<boolean | null>(null);
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [encryption, setEncryption] = useState<EncryptionType>('Disabled');
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const reloadAuth = useCallback(async () => {
|
||||
try {
|
||||
setAuth(await fetchAuthInfo());
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) { setSupported(false); return; }
|
||||
if (!isStalwartSupported()) { setSupported(false); return; }
|
||||
setSupported(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const authInfo = await fetchAuthInfo();
|
||||
if (cancelled) return;
|
||||
setAuth(authInfo);
|
||||
if (!isOAuth) {
|
||||
// Principal + crypto only matter for password accounts; failures are
|
||||
// non-fatal (e.g. a non-admin principal read is forbidden).
|
||||
const [principal, enc] = await Promise.allSettled([fetchPrincipal(), fetchEncryptionType()]);
|
||||
if (cancelled) return;
|
||||
if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName);
|
||||
if (enc.status === 'fulfilled') setEncryption(enc.value);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOAuth]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Current password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input
|
||||
value={currentPwd}
|
||||
onChangeText={setCurrentPwd}
|
||||
secureTextEntry={!showCurrent}
|
||||
containerStyle={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
||||
{showCurrent ? (
|
||||
<EyeOff size={16} color={c.mutedForeground} />
|
||||
) : (
|
||||
<Eye size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{certSupported && <ClientCertSection />}
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>New password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input
|
||||
value={newPwd}
|
||||
onChangeText={setNewPwd}
|
||||
secureTextEntry={!showNew}
|
||||
containerStyle={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
||||
{showNew ? (
|
||||
<EyeOff size={16} color={c.mutedForeground} />
|
||||
) : (
|
||||
<Eye size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Confirm password</Text>
|
||||
<Input
|
||||
value={confirmPwd}
|
||||
onChangeText={setConfirmPwd}
|
||||
secureTextEntry={!showNew}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Button size="sm" icon={<Key size={14} color={c.primaryForeground} />}>
|
||||
Change Password
|
||||
</Button>
|
||||
</View>
|
||||
{supported === null && (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator color={c.mutedForeground} />
|
||||
<Text style={styles.panelHint}>Detecting server features…</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Two-Factor Authentication" description="Add an extra layer of security with an authenticator app.">
|
||||
<SettingItem label="Enable 2FA" description="Require a one-time code at login.">
|
||||
<ToggleSwitch checked={twoFactor} onChange={setTwoFactor} />
|
||||
</SettingItem>
|
||||
{supported === false && (
|
||||
<SettingsSection title="Account security" description="Manage your password, two-factor authentication, and app credentials.">
|
||||
<Text style={styles.emptyText}>
|
||||
Account security management requires a Stalwart server. These features are not available for this account.
|
||||
</Text>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{twoFactor && (
|
||||
<View style={styles.infoBox}>
|
||||
<Smartphone size={16} color={c.primary} />
|
||||
<Text style={styles.infoText}>
|
||||
Scan the QR code in your authenticator app to finish setup.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
{supported && (
|
||||
<SettingsSection title="Account security" description="Manage your password, two-factor authentication, and app credentials.">
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
{loadError ? <Text style={styles.errorText}>{loadError}</Text> : null}
|
||||
|
||||
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
|
||||
{appPwds.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Lock size={24} color={c.mutedForeground} />
|
||||
<Text style={styles.emptyText}>No app passwords yet.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ alignItems: 'flex-start' }}>
|
||||
<Button variant="outline" size="sm">
|
||||
Generate New
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Active Sessions" description="Devices currently signed into your account.">
|
||||
{MOCK_SESSIONS.map((s) => (
|
||||
<View key={s.id} style={styles.sessionRow}>
|
||||
<View style={styles.sessionIcon}>
|
||||
<Monitor size={18} color={c.text} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.sessionDevice}>{s.device}</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{s.location} · {s.current ? 'Current' : 'Active'}
|
||||
</Text>
|
||||
</View>
|
||||
{!s.current && (
|
||||
<Pressable style={styles.trashBtn}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<PasswordChangeSection />
|
||||
<DisplayNameSection initial={displayName} onSaved={setDisplayName} />
|
||||
<TotpSection enabled={!!auth?.otpEnabled} onChanged={() => { void reloadAuth(); }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CredentialSection
|
||||
icon={Smartphone}
|
||||
title="App passwords"
|
||||
description="Generate passwords for other mail clients that can't do interactive login."
|
||||
nameLabel="Name"
|
||||
namePlaceholder="e.g. Thunderbird laptop"
|
||||
entries={auth?.appPasswords ?? []}
|
||||
onCreate={async (input) => { const r = await createAppPassword(input); await reloadAuth(); return r; }}
|
||||
onRemove={async (id) => { await removeAppPassword(id); await reloadAuth(); }}
|
||||
/>
|
||||
|
||||
<CredentialSection
|
||||
icon={Terminal}
|
||||
title="API keys"
|
||||
description="Tokens for scripts and integrations that use the JMAP API."
|
||||
nameLabel="Name"
|
||||
namePlaceholder="e.g. Backup script"
|
||||
entries={auth?.apiKeys ?? []}
|
||||
onCreate={async (input) => { const r = await createApiKey(input); await reloadAuth(); return r; }}
|
||||
onRemove={async (id) => { await removeApiKey(id); await reloadAuth(); }}
|
||||
/>
|
||||
|
||||
{isOAuth && <EmailClientSection />}
|
||||
{!isOAuth && <EncryptionSection type={encryption} />}
|
||||
</View>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
pwField: { gap: 4 },
|
||||
pwLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
eyeBtn: { padding: spacing.sm },
|
||||
infoBox: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
backgroundColor: c.primaryBg,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoText: {
|
||||
...typography.caption,
|
||||
color: c.primary,
|
||||
flex: 1,
|
||||
},
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
sessionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sessionIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.muted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sessionDevice: { ...typography.bodyMedium, color: c.text },
|
||||
sessionMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
trashBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
});
|
||||
container: { gap: spacing.xxxl },
|
||||
rowGap: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
|
||||
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.lg },
|
||||
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
headerRowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
headerTitle: { ...typography.bodyMedium, color: c.text },
|
||||
|
||||
statusText: { ...typography.caption, fontWeight: '500' },
|
||||
|
||||
pwField: { gap: 4 },
|
||||
pwLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
|
||||
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
eyeBtn: { padding: spacing.sm },
|
||||
fieldHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4, fontSize: 11 },
|
||||
errorText: { ...typography.caption, color: c.error },
|
||||
|
||||
panel: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
panelHint: { ...typography.caption, color: c.mutedForeground },
|
||||
secretText: {
|
||||
...typography.body,
|
||||
fontFamily: 'monospace',
|
||||
color: c.text,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
clearExpiry: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
|
||||
credRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
credName: { ...typography.bodyMedium, color: c.text },
|
||||
credMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
ipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
|
||||
ipPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.xs, backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
|
||||
ipText: { fontSize: 10, fontFamily: 'monospace', color: c.mutedForeground },
|
||||
iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
emptyText: { ...typography.caption, color: c.mutedForeground, fontStyle: 'italic' },
|
||||
|
||||
// TLS client cert
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,57 @@
|
||||
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 addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
// Derive in a memo from stable store fields. Subscribing with
|
||||
// `selectAddressBooksWithCount` directly returns a new array each call and
|
||||
// sends useSyncExternalStore into an infinite render loop.
|
||||
const books = React.useMemo(
|
||||
() => selectAddressBooksWithCount(addressBooks, contacts),
|
||||
[addressBooks, contacts],
|
||||
);
|
||||
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 +77,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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { RotateCcw } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SpaceReplacement } from '../../stores/settings-store';
|
||||
import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
EMAIL_TOKENS,
|
||||
ATTACHMENT_TOKENS,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
DEFAULT_ATTACHMENT_TEMPLATE,
|
||||
type EmailFilenameOptions,
|
||||
} from '../../lib/download-filename';
|
||||
|
||||
const SAMPLE = buildSampleEmail();
|
||||
const SAMPLE_ATTACHMENT = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
|
||||
|
||||
export function DownloadsSettings() {
|
||||
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 emailTemplate = useSettingsStore((s) => s.emailExportTemplate);
|
||||
const attachmentTemplate = useSettingsStore((s) => s.attachmentExportTemplate);
|
||||
const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.exportLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const transforms: EmailFilenameOptions = { spaceReplacement, lowercase, stripDiacritics };
|
||||
const emailPreview = emailExportFilename(SAMPLE, { ...transforms, template: emailTemplate });
|
||||
const attachmentPreview = attachmentDownloadFilename(SAMPLE, SAMPLE_ATTACHMENT, {
|
||||
...transforms,
|
||||
template: attachmentTemplate,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
<SettingsSection
|
||||
title="Email Filename"
|
||||
description="Template used when exporting a message as an .eml file."
|
||||
>
|
||||
<Input
|
||||
value={emailTemplate}
|
||||
onChangeText={(v) => update('emailExportTemplate', v)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => update('emailExportTemplate', DEFAULT_EMAIL_TEMPLATE)}
|
||||
style={styles.resetRow}
|
||||
>
|
||||
<RotateCcw size={12} color={c.mutedForeground} />
|
||||
<Text style={styles.resetText}>Reset to default</Text>
|
||||
</Pressable>
|
||||
<View style={styles.previewBox}>
|
||||
<Text style={styles.previewLabel}>Preview</Text>
|
||||
<Text style={styles.previewValue}>{emailPreview}</Text>
|
||||
</View>
|
||||
<TokenList tokens={EMAIL_TOKENS} styles={styles} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Attachment Filename"
|
||||
description="Template used when saving or sharing an attachment."
|
||||
>
|
||||
<Input
|
||||
value={attachmentTemplate}
|
||||
onChangeText={(v) => update('attachmentExportTemplate', v)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => update('attachmentExportTemplate', DEFAULT_ATTACHMENT_TEMPLATE)}
|
||||
style={styles.resetRow}
|
||||
>
|
||||
<RotateCcw size={12} color={c.mutedForeground} />
|
||||
<Text style={styles.resetText}>Reset to default</Text>
|
||||
</Pressable>
|
||||
<View style={styles.previewBox}>
|
||||
<Text style={styles.previewLabel}>Preview</Text>
|
||||
<Text style={styles.previewValue}>{attachmentPreview}</Text>
|
||||
</View>
|
||||
<TokenList tokens={ATTACHMENT_TOKENS} styles={styles} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Filename Transform"
|
||||
description="Applied to every exported filename."
|
||||
>
|
||||
<View style={styles.group}>
|
||||
<SettingItem label="Spaces" description="Replace spaces in filenames." noBorder />
|
||||
<RadioGroup
|
||||
value={spaceReplacement}
|
||||
onChange={(v) => update('exportSpaceReplacement', v as SpaceReplacement)}
|
||||
options={[
|
||||
{ value: 'keep', label: 'Keep' },
|
||||
{ value: 'underscore', label: 'Underscore' },
|
||||
{ value: 'dash', label: 'Dash' },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SettingItem label="Lowercase" description="Force the whole filename to lowercase.">
|
||||
<ToggleSwitch checked={lowercase} onChange={(v) => update('exportLowercase', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Strip Accents"
|
||||
description="Convert accented letters to plain ASCII (ä → a)."
|
||||
>
|
||||
<ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenList({
|
||||
tokens,
|
||||
styles,
|
||||
}: {
|
||||
tokens: { token: string; description: string }[];
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tokenWrap}>
|
||||
{tokens.map((t) => (
|
||||
<View key={t.token} style={styles.tokenPill}>
|
||||
<Text style={styles.tokenText}>{`{${t.token}}`}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
group: { gap: spacing.sm },
|
||||
resetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
resetText: { ...typography.caption, color: c.mutedForeground },
|
||||
previewBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
gap: 2,
|
||||
},
|
||||
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
|
||||
previewValue: { ...typography.captionMedium, color: c.text },
|
||||
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
|
||||
tokenPill: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
tokenText: { fontSize: 11, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -58,7 +49,7 @@ function formatSize(b: number) {
|
||||
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getIcon(file: SampleFile, colored: boolean, sz: number) {
|
||||
function getIcon(c: ThemePalette, file: SampleFile, colored: boolean, sz: number) {
|
||||
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : c.mutedForeground} />;
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
@@ -96,7 +87,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
<View style={styles.gridWrap}>
|
||||
{files.map((f) => (
|
||||
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
|
||||
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
|
||||
{prefs.showIcons ? getIcon(c, f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
|
||||
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -114,7 +105,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
|
||||
</View>
|
||||
{files.map((f) => (
|
||||
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
|
||||
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
|
||||
{prefs.showIcons && getIcon(c, f, prefs.coloredIcons, 14)}
|
||||
<Text
|
||||
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
|
||||
numberOfLines={1}
|
||||
@@ -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,176 +1,464 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import {
|
||||
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
|
||||
ChevronUp, ChevronDown, Palmtree,
|
||||
} 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';
|
||||
import { useFilterStore } from '../../stores/filter-store';
|
||||
import { useVacationStore } from '../../stores/vacation-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { FilterRuleModal } from '../filters/FilterRuleModal';
|
||||
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
|
||||
import type { FilterRule } from '../../lib/sieve/types';
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
summary: string;
|
||||
action: string;
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === 'external' || r.origin === 'opaque';
|
||||
}
|
||||
|
||||
const SAMPLE_RULES: Rule[] = [];
|
||||
function summarizeRule(rule: FilterRule, t: Translate): string {
|
||||
const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or');
|
||||
const conditions = rule.conditions.slice(0, 2).map((cond) => {
|
||||
const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field);
|
||||
const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator);
|
||||
return `${field} ${comparator} "${cond.value}"`;
|
||||
});
|
||||
const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : '';
|
||||
const actions = rule.actions.slice(0, 2).map((a) => {
|
||||
const action = t(`settings.filters.action_types.${a.type}`, a.type);
|
||||
return a.value ? `${action} "${a.value}"` : action;
|
||||
});
|
||||
return `${conditions.join(` ${joiner} `)}${extra} → ${actions.join(', ')}`;
|
||||
}
|
||||
|
||||
export function FilterSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
||||
const [expandedView, setExpandedView] = useState(false);
|
||||
const [isOpaque] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const remove = (id: string) => {
|
||||
setRules((rs) => rs.filter((r) => r.id !== id));
|
||||
setDeleteId(null);
|
||||
};
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const vacationEnabled = useVacationStore((s) => s.isEnabled);
|
||||
|
||||
const {
|
||||
rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings,
|
||||
fetchFilters, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
|
||||
setOpaqueScript, resetToVisualBuilder, validateScript,
|
||||
} = useFilterStore();
|
||||
|
||||
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
const [showSieveEditor, setShowSieveEditor] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchFilters();
|
||||
}, [fetchFilters]);
|
||||
|
||||
const showVacationBanner = (vacationEnabled || vacationSettings?.isEnabled) ?? false;
|
||||
|
||||
// Editable (Bulwark-managed) rules, in order, for reorder math.
|
||||
const editableIds = useMemo(
|
||||
() => rules.filter((r) => !isReadonlyRule(r)).map((r) => r.id),
|
||||
[rules],
|
||||
);
|
||||
|
||||
const persist = useCallback(async (rollback: () => void) => {
|
||||
try {
|
||||
await saveFilters();
|
||||
} catch {
|
||||
rollback();
|
||||
Alert.alert(t('settings.filters.save_failed', 'Failed to save filters'));
|
||||
}
|
||||
}, [saveFilters, t]);
|
||||
|
||||
const handleToggle = useCallback((ruleId: string) => {
|
||||
toggleRule(ruleId);
|
||||
void persist(() => toggleRule(ruleId));
|
||||
}, [toggleRule, persist]);
|
||||
|
||||
const handleDelete = useCallback((rule: FilterRule) => {
|
||||
Alert.alert(
|
||||
t('settings.filters.delete_rule', 'Delete Rule'),
|
||||
t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'),
|
||||
[
|
||||
{ text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('settings.filters.confirm_delete', 'Delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
deleteRule(rule.id);
|
||||
void persist(() => addRule(rule));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [deleteRule, addRule, persist, t]);
|
||||
|
||||
const handleSaveRule = useCallback((rule: FilterRule) => {
|
||||
const previous = useFilterStore.getState().rules;
|
||||
if (editingRule) updateRule(rule.id, rule);
|
||||
else addRule(rule);
|
||||
setShowRuleModal(false);
|
||||
setEditingRule(undefined);
|
||||
void persist(() => useFilterStore.setState({ rules: previous }));
|
||||
}, [editingRule, updateRule, addRule, persist]);
|
||||
|
||||
const handleSaveSieve = useCallback((content: string) => {
|
||||
const previous = useFilterStore.getState();
|
||||
setOpaqueScript(content);
|
||||
setShowSieveEditor(false);
|
||||
void persist(() => useFilterStore.setState({
|
||||
isOpaque: previous.isOpaque,
|
||||
rules: previous.rules,
|
||||
rawScript: previous.rawScript,
|
||||
}));
|
||||
}, [setOpaqueScript, persist]);
|
||||
|
||||
const handleResetToVisual = useCallback(() => {
|
||||
if (!showResetConfirm) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
const previous = useFilterStore.getState();
|
||||
resetToVisualBuilder();
|
||||
setShowResetConfirm(false);
|
||||
void persist(() => useFilterStore.setState({
|
||||
isOpaque: previous.isOpaque,
|
||||
rules: previous.rules,
|
||||
rawScript: previous.rawScript,
|
||||
externalRequires: previous.externalRequires,
|
||||
}));
|
||||
}, [showResetConfirm, resetToVisualBuilder, persist]);
|
||||
|
||||
const moveRule = useCallback((ruleId: string, dir: -1 | 1) => {
|
||||
const idx = editableIds.indexOf(ruleId);
|
||||
const target = idx + dir;
|
||||
if (idx === -1 || target < 0 || target >= editableIds.length) return;
|
||||
const previousOrder = [...editableIds];
|
||||
const next = [...editableIds];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
reorderRules(next);
|
||||
void persist(() => reorderRules(previousOrder));
|
||||
}, [editableIds, reorderRules, persist]);
|
||||
|
||||
const handleValidate = useCallback(
|
||||
(content: string) => validateScript(content),
|
||||
[validateScript],
|
||||
);
|
||||
|
||||
// ── Status screens ──────────────────────────────────────
|
||||
if (!isSupported && !isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
<Text style={styles.statusText}>{t('settings.filters.not_supported', 'Your mail server does not support email filters.')}</Text>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator size="small" color={c.mutedForeground} />
|
||||
<Text style={styles.statusText}>{t('settings.filters.loading', 'Loading filters...')}</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
<Text style={[styles.statusText, { color: c.error }]}>{t('settings.filters.fetch_error', 'Failed to load filters')}</Text>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="Filters & Rules" description="Server-side rules that process incoming mail.">
|
||||
<View style={{ gap: spacing.xl }}>
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
{isOpaque && (
|
||||
<View style={styles.opaqueWarning}>
|
||||
<AlertTriangle size={16} color="#fbbf24" />
|
||||
<View style={styles.opaqueBanner}>
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.opaqueText}>
|
||||
Your Sieve script contains advanced features. Edit raw or reset to visual builder.
|
||||
{t('settings.filters.opaque_warning', 'This script was edited outside the visual builder. Only raw Sieve editing is available.')}
|
||||
</Text>
|
||||
<View style={styles.opaqueActions}>
|
||||
<Pressable>
|
||||
<Text style={styles.linkText}>Open Sieve Editor</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.resetLink}>
|
||||
<RotateCcw size={12} color={c.error} />
|
||||
<Text style={styles.resetText}>Reset to visual</Text>
|
||||
<Pressable onPress={() => setShowSieveEditor(true)}>
|
||||
<Text style={styles.linkPrimary}>{t('settings.filters.open_sieve_editor', 'Open raw Sieve editor')}</Text>
|
||||
</Pressable>
|
||||
{showResetConfirm ? (
|
||||
<View style={styles.resetConfirmRow}>
|
||||
<Pressable onPress={handleResetToVisual}>
|
||||
<Text style={styles.linkDanger}>{t('settings.filters.confirm_reset', 'Reset')}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setShowResetConfirm(false)}>
|
||||
<Text style={styles.linkMuted}>{t('settings.filters.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleResetToVisual} style={styles.resetLink}>
|
||||
<RotateCcw size={12} color={c.error} />
|
||||
<Text style={styles.linkDanger}>{t('settings.filters.reset_to_visual', 'Reset to visual builder')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !isOpaque && (
|
||||
<View style={styles.emptyState}>
|
||||
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>No rules yet.</Text>
|
||||
{!isOpaque && showVacationBanner && (
|
||||
<View style={styles.vacationBanner}>
|
||||
<View style={styles.vacationIcon}>
|
||||
<Palmtree size={16} color={c.success} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.vacationTitle}>{t('settings.filters.vacation_active', 'Vacation Responder is active')}</Text>
|
||||
<Text style={styles.vacationDesc}>{t('settings.filters.vacation_active_description', 'Auto-reply is enabled for incoming messages')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rules.length > 0 && (
|
||||
<View style={{ gap: 4 }}>
|
||||
{rules.map((rule) => (
|
||||
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
||||
<View style={styles.grip}>
|
||||
<GripVertical size={16} color={c.mutedForeground} />
|
||||
</View>
|
||||
{!isOpaque && rules.length === 0 && !showVacationBanner && (
|
||||
<View style={styles.emptyState}>
|
||||
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>{t('settings.filters.no_rules', 'No filter rules')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{rules.map((rule) => {
|
||||
const readonly = isReadonlyRule(rule);
|
||||
const editIdx = editableIds.indexOf(rule.id);
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.ruleName}>{rule.name}</Text>
|
||||
<Text style={styles.ruleSummary} numberOfLines={expandedView ? 0 : 1}>
|
||||
{rule.summary} → {rule.action}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{deleteId === rule.id ? (
|
||||
<View style={{ flexDirection: 'row', gap: 4 }}>
|
||||
<Button variant="destructive" size="sm" onPress={() => remove(rule.id)}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onPress={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
if (readonly) {
|
||||
const label = rule.originLabel || t('settings.filters.origin_external', 'External');
|
||||
const hasStructured = rule.origin === 'external' && rule.conditions.length > 0 && rule.actions.length > 0;
|
||||
return (
|
||||
<View key={rule.id} style={styles.ruleRow}>
|
||||
<Lock size={16} color={c.mutedForeground} style={{ marginTop: 2 }} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View style={styles.ruleNameRow}>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
|
||||
</View>
|
||||
{hasStructured ? (
|
||||
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
|
||||
{summarizeRule(rule, t)}
|
||||
</Text>
|
||||
) : rule.rawBlock ? (
|
||||
<Text style={styles.rawBlock} numberOfLines={expandedView ? undefined : 4}>
|
||||
{rule.rawBlock.trim()}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
||||
<View style={{ paddingTop: 2 }}>
|
||||
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} />
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
onPress={() => { setEditingRule(rule); setShowRuleModal(true); }}
|
||||
>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
|
||||
{summarizeRule(rule, t)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.reorderCol}>
|
||||
<Pressable
|
||||
onPress={() => moveRule(rule.id, -1)}
|
||||
disabled={editIdx <= 0}
|
||||
hitSlop={6}
|
||||
style={[styles.reorderBtn, editIdx <= 0 && styles.reorderBtnDisabled]}
|
||||
>
|
||||
<ChevronUp size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => moveRule(rule.id, 1)}
|
||||
disabled={editIdx === editableIds.length - 1}
|
||||
hitSlop={6}
|
||||
style={[styles.reorderBtn, editIdx === editableIds.length - 1 && styles.reorderBtnDisabled]}
|
||||
>
|
||||
<ChevronDown size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={() => handleDelete(rule)} hitSlop={6} style={styles.deleteBtn}>
|
||||
<X size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
||||
<View style={styles.footerBtns}>
|
||||
{!isOpaque && (
|
||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
|
||||
Add Rule
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Plus size={14} color={c.text} />}
|
||||
onPress={() => { setEditingRule(undefined); setShowRuleModal(true); }}
|
||||
>
|
||||
{t('settings.filters.add_rule', 'Add Rule')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
|
||||
Raw Editor
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Code size={14} color={c.text} />}
|
||||
onPress={() => setShowSieveEditor(true)}
|
||||
>
|
||||
{t('settings.filters.raw_editor', 'Raw Sieve Editor')}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={styles.expandedToggle}>
|
||||
<Text style={styles.expandedLabel}>Expanded view</Text>
|
||||
<ToggleSwitch checked={expandedView} onChange={setExpandedView} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.footerRight}>
|
||||
{isSaving && <ActivityIndicator size="small" color={c.mutedForeground} />}
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={styles.expandedToggle}>
|
||||
<Text style={styles.expandedLabel}>{t('settings.filters.expanded_view', 'Expanded view')}</Text>
|
||||
<ToggleSwitch checked={expandedView} onChange={(v) => updateSetting('filtersExpandedView', v)} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FilterRuleModal
|
||||
visible={showRuleModal}
|
||||
rule={editingRule}
|
||||
mailboxes={mailboxes}
|
||||
onSave={handleSaveRule}
|
||||
onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }}
|
||||
/>
|
||||
|
||||
<SieveEditorSheet
|
||||
visible={showSieveEditor}
|
||||
content={rawScript}
|
||||
onSave={handleSaveSieve}
|
||||
onClose={() => setShowSieveEditor(false)}
|
||||
onValidate={handleValidate}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xl },
|
||||
opaqueWarning: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
opaqueText: { ...typography.caption, color: '#fde68a' },
|
||||
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
|
||||
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
|
||||
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
|
||||
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
||||
emptyText: { ...typography.body, color: c.mutedForeground },
|
||||
ruleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
ruleDisabled: { opacity: 0.6 },
|
||||
grip: { paddingTop: 2 },
|
||||
ruleName: { ...typography.bodyMedium, color: c.text },
|
||||
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
iconBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
expandedLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
statusText: { ...typography.body, color: c.mutedForeground, paddingVertical: spacing.md },
|
||||
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.md },
|
||||
|
||||
opaqueBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
opaqueText: { ...typography.caption, color: '#fde68a' },
|
||||
opaqueActions: { flexDirection: 'row', gap: spacing.lg, marginTop: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
|
||||
resetConfirmRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'center' },
|
||||
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
linkPrimary: { ...typography.caption, color: c.primary, fontWeight: '500' },
|
||||
linkDanger: { ...typography.caption, color: c.error, fontWeight: '500' },
|
||||
linkMuted: { ...typography.caption, color: c.mutedForeground },
|
||||
|
||||
vacationBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.success,
|
||||
backgroundColor: c.successBg,
|
||||
},
|
||||
vacationIcon: {
|
||||
width: 32, height: 32, borderRadius: radius.full,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||
},
|
||||
vacationTitle: { ...typography.bodyMedium, color: c.success },
|
||||
vacationDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
|
||||
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
||||
emptyText: { ...typography.body, color: c.mutedForeground },
|
||||
|
||||
ruleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
ruleDisabled: { opacity: 0.6 },
|
||||
ruleNameRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
ruleName: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
|
||||
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
rawBlock: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
fontFamily: 'monospace',
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
|
||||
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
|
||||
reorderCol: { alignItems: 'center' },
|
||||
reorderBtn: { padding: 2 },
|
||||
reorderBtnDisabled: { opacity: 0.25 },
|
||||
deleteBtn: {
|
||||
width: 28, height: 28,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
footerBtns: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
footerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
expandedLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,12 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore, type DateFormat, type TimeFormat } from '../../stores/settings-store';
|
||||
import { formatListDate } from '../../lib/date-format';
|
||||
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
|
||||
|
||||
export function LanguageSettings() {
|
||||
@@ -12,15 +14,30 @@ export function LanguageSettings() {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const override = useLocaleStore((s) => s.override);
|
||||
const setOverride = useLocaleStore((s) => s.setOverride);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const hydrated = useLocaleStore((s) => s.hydrated);
|
||||
const hydrate = useLocaleStore((s) => s.hydrate);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrateSettings = useSettingsStore((s) => s.hydrate);
|
||||
const dateFormat = useSettingsStore((s) => s.dateFormat);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
useEffect(() => { if (!settingsHydrated) void hydrateSettings(); }, [settingsHydrated, hydrateSettings]);
|
||||
|
||||
const deviceCode = detectDeviceLocale();
|
||||
const selected: LocaleCode | 'system' = override ?? 'system';
|
||||
|
||||
// Build live preview samples so the user sees what each format looks like.
|
||||
const now = new Date();
|
||||
const fmtOpts = { dateFormat, timeFormat, locale };
|
||||
const previewToday = formatListDate(now, fmtOpts);
|
||||
const previewWeek = formatListDate(new Date(now.getTime() - 3 * 86400000), fmtOpts);
|
||||
const previewOlder = formatListDate(new Date(now.getTime() - 40 * 86400000), fmtOpts);
|
||||
|
||||
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
|
||||
const active = selected === code;
|
||||
return (
|
||||
@@ -42,21 +59,61 @@ export function LanguageSettings() {
|
||||
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.appearance.language.label', 'Language')}
|
||||
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.appearance.language.label', 'Language')}
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
<SettingsSection
|
||||
title={t('settings.appearance.language.label', 'Language')}
|
||||
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||
noBorder
|
||||
/>
|
||||
<View style={styles.list}>
|
||||
{renderRow('system', `System default (${deviceLabel})`)}
|
||||
<View style={styles.divider} />
|
||||
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{renderRow('system', `System default (${deviceLabel})`)}
|
||||
<View style={styles.divider} />
|
||||
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.language_region.date_format.label', 'Date Format')}
|
||||
description={t('settings.language_region.date_format.description', 'How dates are shown in the email list')}
|
||||
>
|
||||
<RadioGroup
|
||||
value={dateFormat}
|
||||
onChange={(v) => update('dateFormat', v as DateFormat)}
|
||||
options={[
|
||||
{ value: 'smart', label: t('settings.language_region.date_format.smart', 'Smart (locale-aware)') },
|
||||
{ value: 'relative', label: t('settings.language_region.date_format.relative', 'Relative (1h ago, 2d ago)') },
|
||||
{ value: 'full', label: t('settings.language_region.date_format.full', 'Always full date') },
|
||||
]}
|
||||
/>
|
||||
<View style={styles.previewBox}>
|
||||
<View style={styles.previewRow}>
|
||||
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_today', 'Today:')}</Text>
|
||||
<Text style={styles.previewValue}>{previewToday}</Text>
|
||||
</View>
|
||||
<View style={styles.previewRow}>
|
||||
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_this_week', 'This week:')}</Text>
|
||||
<Text style={styles.previewValue}>{previewWeek}</Text>
|
||||
</View>
|
||||
<View style={styles.previewRow}>
|
||||
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_older', 'Older:')}</Text>
|
||||
<Text style={styles.previewValue}>{previewOlder}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.language_region.time_format.label', 'Time Format')}
|
||||
description={t('settings.language_region.time_format.description', 'Choose between 12-hour or 24-hour clock')}
|
||||
>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => update('timeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '24h', label: t('settings.language_region.time_format.24h', '24-hour') },
|
||||
{ value: '12h', label: t('settings.language_region.time_format.12h', '12-hour') },
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,5 +131,15 @@ function makeStyles(c: ThemePalette) {
|
||||
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
|
||||
label: { ...typography.body, color: c.text },
|
||||
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
previewBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
gap: 4,
|
||||
},
|
||||
previewRow: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.md },
|
||||
previewLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
previewValue: { ...typography.captionMedium, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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 { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
|
||||
import { SettingsSection, RadioGroup, Select } 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,
|
||||
normalizeBottomQuickActions,
|
||||
REPLY_QUICK_ACTIONS,
|
||||
ALL_QUICK_ACTIONS,
|
||||
type SwipeAction,
|
||||
type SwipeMode,
|
||||
type QuickAction,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
@@ -13,8 +21,33 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'spam', label: 'Spam' },
|
||||
{ value: 'read', label: 'Read/Unread' },
|
||||
{ value: 'star', label: 'Star' },
|
||||
{ value: 'pin', label: 'Pin' },
|
||||
{ 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)' },
|
||||
];
|
||||
|
||||
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
|
||||
reply: 'Reply',
|
||||
replyAll: 'Reply All',
|
||||
forward: 'Forward',
|
||||
delete: 'Delete',
|
||||
archive: 'Archive',
|
||||
markUnread: 'Mark Read/Unread',
|
||||
star: 'Star',
|
||||
move: 'Move to folder',
|
||||
spam: 'Spam',
|
||||
tag: 'Tag',
|
||||
};
|
||||
|
||||
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
|
||||
value,
|
||||
label: QUICK_ACTION_LABELS[value],
|
||||
}));
|
||||
|
||||
export function LayoutSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -22,14 +55,45 @@ 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 bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
|
||||
// Pick an action for a bar slot. If the chosen action already occupies
|
||||
// another slot, swap the two so the three slots stay unique.
|
||||
const setQuickActionSlot = (index: number, value: QuickAction) => {
|
||||
const next = [...bottomActions];
|
||||
const existing = next.indexOf(value);
|
||||
if (existing !== -1 && existing !== index) {
|
||||
next[existing] = next[index];
|
||||
}
|
||||
next[index] = value;
|
||||
update('bottomQuickActions', next);
|
||||
};
|
||||
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
|
||||
|
||||
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>
|
||||
@@ -59,10 +123,34 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SettingItem
|
||||
label=""
|
||||
description="Drag past about half a row to commit; lift earlier to cancel."
|
||||
/>
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<MoveVertical size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Reader quick actions</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
The three buttons shown between Prev/Next at the bottom of an open
|
||||
email. Defaults to Reply, Reply All and Forward.
|
||||
</Text>
|
||||
{bottomActions.map((action, index) => (
|
||||
<View key={index} style={styles.slotRow}>
|
||||
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
|
||||
<Select
|
||||
value={action}
|
||||
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
|
||||
options={QUICK_ACTION_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{relocated.length > 0 && (
|
||||
<Text style={styles.rowDescription}>
|
||||
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
|
||||
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
|
||||
you can still use {relocated.length === 1 ? 'it' : 'them'}.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -72,5 +160,13 @@ function makeStyles(c: ThemePalette) {
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
slotRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
slotLabel: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -20,32 +21,56 @@ interface Plugin {
|
||||
forceEnabled?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
|
||||
installed: { bg: c.muted, fg: c.mutedForeground },
|
||||
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
|
||||
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
|
||||
disabled: { bg: c.muted, fg: c.mutedForeground },
|
||||
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
|
||||
};
|
||||
function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; fg: string } {
|
||||
switch (status) {
|
||||
case 'installed': return { bg: c.muted, fg: c.mutedForeground };
|
||||
case 'enabled': return { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' };
|
||||
case 'running': return { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' };
|
||||
case 'disabled': return { bg: c.muted, fg: c.mutedForeground };
|
||||
case 'error': return { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' };
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -63,7 +88,7 @@ export function PluginsSettings() {
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{plugins.map((plugin) => {
|
||||
const expanded = expandedId === plugin.id;
|
||||
const status = STATUS_STYLE[plugin.status];
|
||||
const status = statusStyle(c, plugin.status);
|
||||
return (
|
||||
<View
|
||||
key={plugin.id}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
|
||||
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from '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 ExternalContentPolicy } from '../../stores/settings-store';
|
||||
|
||||
type MailLayout = 'split' | 'focus';
|
||||
type DeleteAction = 'trash' | 'permanent';
|
||||
type ArchiveMode = 'single' | 'year' | 'month';
|
||||
type AttachmentAction = 'preview' | 'download';
|
||||
type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
|
||||
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
|
||||
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';
|
||||
|
||||
function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
const c = useColors();
|
||||
@@ -71,43 +72,96 @@ 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 update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
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 includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
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 [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, setArchiveMode] = useState<ArchiveMode>('single');
|
||||
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);
|
||||
const [reorganizing, setReorganizing] = useState(false);
|
||||
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||
const archiveMailbox = mailboxes.find(
|
||||
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
||||
);
|
||||
if (!archiveMailbox) {
|
||||
setReorganizeResult('No archive folder found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setReorganizing(true);
|
||||
setReorganizeResult(null);
|
||||
|
||||
try {
|
||||
// Drain the archive in pages so we don't OOM on huge mailboxes.
|
||||
const PAGE = 100;
|
||||
let position = 0;
|
||||
let total = 0;
|
||||
let moved = 0;
|
||||
|
||||
while (true) {
|
||||
const { ids, total: pageTotal } = await queryEmails(archiveMailbox.id, {
|
||||
position,
|
||||
limit: PAGE,
|
||||
});
|
||||
if (position === 0) total = pageTotal;
|
||||
if (ids.length === 0) break;
|
||||
|
||||
const list = await getEmails(ids);
|
||||
const refreshed = useEmailStore.getState().mailboxes;
|
||||
await archiveEmails(
|
||||
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
|
||||
archiveMailbox.id,
|
||||
archiveMode,
|
||||
refreshed,
|
||||
);
|
||||
moved += list.length;
|
||||
|
||||
// Newly-created year/month folders need to be visible to the next batch
|
||||
// so we don't try to create the same folder twice.
|
||||
await fetchMailboxes();
|
||||
|
||||
// Items just got moved out of the root archive view; the next page
|
||||
// starts again at position 0 of the now-shorter list.
|
||||
if (ids.length < PAGE) break;
|
||||
}
|
||||
|
||||
setReorganizeResult(`Moved ${moved} of ${total} emails.`);
|
||||
} catch (err) {
|
||||
setReorganizeResult(err instanceof Error ? err.message : 'Reorganize failed.');
|
||||
} finally {
|
||||
setReorganizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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' },
|
||||
@@ -121,9 +175,10 @@ 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: 'trash-and-read', label: 'Move to Trash and mark as read' },
|
||||
{ value: 'permanent', label: 'Permanently delete' },
|
||||
]}
|
||||
/>
|
||||
@@ -140,7 +195,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' },
|
||||
@@ -148,23 +203,38 @@ export function ReadingSettings() {
|
||||
]}
|
||||
/>
|
||||
{archiveMode !== 'single' && (
|
||||
<Pressable style={styles.inlineBtn}>
|
||||
<FolderSync size={14} color={c.text} />
|
||||
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
|
||||
</Pressable>
|
||||
<>
|
||||
<Pressable
|
||||
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
|
||||
onPress={reorganizing ? undefined : handleReorganizeArchive}
|
||||
disabled={reorganizing}
|
||||
>
|
||||
{reorganizing ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<FolderSync size={14} color={c.text} />
|
||||
)}
|
||||
<Text style={styles.inlineBtnText}>
|
||||
{reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{reorganizeResult && (
|
||||
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<View style={styles.divider} />
|
||||
</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' },
|
||||
@@ -175,25 +245,29 @@ 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="Include Group Inboxes" description="Also show group and shared inboxes in the unified All Inboxes view.">
|
||||
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', 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>
|
||||
|
||||
@@ -207,7 +281,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}
|
||||
>
|
||||
@@ -226,7 +303,7 @@ export function ReadingSettings() {
|
||||
onSubmitEditing={() => {
|
||||
const t = newKeyword.trim().toLowerCase();
|
||||
if (t && !attachmentReminderKeywords.includes(t)) {
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
|
||||
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
@@ -236,7 +313,7 @@ export function ReadingSettings() {
|
||||
onPress={() => {
|
||||
const t = newKeyword.trim().toLowerCase();
|
||||
if (t && !attachmentReminderKeywords.includes(t)) {
|
||||
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
|
||||
update('attachmentReminderKeywords', [...attachmentReminderKeywords, t]);
|
||||
}
|
||||
setNewKeyword('');
|
||||
}}
|
||||
@@ -249,13 +326,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' },
|
||||
@@ -266,7 +343,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' },
|
||||
@@ -276,8 +353,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' },
|
||||
@@ -327,6 +404,14 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
},
|
||||
inlineBtnDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
reorganizeResult: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
subLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -121,6 +124,8 @@ interface AppFormProps {
|
||||
}
|
||||
|
||||
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
|
||||
const c = useColors();
|
||||
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [url, setUrl] = useState(initial?.url ?? '');
|
||||
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
|
||||
@@ -254,40 +259,42 @@ function makeStyles(c: ThemePalette) {
|
||||
});
|
||||
}
|
||||
|
||||
const formStyles = StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
label: { ...typography.captionMedium, color: c.text },
|
||||
input: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
modeBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
modeBtnActive: {
|
||||
borderColor: c.primary,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
modeBtnText: { ...typography.caption, color: c.text },
|
||||
});
|
||||
function makeFormStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
label: { ...typography.captionMedium, color: c.text },
|
||||
input: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
},
|
||||
modeBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
modeBtnActive: {
|
||||
borderColor: c.primary,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
modeBtnText: { ...typography.caption, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
@@ -119,18 +126,18 @@ export function SmimeSettings() {
|
||||
description="Public certificates used to encrypt outgoing mail."
|
||||
>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{certs.map((c) => {
|
||||
const expired = isExpired(c.notAfter);
|
||||
{certs.map((cert) => {
|
||||
const expired = isExpired(cert.notAfter);
|
||||
return (
|
||||
<View key={c.id} style={styles.certRow}>
|
||||
<View key={cert.id} style={styles.certRow}>
|
||||
<View style={styles.certLeft}>
|
||||
<View style={[styles.certIcon, styles.certIconMuted]}>
|
||||
<Users size={16} color={c.mutedForeground} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
|
||||
<Text style={styles.certName} numberOfLines={1}>{cert.email || cert.subject}</Text>
|
||||
<Text style={styles.certMeta}>
|
||||
{c.issuer} · {c.source}
|
||||
{cert.issuer} · {cert.source}
|
||||
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -139,7 +146,7 @@ export function SmimeSettings() {
|
||||
<Pressable style={styles.iconBtn}>
|
||||
<Eye size={16} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteCert(cert.id)}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -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}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, Linking } from 'react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -30,9 +30,8 @@ function formatProgressLabel(p: InstallProgress): string {
|
||||
function installButtonLabel(
|
||||
installing: boolean,
|
||||
progress: InstallProgress | null,
|
||||
apkAsset: unknown,
|
||||
): string {
|
||||
if (!installing) return apkAsset ? 'Install' : 'Open release';
|
||||
if (!installing) return 'Install';
|
||||
if (progress?.phase === 'installing') return 'Installing…';
|
||||
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
|
||||
return 'Downloading…';
|
||||
@@ -62,14 +61,17 @@ export function UpdatesSettings() {
|
||||
|
||||
const updateAvailable = hasUpdate();
|
||||
const apkAsset = cachedLatest?.apkAsset ?? null;
|
||||
const apkPending = updateAvailable && !apkAsset;
|
||||
|
||||
useEffect(() => {
|
||||
if (!apkPending) return;
|
||||
const id = setInterval(() => {
|
||||
void checkNow({ force: true });
|
||||
}, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [apkPending, checkNow]);
|
||||
|
||||
const onInstall = () => {
|
||||
if (!apkAsset) {
|
||||
if (cachedLatest?.htmlUrl) {
|
||||
void Linking.openURL(cachedLatest.htmlUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void installLatest();
|
||||
};
|
||||
|
||||
@@ -99,19 +101,38 @@ export function UpdatesSettings() {
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={updateAvailable ? 'Update available' : 'Check for updates'}
|
||||
description={updateAvailable
|
||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||
: undefined}
|
||||
label={
|
||||
apkPending
|
||||
? 'Update building'
|
||||
: updateAvailable
|
||||
? 'Update available'
|
||||
: 'Check for updates'
|
||||
}
|
||||
description={
|
||||
apkPending
|
||||
? `v${cachedLatest?.tag} was published, but the APK is still being built. This page will check again every 30 seconds.`
|
||||
: updateAvailable
|
||||
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{updateAvailable ? (
|
||||
{apkPending ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => void checkNow({ force: true })}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? 'Checking…' : 'Check again'}
|
||||
</Button>
|
||||
) : updateAvailable ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installButtonLabel(installing, installProgress, apkAsset)}
|
||||
{installButtonLabel(installing, installProgress)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -125,6 +146,21 @@ export function UpdatesSettings() {
|
||||
)}
|
||||
</SettingItem>
|
||||
|
||||
{apkPending && cachedLatest?.htmlUrl ? (
|
||||
<SettingItem
|
||||
label="Release page"
|
||||
description="Watch the build progress or download the APK manually."
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
|
||||
>
|
||||
Open on GitHub
|
||||
</Button>
|
||||
</SettingItem>
|
||||
) : null}
|
||||
|
||||
{installing && installProgress ? (
|
||||
<View style={styles.progressBox}>
|
||||
<View style={styles.progressTrack}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
normalizeAllDayDuration,
|
||||
buildAllDayDuration,
|
||||
getCalendarColor,
|
||||
getEventStartDate,
|
||||
getEventEndDate,
|
||||
timePattern,
|
||||
layoutOverlappingEvents,
|
||||
CALENDAR_COLOR_PALETTE,
|
||||
} from '../calendar-utils';
|
||||
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
|
||||
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
|
||||
// floating `start` + duration instead of producing an Invalid Date, which
|
||||
// would silently drop the event from the day index and every view.
|
||||
it('falls back to start when utcStart is unparseable', () => {
|
||||
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
|
||||
expect(isNaN(d.getTime())).toBe(false);
|
||||
expect(d.getHours()).toBe(9);
|
||||
});
|
||||
|
||||
it('falls back to start + duration when utcEnd is unparseable', () => {
|
||||
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
|
||||
expect(isNaN(end.getTime())).toBe(false);
|
||||
const start = getEventStartDate(ev({}));
|
||||
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timePattern', () => {
|
||||
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
|
||||
expect(timePattern('12h')).toBe('h:mm a');
|
||||
expect(timePattern('24h')).toBe('HH:mm');
|
||||
expect(timePattern(undefined)).toBe('HH:mm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventTimeRange', () => {
|
||||
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
|
||||
const range = eventTimeRange(ev({
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../compose-html';
|
||||
|
||||
describe('buildInitialHtml', () => {
|
||||
it('returns empty paragraph for plain compose', () => {
|
||||
expect(buildInitialHtml('compose')).toBe('<p><br></p>');
|
||||
});
|
||||
|
||||
it('returns empty paragraph when reply has no body', () => {
|
||||
expect(buildInitialHtml('reply', { from: { email: 'a@b.com' } })).toBe('<p><br></p>');
|
||||
});
|
||||
|
||||
it('builds reply with quoted plain-text body and sender label', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { name: 'Alice', email: 'a@b.com' },
|
||||
subject: 'Hello',
|
||||
body: 'line one\nline two',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('<p><br></p>');
|
||||
// Default header labels the sender by name only (matches webmail #295).
|
||||
expect(html).toMatch(/On .+, Alice wrote:/);
|
||||
expect(html).not.toContain('a@b.com');
|
||||
expect(html).toContain('<blockquote');
|
||||
expect(html).toContain('line one<br>line two');
|
||||
});
|
||||
|
||||
it('falls back to the email address when the sender has no name', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'a@b.com' },
|
||||
body: 'hi',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('a@b.com wrote:');
|
||||
});
|
||||
|
||||
it('uses HTML body when present, stripping dangerous tags', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'x@y.com' },
|
||||
body: 'fallback',
|
||||
htmlBody: '<p>safe</p><script>alert(1)</script>',
|
||||
});
|
||||
expect(html).toContain('<p>safe</p>');
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('forward: includes "Forwarded message" header and From/Subject lines', () => {
|
||||
const html = buildInitialHtml('forward', {
|
||||
from: { name: 'Bob', email: 'b@c.com' },
|
||||
subject: 'Original Subject',
|
||||
body: 'forwarded body',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('Forwarded message');
|
||||
expect(html).toContain('From: Bob');
|
||||
expect(html).toContain('Subject: Original Subject');
|
||||
expect(html).toContain('forwarded body');
|
||||
});
|
||||
|
||||
it('escapes HTML in plain-text quoted body', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'a@b.com' },
|
||||
body: '<script>x</script>',
|
||||
});
|
||||
expect(html).not.toContain('<script>x</script>');
|
||||
expect(html).toContain('<script>x</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('htmlToPlainText', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToPlainText('')).toBe('');
|
||||
});
|
||||
|
||||
it('strips tags and decodes entities', () => {
|
||||
expect(htmlToPlainText('<p>hello & world</p>')).toBe('hello & world');
|
||||
});
|
||||
|
||||
it('converts <br> to newline', () => {
|
||||
expect(htmlToPlainText('a<br>b<br/>c')).toBe('a\nb\nc');
|
||||
});
|
||||
|
||||
it('treats block boundaries as newlines', () => {
|
||||
expect(htmlToPlainText('<p>a</p><p>b</p>')).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('renders list items with bullet prefix', () => {
|
||||
expect(htmlToPlainText('<ul><li>one</li><li>two</li></ul>')).toBe('• one\n• two');
|
||||
});
|
||||
|
||||
it('collapses excessive blank lines', () => {
|
||||
expect(htmlToPlainText('<p>a</p><br><br><br><p>b</p>')).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('decodes to space', () => {
|
||||
expect(htmlToPlainText('hello world')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteInlineImages', () => {
|
||||
it('returns input unchanged when no data-cid is present', () => {
|
||||
const html = '<p>hi <img src="https://x"></p>';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toBe(html);
|
||||
expect(out.usedCids).toEqual([]);
|
||||
});
|
||||
|
||||
it('rewrites data-cid images to cid: src and reports the CID', () => {
|
||||
const html = '<p><img src="data:image/png;base64,xxx" data-cid="abc@x" alt="pic"></p>';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('src="cid:abc@x"');
|
||||
expect(out.html).not.toContain('data-cid');
|
||||
expect(out.html).not.toContain('data:image/png');
|
||||
expect(out.usedCids).toEqual(['abc@x']);
|
||||
});
|
||||
|
||||
it('handles single-quoted attributes', () => {
|
||||
const html = `<img src='data:image/png' data-cid='zzz' alt='x'>`;
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('src="cid:zzz"');
|
||||
expect(out.usedCids).toEqual(['zzz']);
|
||||
});
|
||||
|
||||
it('preserves alt attribute on rewritten images', () => {
|
||||
const html = '<img src="data:" alt="my pic" data-cid="m1">';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.html).toContain('alt="my pic"');
|
||||
});
|
||||
|
||||
it('reports each cid only once even if image referenced multiple times', () => {
|
||||
const html = '<img src="data:" data-cid="dup"><img src="data:" data-cid="dup">';
|
||||
const out = rewriteInlineImages(html);
|
||||
expect(out.usedCids).toEqual(['dup']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { formatListDate } from '../date-format';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('formatListDate', () => {
|
||||
it('returns empty string for an invalid date', () => {
|
||||
expect(formatListDate('not-a-date', { dateFormat: 'smart', timeFormat: '24h', locale: 'en' })).toBe('');
|
||||
});
|
||||
|
||||
it('relative: minutes / hours / days ago', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
const opts = { dateFormat: 'relative' as const, timeFormat: '24h' as const, locale: 'en' };
|
||||
expect(formatListDate(new Date(Date.now() - 5 * 60000), opts)).toBe('5m ago');
|
||||
expect(formatListDate(new Date(Date.now() - 3 * 3600000), opts)).toBe('3h ago');
|
||||
expect(formatListDate(new Date(Date.now() - 2 * 86400000), opts)).toBe('2d ago');
|
||||
});
|
||||
|
||||
it('relative: "Just now" under a minute', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
expect(
|
||||
formatListDate(new Date(Date.now() - 10_000), { dateFormat: 'relative', timeFormat: '24h', locale: 'en' }),
|
||||
).toBe('Just now');
|
||||
});
|
||||
|
||||
it('smart: same-day shows time only (24h, no AM/PM)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
const out = formatListDate(new Date(), { dateFormat: 'smart', timeFormat: '24h', locale: 'en' });
|
||||
expect(out).not.toMatch(/AM|PM/);
|
||||
expect(out).toMatch(/^\d{1,2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('smart: older than a week shows a numeric date', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
const out = formatListDate(new Date('2026-01-01T09:00:00Z'), {
|
||||
dateFormat: 'smart',
|
||||
timeFormat: '24h',
|
||||
locale: 'en',
|
||||
});
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
|
||||
it('full: always includes the year', () => {
|
||||
const out = formatListDate('2026-04-28T15:31:00Z', { dateFormat: 'full', timeFormat: '24h', locale: 'en' });
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
} from '../download-filename';
|
||||
|
||||
const SAMPLE = buildSampleEmail();
|
||||
|
||||
describe('emailExportFilename', () => {
|
||||
it('renders the default template and appends .eml', () => {
|
||||
const name = emailExportFilename(SAMPLE, DEFAULT_EMAIL_TEMPLATE);
|
||||
expect(name.endsWith('.eml')).toBe(true);
|
||||
expect(name).toContain('2026-05-22');
|
||||
expect(name).toContain('Alice');
|
||||
expect(name).toContain('Bob');
|
||||
});
|
||||
|
||||
it('falls back to "email.eml" when the template renders empty', () => {
|
||||
expect(emailExportFilename(SAMPLE, '')).toBe('email.eml');
|
||||
});
|
||||
|
||||
it('applies the lowercase + underscore transforms', () => {
|
||||
const name = emailExportFilename(SAMPLE, {
|
||||
template: '{from} {subject}',
|
||||
lowercase: true,
|
||||
spaceReplacement: 'underscore',
|
||||
});
|
||||
expect(name).toBe(name.toLowerCase());
|
||||
expect(name).not.toContain(' ');
|
||||
});
|
||||
|
||||
it('strips diacritics when requested', () => {
|
||||
const name = emailExportFilename(SAMPLE, {
|
||||
template: '{subject}',
|
||||
stripDiacritics: true,
|
||||
});
|
||||
// "Gerät" → "Gerat"
|
||||
expect(name).toContain('Gerat');
|
||||
expect(name).not.toContain('ä');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachmentDownloadFilename', () => {
|
||||
const attachment = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
|
||||
|
||||
it('keeps the original name with the {filename} template', () => {
|
||||
const name = attachmentDownloadFilename(SAMPLE, attachment, '{filename}');
|
||||
expect(name).toBe('Rechnung 2026.pdf');
|
||||
});
|
||||
|
||||
it('re-appends the extension when the template omits it', () => {
|
||||
const name = attachmentDownloadFilename(SAMPLE, attachment, '{date_short} {name}');
|
||||
expect(name.endsWith('.pdf')).toBe(true);
|
||||
expect(name).toContain('2026-05-22');
|
||||
});
|
||||
|
||||
it('falls back to the raw name when no email is provided', () => {
|
||||
const name = attachmentDownloadFilename(null, attachment);
|
||||
expect(name).toBe('Rechnung 2026.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Provide the Android native FCM surface setupPushNotifications needs. The
|
||||
// global test-setup mocks react-native with an empty NativeModules, so override
|
||||
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
|
||||
// the runtime permission request).
|
||||
vi.mock('react-native', () => {
|
||||
class NativeEventEmitter {
|
||||
addListener() {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
}
|
||||
return {
|
||||
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
|
||||
NativeModules: {
|
||||
BulwarkFcm: {
|
||||
getToken: vi.fn(async () => 'fcm-token-xyz'),
|
||||
deleteToken: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
NativeEventEmitter,
|
||||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/push', () => ({
|
||||
listPushSubscriptions: vi.fn(async () => []),
|
||||
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
||||
verifyPushSubscription: vi.fn(async () => undefined),
|
||||
destroyPushSubscription: vi.fn(async () => undefined),
|
||||
updatePushSubscription: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
|
||||
import {
|
||||
listPushSubscriptions,
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { generateAccountId } from '../account-utils';
|
||||
|
||||
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
|
||||
const RELAY = 'https://relay.example.com';
|
||||
|
||||
// State the fake relay reports for each foreign deviceClientId's /active probe.
|
||||
type RelayState = 'dead' | 'live' | 'unknown';
|
||||
|
||||
function installFetch(states: Record<string, RelayState>): void {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/register')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
|
||||
if (active) {
|
||||
const dcid = decodeURIComponent(active[1]);
|
||||
const state = states[dcid] ?? 'unknown';
|
||||
if (state === 'unknown') {
|
||||
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
||||
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
|
||||
|
||||
function sub(id: string, deviceClientId: string) {
|
||||
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
|
||||
}
|
||||
|
||||
describe('setupPushNotifications leftover reaping', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
// Pin our deviceClientId so we control which leftovers are "ours".
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
});
|
||||
|
||||
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
|
||||
listMock.mockResolvedValue([
|
||||
sub('own-old', OUR_DCID), // our own previous attempt -> reap
|
||||
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
|
||||
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
|
||||
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
|
||||
]);
|
||||
installFetch({
|
||||
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
|
||||
livelivelivelivelivelivelivelive: 'live',
|
||||
unknwunknwunknwunknwunknwunknwun: 'unknown',
|
||||
});
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
const reaped = destroyMock.mock.calls.map((c) => c[0]);
|
||||
expect(reaped).toContain('own-old');
|
||||
expect(reaped).toContain('foreign-dead');
|
||||
expect(reaped).not.toContain('foreign-live');
|
||||
expect(reaped).not.toContain('foreign-unknown');
|
||||
});
|
||||
|
||||
it('keeps foreign subs when the relay probe fails (network error)', async () => {
|
||||
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/register')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/active/')) throw new Error('network down');
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
|
||||
});
|
||||
|
||||
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
|
||||
// Fire several overlapping setups, as App.tsx does while auth settles and
|
||||
// on FCM token refresh. Only one underlying JMAP subscription must be made.
|
||||
const results = await Promise.all([
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
]);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -174,3 +174,77 @@ describe('expandRecurringEvents - range filtering', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - excludedRecurrenceRules', () => {
|
||||
it('removes occurrences generated by excluded rules', () => {
|
||||
const event = ev({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ frequency: 'daily' }],
|
||||
excludedRecurrenceRules: [{
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'tu' }],
|
||||
}],
|
||||
});
|
||||
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
|
||||
const result = expandRecurringEvents([event], '2025-01-06T00:00:00', '2025-01-13T00:00:00');
|
||||
const days = result.map((e) => e.start.substring(0, 10));
|
||||
expect(days).not.toContain('2025-01-07');
|
||||
expect(days).toContain('2025-01-06');
|
||||
expect(days).toContain('2025-01-08');
|
||||
expect(result).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - long-running series', () => {
|
||||
it('expands a daily series started years before the visible range', () => {
|
||||
const event = ev({
|
||||
start: '2022-01-03T09:00:00',
|
||||
recurrenceRules: [{ frequency: 'daily' }],
|
||||
});
|
||||
const result = expandRecurringEvents([event], '2025-06-02T00:00:00', '2025-06-05T00:00:00');
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2025-06-02T09:00:00',
|
||||
'2025-06-03T09:00:00',
|
||||
'2025-06-04T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands a weekly series started years before the visible range', () => {
|
||||
const event = ev({
|
||||
start: '2020-01-06T09:00:00', // Monday
|
||||
recurrenceRules: [{ frequency: 'weekly' }],
|
||||
});
|
||||
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
const days = result.map((e) => e.start.substring(0, 10));
|
||||
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
|
||||
});
|
||||
|
||||
it('still respects count for old series (no fast-forward shortcut)', () => {
|
||||
const event = ev({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ frequency: 'daily', count: 5 }],
|
||||
});
|
||||
// Range far after the 5 occurrences ran out
|
||||
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - event timezone DST handling', () => {
|
||||
it('keeps wall time across a DST transition in the event timezone', () => {
|
||||
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
|
||||
const event = ev({
|
||||
start: '2025-03-03T10:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
utcStart: '2025-03-03T15:00:00Z',
|
||||
utcEnd: '2025-03-03T16:00:00Z',
|
||||
recurrenceRules: [{ frequency: 'weekly' }],
|
||||
});
|
||||
const result = expandRecurringEvents([event], '2025-03-03T00:00:00', '2025-03-17T00:00:00');
|
||||
const utcStarts = result.map((e) => e.utcStart);
|
||||
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
|
||||
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
|
||||
const utcEnds = result.map((e) => e.utcEnd);
|
||||
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable — a malformed utcStart used to surface as an
|
||||
// Invalid Date that silently dropped the event from every view (#316).
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
const start = getEventStartDate(event);
|
||||
if (!event.duration) return start;
|
||||
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||
return subMilliseconds(end, 1);
|
||||
}
|
||||
|
||||
// ─── Time-of-day formatting ──────────────────────────────
|
||||
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
|
||||
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
|
||||
// thread the `calendarTimeFormat` setting through so the calendar renders
|
||||
// times in the same format the webmail does.
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
|
||||
export function timePattern(timeFormat: TimeFormat | undefined): string {
|
||||
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
|
||||
}
|
||||
|
||||
export interface EventTimeRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
@@ -435,12 +453,63 @@ export function getEventColor(
|
||||
event: Pick<CalendarEvent, 'calendarIds' | 'color'>,
|
||||
calendars: Calendar[],
|
||||
): string {
|
||||
if (event.color) return event.color;
|
||||
const calendarId = Object.keys(event.calendarIds || {})[0];
|
||||
const cal = calendars.find((c) => c.id === calendarId);
|
||||
// A local color override on a shared calendar wins over per-event colors,
|
||||
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
|
||||
if (cal?.colorIsLocalOverride && cal.color) return cal.color;
|
||||
if (event.color) return event.color;
|
||||
return getCalendarColor(cal);
|
||||
}
|
||||
|
||||
// ─── Per-viewer colors for shared calendars (#345) ───────
|
||||
// Shared calendars are recolored locally only: the viewer usually can't write
|
||||
// the owner's calendar, and doing so would recolor it for everyone. Overrides
|
||||
// live in the settings store keyed by sharedCalendarColorKey().
|
||||
|
||||
/**
|
||||
* Stable key for a shared calendar's local color override. Built from the
|
||||
* owning JMAP account + the calendar id so it stays unique across accounts.
|
||||
*/
|
||||
export function sharedCalendarColorKey(
|
||||
cal: Pick<Calendar, 'id' | 'accountId'>,
|
||||
): string {
|
||||
return `${cal.accountId ?? ''}|${cal.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random palette color not present in `usedColors`. Once every palette
|
||||
* entry is taken, fall back to a random palette color (collisions are
|
||||
* unavoidable past CALENDAR_PALETTE.length calendars).
|
||||
*/
|
||||
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
|
||||
const used = new Set<string>();
|
||||
for (const c of usedColors) {
|
||||
if (c) used.add(c.toLowerCase());
|
||||
}
|
||||
const available = CALENDAR_PALETTE.filter((c) => !used.has(c.toLowerCase()));
|
||||
const pool = available.length > 0 ? available : CALENDAR_PALETTE;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply each shared calendar's local color override (per-viewer recolor).
|
||||
* The override replaces the calendar's color and wins over per-event colors
|
||||
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
|
||||
* are passed through untouched.
|
||||
*/
|
||||
export function applySharedCalendarColors(
|
||||
calendars: Calendar[],
|
||||
overrides: Record<string, string>,
|
||||
): Calendar[] {
|
||||
return calendars.map((cal) => {
|
||||
if (!cal.isShared) return cal;
|
||||
const override = overrides[sharedCalendarColorKey(cal)];
|
||||
if (!override) return cal;
|
||||
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function getPrimaryCalendarId(
|
||||
event: Pick<CalendarEvent, 'calendarIds'>,
|
||||
): string | undefined {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Build the initial HTML body for a new compose / reply / forward, mirroring
|
||||
// what the webmail composer (`components/email/email-composer.tsx`) produces.
|
||||
// All values are HTML-escaped before insertion into the document.
|
||||
|
||||
import { escapeHtml, stripDangerousTags } from './email-html';
|
||||
import type { TimeFormat } from '../stores/settings-store';
|
||||
|
||||
export interface ReplyMeta {
|
||||
from?: { email?: string; name?: string };
|
||||
to?: Array<{ email?: string; name?: string }>;
|
||||
cc?: Array<{ email?: string; name?: string }>;
|
||||
subject?: string;
|
||||
body?: string; // plain-text body (preferred when htmlBody absent)
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
export interface QuoteHeaderOptions {
|
||||
timeFormat?: TimeFormat;
|
||||
locale?: string;
|
||||
unknownLabel?: string;
|
||||
}
|
||||
|
||||
function escapeForHtmlBody(text: string): string {
|
||||
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// Webmail's default quote header labels the sender by display name when there
|
||||
// is one, falling back to the bare address — not "Name <addr>".
|
||||
function senderName(from?: { email?: string; name?: string }, unknownLabel = 'Unknown'): string {
|
||||
if (!from) return unknownLabel;
|
||||
return from.name || from.email || unknownLabel;
|
||||
}
|
||||
|
||||
// Mirror the webmail quote-header date: locale- and 12/24h-aware, with a short
|
||||
// weekday (lib/quote-header.ts → formatDateTime with weekday/year/month/day).
|
||||
function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
|
||||
return d.toLocaleString(intlLocale, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
}
|
||||
|
||||
const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex';
|
||||
|
||||
/**
|
||||
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
||||
* paragraph for plain compose (so the contenteditable has a caret target).
|
||||
*
|
||||
* The reply/forward quote header mirrors the webmail default header
|
||||
* (`lib/quote-header.ts`): a reply gets "On <date>, <sender> wrote:" above a
|
||||
* blockquoted body; a forward gets a From/Date/Subject block followed by the
|
||||
* original body (not blockquoted).
|
||||
*/
|
||||
export function buildInitialHtml(
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
||||
reply?: ReplyMeta | null,
|
||||
opts: QuoteHeaderOptions = {},
|
||||
): string {
|
||||
if (!reply || mode === 'compose') return '<p><br></p>';
|
||||
|
||||
const { timeFormat = '24h', locale, unknownLabel = 'Unknown' } = opts;
|
||||
const date = formatQuoteDate(reply.receivedAt, timeFormat, locale);
|
||||
const fromStr = senderName(reply.from, unknownLabel);
|
||||
const subject = reply.subject ?? '';
|
||||
|
||||
const quotedBody = reply.htmlBody
|
||||
? stripDangerousTags(reply.htmlBody)
|
||||
: reply.body
|
||||
? escapeForHtmlBody(reply.body)
|
||||
: '';
|
||||
|
||||
if (!quotedBody) return '<p><br></p>';
|
||||
|
||||
if (mode === 'forward') {
|
||||
// Forwarded message block (From/Date/Subject), body appended unquoted.
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>---------- Forwarded message ----------<br>',
|
||||
`From: ${escapeHtml(fromStr)}<br>`,
|
||||
`Date: ${escapeHtml(date)}<br>`,
|
||||
`Subject: ${escapeHtml(subject)}<br><br>`,
|
||||
'</div>',
|
||||
quotedBody,
|
||||
].join('');
|
||||
}
|
||||
|
||||
// reply / replyAll
|
||||
const headerLine = date
|
||||
? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:`
|
||||
: `${escapeHtml(fromStr)} wrote:`;
|
||||
return [
|
||||
'<p><br></p>',
|
||||
`<div>${headerLine}<br></div>`,
|
||||
`<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags and decode common entities for a plain-text fallback body.
|
||||
* Mirrors the webmail's `htmlToPlainText` (DOMParser-based) since we don't
|
||||
* have DOMParser in the RN runtime.
|
||||
*/
|
||||
export function htmlToPlainText(html: string): string {
|
||||
if (!html) return '';
|
||||
let s = html;
|
||||
// Treat block-level boundaries and <br> as newlines before stripping tags.
|
||||
s = s.replace(/<br\s*\/?>/gi, '\n');
|
||||
// Paragraph-level closes get a blank line; list/heading/cell closes a single
|
||||
// newline so they stack tightly.
|
||||
s = s.replace(/<\/(p|div|blockquote|pre)>/gi, '\n\n');
|
||||
s = s.replace(/<\/(li|h[1-6]|tr)>/gi, '\n');
|
||||
s = s.replace(/<li[^>]*>/gi, '• ');
|
||||
s = s.replace(/<[^>]+>/g, '');
|
||||
// Decode the most common entities; leave the rest as-is.
|
||||
s = s
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'");
|
||||
// Collapse runs of >2 newlines and trim trailing whitespace per line.
|
||||
s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+\n/g, '\n');
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the editor HTML, replacing `<img data-cid="X" ...>` with `<img src="cid:X">`
|
||||
* (and dropping the data-cid attribute). Returns the rewritten HTML and the
|
||||
* set of CIDs actually referenced so the sender can include exactly the
|
||||
* needed inline parts.
|
||||
*/
|
||||
export function rewriteInlineImages(html: string): { html: string; usedCids: string[] } {
|
||||
if (!html || !/data-cid=/i.test(html)) return { html, usedCids: [] };
|
||||
const used = new Set<string>();
|
||||
const out = html.replace(
|
||||
/<img\b([^>]*?)\sdata-cid=("([^"]*)"|'([^']*)')([^>]*)>/gi,
|
||||
(_full, before: string, _quoted: string, dq: string | undefined, sq: string | undefined, after: string) => {
|
||||
const cid = dq ?? sq ?? '';
|
||||
if (!cid) return _full;
|
||||
used.add(cid);
|
||||
// Drop any existing src= and replace with cid: form.
|
||||
const merged = (before + after)
|
||||
.replace(/\ssrc=("[^"]*"|'[^']*')/gi, '')
|
||||
.trim();
|
||||
const space = merged ? ' ' + merged : '';
|
||||
return `<img src="cid:${cid}"${space}>`;
|
||||
},
|
||||
);
|
||||
return { html: out, usedCids: Array.from(used) };
|
||||
}
|
||||
@@ -119,6 +119,60 @@ export function getBirthday(contact: ContactCard): AnniversaryDate | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
if ((dateInput as Timestamp)['@type'] === 'Timestamp') {
|
||||
const ts = dateInput as Timestamp;
|
||||
const d = new Date(ts.utc);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith('--')) {
|
||||
const parts = s.substring(2).split('-');
|
||||
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||
}
|
||||
const d = new Date(s);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
const { year, month, day } = getDateParts(dateInput);
|
||||
if (!year) return null;
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - year;
|
||||
const m = month ?? 1;
|
||||
const d = day ?? 1;
|
||||
const nowM = now.getMonth() + 1;
|
||||
const nowD = now.getDate();
|
||||
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
|
||||
if (years < 0) return null;
|
||||
return years;
|
||||
}
|
||||
|
||||
export function getPhoneFeatures(features?: Record<string, boolean>): string[] {
|
||||
if (!features) return [];
|
||||
return Object.keys(features).filter((k) => features[k]);
|
||||
}
|
||||
|
||||
export function getActiveContexts(contexts?: Record<string, boolean>): string[] {
|
||||
if (!contexts) return [];
|
||||
return Object.keys(contexts).filter((k) => contexts[k]);
|
||||
}
|
||||
|
||||
export function getPrimaryNickname(contact: ContactCard): string | undefined {
|
||||
if (!contact.nicknames) return undefined;
|
||||
return Object.values(contact.nicknames)[0]?.name;
|
||||
}
|
||||
|
||||
export function formatAddress(address: {
|
||||
components?: Array<{ kind: string; value: string }>;
|
||||
full?: string;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { DateFormat, TimeFormat } from '../stores/settings-store';
|
||||
|
||||
/**
|
||||
* Formats a received-at date for the email list. Mirrors the webmail
|
||||
* `formatDate` helper (lib/utils.ts) so both clients render list dates the
|
||||
* same way. The style is controlled by the `dateFormat` user setting:
|
||||
*
|
||||
* - `smart` (default) — locale-aware, age-bucketed:
|
||||
* today → time only ("15:31" or "3:31 PM")
|
||||
* last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
|
||||
* older → full locale date ("28.04.2026", "04/28/2026")
|
||||
* - `relative` — relative format ("1h ago", "2d ago").
|
||||
* - `full` — always the full locale date+time.
|
||||
*
|
||||
* `locale` is the language subtag from the locale store (e.g. "en", "de").
|
||||
*/
|
||||
export function formatListDate(
|
||||
date: Date | string,
|
||||
opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string },
|
||||
): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
|
||||
const { dateFormat, timeFormat } = opts;
|
||||
const localeRaw = opts.locale;
|
||||
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : 'en';
|
||||
// `en` alone resolves to en-US in Intl; everything else uses the language
|
||||
// subtag as-is and lets the runtime pick a sensible default region.
|
||||
const intlLocale = locale === 'en' ? 'en-US' : locale;
|
||||
const hour12 = timeFormat === '12h';
|
||||
|
||||
if (dateFormat === 'relative') {
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (dateFormat === 'full') {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
});
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
});
|
||||
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (isSameDay) return timeStr;
|
||||
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(intlLocale, { weekday: 'short' })
|
||||
.replace(/\.$/, '');
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
// Ported from the webmail `lib/download-filename.ts` so exported `.eml` files and
|
||||
// downloaded attachments get the same templated, cross-platform-safe filenames.
|
||||
|
||||
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
|
||||
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
|
||||
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
|
||||
// to `_`. Keeps filenames usable across Android/iOS/desktop.
|
||||
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
|
||||
|
||||
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
|
||||
|
||||
export interface FilenameTransformOptions {
|
||||
spaceReplacement?: SpaceReplacement;
|
||||
lowercase?: boolean;
|
||||
stripDiacritics?: boolean;
|
||||
collapseSeparators?: boolean;
|
||||
}
|
||||
|
||||
export interface EmailFilenameOptions extends FilenameTransformOptions {
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
|
||||
spaceReplacement: 'keep',
|
||||
lowercase: false,
|
||||
stripDiacritics: false,
|
||||
collapseSeparators: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_EMAIL_TEMPLATE = '{date} ({from}-{to}) {subject}';
|
||||
export const DEFAULT_ATTACHMENT_TEMPLATE = '{filename}';
|
||||
|
||||
export const EMAIL_TOKENS: { token: string; description: string }[] = [
|
||||
{ token: 'date', description: 'Full date and time, e.g. 2026-05-22 14.05.33' },
|
||||
{ token: 'date_short', description: 'Date only, e.g. 2026-05-22' },
|
||||
{ token: 'time', description: 'Time only, e.g. 14.05.33' },
|
||||
{ token: 'year', description: '4-digit year' },
|
||||
{ token: 'month', description: '2-digit month' },
|
||||
{ token: 'day', description: '2-digit day' },
|
||||
{ token: 'from', description: 'Sender display name (falls back to email user part)' },
|
||||
{ token: 'from_email', description: 'Sender full email address' },
|
||||
{ token: 'from_name', description: 'Sender name only' },
|
||||
{ token: 'to', description: 'First recipient display name' },
|
||||
{ token: 'to_email', description: 'First recipient full email address' },
|
||||
{ token: 'to_name', description: 'First recipient name only' },
|
||||
{ token: 'subject', description: 'Email subject' },
|
||||
];
|
||||
|
||||
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
|
||||
...EMAIL_TOKENS,
|
||||
{ token: 'filename', description: 'Original attachment filename including extension' },
|
||||
{ token: 'name', description: 'Attachment filename without extension' },
|
||||
{ token: 'ext', description: 'Attachment file extension without leading dot' },
|
||||
];
|
||||
|
||||
function sanitizePart(input: string, maxLen = 80): string {
|
||||
const cleaned = input
|
||||
.replace(SAFE_CHARS, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^[._-]+|[._-]+$/g, '');
|
||||
return cleaned.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
|
||||
let s = input;
|
||||
if (opts.stripDiacritics) {
|
||||
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
|
||||
// combining marks then leaves plain ASCII letters.
|
||||
s = s.normalize('NFD').replace(/\p{M}+/gu, '');
|
||||
}
|
||||
const repl = opts.spaceReplacement ?? 'keep';
|
||||
if (repl === 'underscore') s = s.replace(/ +/g, '_');
|
||||
else if (repl === 'dash') s = s.replace(/ +/g, '-');
|
||||
if (opts.collapseSeparators ?? true) {
|
||||
s = s.replace(/_+/g, '_').replace(/-+/g, '-').replace(/ +/g, ' ');
|
||||
}
|
||||
if (opts.lowercase) s = s.toLocaleLowerCase();
|
||||
return s.replace(/^[._\- ]+|[._\- ]+$/g, '');
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function dateParts(iso: string | null | undefined) {
|
||||
const d = iso ? new Date(iso) : new Date();
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return {
|
||||
date: '0000-00-00 00.00.00',
|
||||
date_short: '0000-00-00',
|
||||
time: '00.00.00',
|
||||
year: '0000',
|
||||
month: '00',
|
||||
day: '00',
|
||||
};
|
||||
}
|
||||
const year = String(d.getFullYear());
|
||||
const month = pad2(d.getMonth() + 1);
|
||||
const day = pad2(d.getDate());
|
||||
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
|
||||
return {
|
||||
date: `${year}-${month}-${day} ${time}`,
|
||||
date_short: `${year}-${month}-${day}`,
|
||||
time,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
};
|
||||
}
|
||||
|
||||
function addrLabel(addr: { name?: string | null; email?: string } | undefined): {
|
||||
name: string;
|
||||
email: string;
|
||||
label: string;
|
||||
} {
|
||||
if (!addr) return { name: '', email: '', label: 'unknown' };
|
||||
const name = (addr.name && addr.name.trim()) || '';
|
||||
const email = addr.email || '';
|
||||
const label = name || email.split('@')[0] || email || 'unknown';
|
||||
return { name, email, label };
|
||||
}
|
||||
|
||||
export function emailVars(email: Email): Record<string, string> {
|
||||
const dp = dateParts(email.receivedAt || email.sentAt);
|
||||
const from = addrLabel(email.from?.[0]);
|
||||
const to = addrLabel(email.to?.[0]);
|
||||
return {
|
||||
...dp,
|
||||
from: from.label,
|
||||
from_email: from.email,
|
||||
from_name: from.name,
|
||||
to: to.label,
|
||||
to_email: to.email,
|
||||
to_name: to.name,
|
||||
subject: email.subject || 'no subject',
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttachmentLike {
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
|
||||
const filename = (attachment.name || 'attachment').trim();
|
||||
const dot = filename.lastIndexOf('.');
|
||||
const hasExt = dot > 0 && dot < filename.length - 1;
|
||||
const name = hasExt ? filename.slice(0, dot) : filename;
|
||||
const ext = hasExt ? filename.slice(dot + 1) : '';
|
||||
return {
|
||||
...emailVars(email),
|
||||
filename,
|
||||
name,
|
||||
ext,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRaw(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return '';
|
||||
return sanitizePart(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function emailExportFilename(
|
||||
email: Email,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const opts = typeof options === 'string' ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
|
||||
const rendered = renderRaw(template, emailVars(email));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || 'email';
|
||||
return `${stem}.eml`;
|
||||
}
|
||||
|
||||
export function attachmentDownloadFilename(
|
||||
email: Email | null | undefined,
|
||||
attachment: AttachmentLike,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const opts = typeof options === 'string' ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
if (!email) {
|
||||
const filename = (attachment.name || 'attachment').trim();
|
||||
const cleaned = sanitizePart(filename, 200) || 'attachment';
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const vars = attachmentVars(email, attachment);
|
||||
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return '';
|
||||
// Preserve dots in {filename} so the original extension survives the
|
||||
// sanitiser (it strips trailing dots otherwise).
|
||||
return key === 'filename' ? value.replace(SAFE_CHARS, '_') : sanitizePart(value);
|
||||
});
|
||||
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
|
||||
const cleaned = sanitizePart(rendered, 200) || 'attachment';
|
||||
if (templateMentionsExt) {
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
|
||||
const ext = vars.ext;
|
||||
if (!ext) return transformedStem;
|
||||
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
|
||||
return `${transformedStem}.${transformedExt}`;
|
||||
}
|
||||
|
||||
// Build a synthetic email for previewing templates in the settings UI.
|
||||
export function buildSampleEmail(): Email {
|
||||
const iso = '2026-05-22T14:05:33Z';
|
||||
return {
|
||||
id: 'sample-1',
|
||||
threadId: 'sample-thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 12345,
|
||||
receivedAt: iso,
|
||||
sentAt: iso,
|
||||
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
|
||||
to: [{ name: 'Bob Recipient', email: 'bob@example.com' }],
|
||||
cc: [],
|
||||
subject: 'Benachrichtigung von Ihrem Gerät',
|
||||
preview: '',
|
||||
hasAttachment: true,
|
||||
blobId: 'sample-blob',
|
||||
} as Email;
|
||||
}
|
||||
+141
-11
@@ -1,19 +1,145 @@
|
||||
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 type { Email } from '../api/types';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
attachmentDownloadFilename,
|
||||
emailExportFilename,
|
||||
type EmailFilenameOptions,
|
||||
} from './download-filename';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
|
||||
// Read the user's filename template + transform preferences for exports.
|
||||
function emailFileOptions(): EmailFilenameOptions {
|
||||
const s = useSettingsStore.getState();
|
||||
return {
|
||||
template: s.emailExportTemplate,
|
||||
spaceReplacement: s.exportSpaceReplacement,
|
||||
lowercase: s.exportLowercase,
|
||||
stripDiacritics: s.exportStripDiacritics,
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentFileOptions(): EmailFilenameOptions {
|
||||
const s = useSettingsStore.getState();
|
||||
return {
|
||||
template: s.attachmentExportTemplate,
|
||||
spaceReplacement: s.exportSpaceReplacement,
|
||||
lowercase: s.exportLowercase,
|
||||
stripDiacritics: s.exportStripDiacritics,
|
||||
};
|
||||
}
|
||||
|
||||
function authHeader(): string {
|
||||
const h = (jmapClient 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 {
|
||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
if (cleaned) return cleaned.slice(0, 120);
|
||||
return `attachment.${fallbackExt}`;
|
||||
}
|
||||
|
||||
// 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(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
email?: Email | null,
|
||||
// Owning account for blobs shared by another principal (Files app).
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const filename = email
|
||||
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
||||
: safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
||||
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(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,
|
||||
email?: Email | null,
|
||||
// Owning account for blobs shared by another principal (Files app).
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const filename = email
|
||||
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
||||
: safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.document, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -23,13 +149,17 @@ function safeFilename(subject: string | undefined): string {
|
||||
return `${base}.eml`;
|
||||
}
|
||||
|
||||
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||
const dest = new File(Paths.cache, safeFilename(subject));
|
||||
if (dest.exists) dest.delete();
|
||||
export async function shareEmailEml(
|
||||
blobId: string,
|
||||
email?: Email | null,
|
||||
subjectFallback?: string,
|
||||
): Promise<void> {
|
||||
const filename = email
|
||||
? emailExportFilename(email, emailFileOptions())
|
||||
: safeFilename(subjectFallback);
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user