mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -15,7 +15,7 @@ import {
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
@@ -31,11 +31,14 @@ 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';
|
||||
@@ -43,6 +46,7 @@ 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';
|
||||
@@ -51,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,
|
||||
@@ -75,6 +92,10 @@ 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 }}>
|
||||
@@ -136,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
|
||||
@@ -143,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
|
||||
@@ -150,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
|
||||
@@ -227,6 +269,7 @@ export default function App() {
|
||||
// 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();
|
||||
@@ -235,10 +278,21 @@ export default function App() {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
// Slight delay so cold start doesn't compete with the inbox load.
|
||||
const t = setTimeout(() => {
|
||||
void runOfflineSync({ days: offlineCacheDays });
|
||||
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
|
||||
}, [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
|
||||
@@ -261,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 () => {
|
||||
@@ -276,18 +330,24 @@ export default function App() {
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down the active subscription so the
|
||||
// FirebaseMessagingService stops receiving pings for this device.
|
||||
// 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) {
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
@@ -315,7 +375,7 @@ export default function App() {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
@@ -404,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={{
|
||||
|
||||
@@ -65,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.
|
||||
@@ -73,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)
|
||||
}
|
||||
@@ -94,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 {
|
||||
@@ -101,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,
|
||||
|
||||
@@ -23,6 +23,11 @@ object NotificationTapStore {
|
||||
// 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
|
||||
@@ -34,12 +39,14 @@ object NotificationTapStore {
|
||||
// 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 payload = TapPayload(emailId, threadId, subject)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -47,15 +54,18 @@ object NotificationTapStore {
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
|
||||
@@ -821,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",
|
||||
@@ -1424,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
+123
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.41",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.41",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -24,6 +25,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 +4758,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",
|
||||
@@ -5216,6 +5225,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 +5426,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 +6209,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 +6331,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 +6838,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 +6877,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 +8046,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 +8263,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 +8637,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 +9062,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 +9305,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 +9856,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",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.41",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -30,6 +31,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",
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContacts,
|
||||
createAddressBook,
|
||||
updateAddressBook,
|
||||
deleteAddressBook,
|
||||
} from '../contacts';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
@@ -117,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -61,6 +61,57 @@ export async function uploadBlob(
|
||||
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,
|
||||
|
||||
+139
-9
@@ -8,8 +8,11 @@ const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'recurrenceRules',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRules',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'links', 'created', 'updated',
|
||||
];
|
||||
|
||||
@@ -69,38 +72,165 @@ export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
|
||||
return all;
|
||||
}
|
||||
|
||||
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
|
||||
// / reply / cancellation emails to participants. We pass it whenever an event
|
||||
// has participants so creating/updating/deleting a meeting notifies attendees.
|
||||
function setArgs(
|
||||
accountId: string,
|
||||
payload: Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = { accountId, ...payload };
|
||||
if (sendSchedulingMessages !== undefined) {
|
||||
args.sendSchedulingMessages = sendSchedulingMessages;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function createEvent(
|
||||
event: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', {
|
||||
accountId,
|
||||
[['CalendarEvent/set', setArgs(accountId, {
|
||||
create: {
|
||||
'new-event': { ...event, calendarIds: { [calendarId]: true } },
|
||||
},
|
||||
}, '0']],
|
||||
}, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-event'];
|
||||
const result = methodResult<{
|
||||
created?: Record<string, CalendarEvent>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-event'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-event'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create event');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Batch-create many events in one CalendarEvent/set. Returns the number created.
|
||||
export async function batchCreateEvents(
|
||||
events: Partial<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
});
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, create }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ created?: Record<string, unknown> }>(res);
|
||||
return result.created ? Object.keys(result.created).length : 0;
|
||||
}
|
||||
|
||||
export async function updateEvent(
|
||||
id: string,
|
||||
changes: Partial<CalendarEvent>,
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update event');
|
||||
}
|
||||
|
||||
export async function deleteEvents(
|
||||
ids: string[],
|
||||
sendSchedulingMessages?: boolean,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
|
||||
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEvents(ids: string[]): Promise<void> {
|
||||
// RSVP to an invitation: patch the participant's participationStatus via a JSON
|
||||
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
|
||||
export async function rsvpEvent(
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Escape per RFC 6901: ~ → ~0, / → ~1.
|
||||
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patch: Record<string, unknown> = {
|
||||
[`participants/${escaped}/participationStatus`]: status,
|
||||
};
|
||||
if (replyTo) patch.replyTo = replyTo;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[eventId];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
|
||||
}
|
||||
|
||||
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
|
||||
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
|
||||
notParsable?: string[];
|
||||
notFound?: string[];
|
||||
}>(res);
|
||||
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
|
||||
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (!parsed) return [];
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
|
||||
export async function createCalendar(
|
||||
name: string,
|
||||
color?: string,
|
||||
): Promise<Calendar> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', {
|
||||
accountId,
|
||||
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, Calendar>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-cal'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-cal'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create calendar');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
|
||||
// Stalwart otherwise refuses to delete a non-empty calendar.
|
||||
export async function deleteCalendar(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
|
||||
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAddressBook(name: string): Promise<AddressBook> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{
|
||||
created?: Record<string, AddressBook>;
|
||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
||||
}>(res);
|
||||
const created = result.created?.['new-book'];
|
||||
if (created) return { ...created, name };
|
||||
const err = result.notCreated?.['new-book'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create address book');
|
||||
}
|
||||
|
||||
export async function updateAddressBook(
|
||||
id: string,
|
||||
updates: Partial<AddressBook>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
// Only forward server-settable properties.
|
||||
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (name !== undefined) patch.name = name;
|
||||
if (description !== undefined) patch.description = description;
|
||||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||
if (isDefault !== undefined) patch.isDefault = isDefault;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notUpdated?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
|
||||
}
|
||||
|
||||
export async function deleteAddressBook(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
const err = result.notDestroyed?.[id];
|
||||
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
|
||||
}
|
||||
|
||||
/** Fetch all contact cards that live in a specific address book. */
|
||||
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
|
||||
const ids = await queryContacts({ inAddressBook: addressBookId });
|
||||
return getContacts(ids);
|
||||
}
|
||||
|
||||
+432
-20
@@ -22,6 +22,55 @@ 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> {
|
||||
@@ -83,6 +132,22 @@ export async function deleteMailbox(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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?: {
|
||||
@@ -91,20 +156,9 @@ 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 inMailbox = { inMailbox: mailboxId };
|
||||
const userFilter = options?.filter;
|
||||
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
|
||||
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
|
||||
// produces a hybrid object that servers reduce to the FilterCondition,
|
||||
// silently dropping the operator's conditions (e.g. the "unread" toggle).
|
||||
const filter =
|
||||
!userFilter || Object.keys(userFilter).length === 0
|
||||
? inMailbox
|
||||
: 'operator' in userFilter
|
||||
? { operator: 'AND', conditions: [inMailbox, userFilter] }
|
||||
: { ...inMailbox, ...userFilter };
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
@@ -115,9 +169,56 @@ export async function queryEmails(
|
||||
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 }>) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,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,
|
||||
@@ -167,6 +323,35 @@ export async function getFullEmails(ids: string[]): Promise<Email[]> {
|
||||
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(
|
||||
@@ -178,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'],
|
||||
]);
|
||||
@@ -204,6 +390,50 @@ export async function moveEmail(
|
||||
]);
|
||||
}
|
||||
|
||||
// Move several emails from one mailbox to another in a single Email/set.
|
||||
export async function moveEmails(
|
||||
ids: string[],
|
||||
fromMailboxId: string,
|
||||
toMailboxId: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const id of ids) {
|
||||
update[id] = {
|
||||
[`mailboxIds/${fromMailboxId}`]: null,
|
||||
[`mailboxIds/${toMailboxId}`]: true,
|
||||
};
|
||||
}
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
}
|
||||
|
||||
// Batch delete: destroy outright when already in trash, otherwise move to trash.
|
||||
export async function deleteEmails(
|
||||
ids: string[],
|
||||
trashMailboxId: string,
|
||||
currentMailboxId: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
if (currentMailboxId === trashMailboxId) {
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
} else {
|
||||
await moveEmails(ids, currentMailboxId, trashMailboxId);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply keyword maps to several emails in one round-trip.
|
||||
export async function setKeywordsForEmails(
|
||||
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const update: Record<string, { keywords: Record<string, boolean> }> = {};
|
||||
for (const u of updates) update[u.id] = { keywords: u.keywords };
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
}
|
||||
|
||||
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
|
||||
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
|
||||
// the entire map, so we don't need to compute a diff against the current state.
|
||||
@@ -225,6 +455,25 @@ export async function restoreEmailMailboxes(
|
||||
]);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -404,6 +653,15 @@ export interface OutgoingAttachment {
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
|
||||
scheduled: boolean;
|
||||
/** ISO timestamp the server resolved for a deferred send, when known. */
|
||||
sendAt?: string;
|
||||
emailId?: string;
|
||||
emailSubmissionId?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
@@ -419,7 +677,11 @@ export async function sendEmail(
|
||||
},
|
||||
identityId: string,
|
||||
sentMailboxId: string,
|
||||
): Promise<void> {
|
||||
// When > 0 the message is held for this many seconds before delivery via the
|
||||
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
|
||||
// scheduling and the global send-delay (undo-send) window.
|
||||
holdForSeconds?: number,
|
||||
): Promise<SendEmailResult> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const emailCreate: Record<string, unknown> = {
|
||||
from: email.from,
|
||||
@@ -461,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');
|
||||
}
|
||||
}
|
||||
|
||||
+157
-8
@@ -1,19 +1,68 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
const t = node.type;
|
||||
if (!t) return false;
|
||||
return DIRECTORY_TYPES.has(t) || t.includes('directory');
|
||||
}
|
||||
|
||||
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
|
||||
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
|
||||
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
|
||||
// e.g. a file inside the `Documents` folder is stored with
|
||||
// `name = "Documents∕report.pdf"`. The display name shown to the user
|
||||
// strips the prefix.
|
||||
export const PATH_SEP = '∕';
|
||||
|
||||
// Path is a list of folder display names from root. [] is root,
|
||||
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
|
||||
// `sub` folder under Documents.
|
||||
export function pathToPrefix(path: readonly string[]): string {
|
||||
if (path.length === 0) return '';
|
||||
return path.join(PATH_SEP) + PATH_SEP;
|
||||
}
|
||||
|
||||
// True when `node` is a direct child (not a descendant) of `prefix`.
|
||||
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
|
||||
if (!node.name) return false;
|
||||
if (prefix === '') return !node.name.includes(PATH_SEP);
|
||||
if (!node.name.startsWith(prefix)) return false;
|
||||
const rest = node.name.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes(PATH_SEP);
|
||||
}
|
||||
|
||||
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
|
||||
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter and some builds
|
||||
// omit `type` from the default property set. We fetch the whole tree and
|
||||
// filter client-side; properties are listed explicitly so `isDirectory()`
|
||||
// always has a value to look at.
|
||||
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
|
||||
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (parentId !== undefined) filter.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, filter }, '0']],
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
@@ -27,13 +76,113 @@ export async function createFileNode(
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
displayName: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
// Stalwart's FileNode/set rejects directory creates without a blobId
|
||||
// ("Missing blob id"), so we upload an empty blob first and reference it.
|
||||
// Mirrors the webmail's createFileDirectory.
|
||||
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
// Rename a node and, if it's a folder, cascade the rename to every descendant
|
||||
// so the encoded path stays consistent. The webmail does this same dance.
|
||||
export async function renameFileNode(
|
||||
node: FileNode,
|
||||
newDisplayName: string,
|
||||
currentPath: readonly string[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const prefix = pathToPrefix(currentPath);
|
||||
const oldServerName = node.name;
|
||||
const newServerName = prefix + newDisplayName;
|
||||
if (oldServerName === newServerName) return;
|
||||
|
||||
await updateFileNode(node.id, { name: newServerName });
|
||||
|
||||
if (isDirectory(node)) {
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id) continue;
|
||||
if (n.name.startsWith(oldFolderPrefix)) {
|
||||
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
|
||||
// references, so we collect descendants by name-prefix and destroy them all
|
||||
// in one call. Without this, deleting a folder leaves its files orphaned and
|
||||
// still discoverable at the old encoded path.
|
||||
export async function deleteWithDescendants(
|
||||
targets: readonly FileNode[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targets) {
|
||||
ids.add(node.id);
|
||||
if (isDirectory(node)) {
|
||||
const folderPrefix = node.name + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteFileNodes(Array.from(ids));
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
displayName: string,
|
||||
mimeType: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({
|
||||
name: fullName,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
}
|
||||
|
||||
+212
-17
@@ -8,6 +8,11 @@ import type {
|
||||
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__';
|
||||
@@ -22,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 {
|
||||
@@ -48,6 +60,13 @@ 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;
|
||||
}
|
||||
@@ -93,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> {
|
||||
@@ -235,12 +361,18 @@ export class JMAPClient {
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
const url = `${baseUrl}/.well-known/jmap`;
|
||||
const response = await secureFetch(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');
|
||||
@@ -280,14 +412,21 @@ export class JMAPClient {
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const response = await secureFetch(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');
|
||||
@@ -319,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.
|
||||
@@ -335,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 secureFetch(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();
|
||||
|
||||
@@ -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'] };
|
||||
}
|
||||
+77
-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];
|
||||
@@ -284,6 +291,13 @@ export interface ContactCryptoKey {
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactDirectory {
|
||||
uri: string;
|
||||
kind?: 'directory' | 'entry' | string;
|
||||
mediaType?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface ContactPronouns {
|
||||
pronouns: string;
|
||||
pref?: number;
|
||||
@@ -318,12 +332,15 @@ export interface ContactCard {
|
||||
relatedTo?: Record<string, ContactRelation>;
|
||||
links?: Record<string, ContactLink>;
|
||||
cryptoKeys?: Record<string, ContactCryptoKey>;
|
||||
directories?: Record<string, ContactDirectory>;
|
||||
keywords?: Record<string, boolean>;
|
||||
members?: Record<string, boolean>;
|
||||
speakToAs?: ContactSpeakToAs;
|
||||
calendarUri?: string;
|
||||
schedulingUri?: string;
|
||||
freeBusyUri?: string;
|
||||
source?: string;
|
||||
prodId?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
@@ -352,13 +369,39 @@ export interface AddressBookWithCount extends AddressBook {
|
||||
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
|
||||
|
||||
export interface Participant {
|
||||
'@type'?: 'Participant';
|
||||
name?: string;
|
||||
email?: string;
|
||||
sendTo?: Record<string, string>;
|
||||
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
|
||||
calendarAddress?: string;
|
||||
kind?: string;
|
||||
roles?: Record<string, boolean>;
|
||||
participationStatus?: string;
|
||||
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
|
||||
participationComment?: string;
|
||||
scheduleStatus?: string[];
|
||||
expectReply?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
|
||||
export interface EventLocation {
|
||||
'@type'?: 'Location';
|
||||
name?: string;
|
||||
description?: string;
|
||||
locationTypes?: Record<string, boolean>;
|
||||
relativeTo?: string;
|
||||
timeZone?: string;
|
||||
coordinates?: string;
|
||||
}
|
||||
|
||||
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
|
||||
export interface VirtualLocation {
|
||||
'@type'?: 'VirtualLocation';
|
||||
name?: string;
|
||||
description?: string;
|
||||
uri: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RecurrenceRule {
|
||||
@@ -399,27 +442,56 @@ export interface CalendarEvent {
|
||||
status?: string;
|
||||
freeBusyStatus?: string;
|
||||
participants?: Record<string, Participant>;
|
||||
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
|
||||
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
|
||||
replyTo?: Record<string, string>;
|
||||
organizerCalendarAddress?: string;
|
||||
sequence?: number;
|
||||
recurrenceRules?: RecurrenceRule[];
|
||||
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
|
||||
excludedRecurrenceRules?: RecurrenceRule[];
|
||||
recurrenceId?: string;
|
||||
originalId?: string;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, Alert>;
|
||||
locations?: Record<string, EventLocation>;
|
||||
virtualLocations?: Record<string, VirtualLocation>;
|
||||
links?: Record<string, { href: string; rel?: string }>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
progress?: string;
|
||||
// Task-only (RFC 8984 JSTask) fields.
|
||||
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
|
||||
due?: string;
|
||||
priority?: number;
|
||||
percentComplete?: number;
|
||||
color?: string;
|
||||
// Client-only: account the event/occurrence belongs to (for shared/virtual).
|
||||
localAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
mayReadFreeBusy?: boolean;
|
||||
mayReadItems?: boolean;
|
||||
mayWriteAll?: boolean;
|
||||
mayWriteOwn?: boolean;
|
||||
mayUpdatePrivate?: boolean;
|
||||
mayRSVP?: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete?: boolean;
|
||||
// Legacy short flag still read by the sidebar drawer.
|
||||
mayWrite?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string;
|
||||
isVisible?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: Record<string, boolean>;
|
||||
myRights?: CalendarRights;
|
||||
}
|
||||
|
||||
// ─── Files (FileNode) ───────────────────────────────────
|
||||
@@ -429,7 +501,7 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
@@ -465,4 +537,5 @@ export const CAPABILITIES = {
|
||||
CALENDARS: 'urn:ietf:params:jmap:calendars',
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -140,7 +140,18 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
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(() => {
|
||||
@@ -170,6 +181,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
|
||||
const trusted = senderEmail
|
||||
? isSenderTrusted(senderEmail)
|
||||
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|
||||
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
|
||||
: false;
|
||||
|
||||
@@ -270,7 +282,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect, and persist to the
|
||||
// dedicated "Trusted Senders" address book so the trust syncs across
|
||||
// devices (matches the webmail behavior).
|
||||
addTrustedSender(senderEmail);
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
setAllowOnce(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
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';
|
||||
@@ -12,9 +14,18 @@ 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
|
||||
@@ -71,7 +82,7 @@ export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const isError = sync.phase === 'error';
|
||||
|
||||
return (
|
||||
<View style={[styles.banner, isError && styles.bannerError]}>
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -427,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 ? (
|
||||
@@ -667,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,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Tag } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, colors as tokenColors, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
interface TagSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
keywords: KeywordDef[];
|
||||
/** The currently-selected emails, used to show which tags are already set. */
|
||||
selectedEmails: Email[];
|
||||
/** Toggle a keyword token on/off across the whole selection. */
|
||||
onToggle: (token: string, on: boolean) => void;
|
||||
}
|
||||
|
||||
export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle }: TagSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
// A tag counts as "applied" only when every selected email carries it; in
|
||||
// that case tapping removes it, otherwise tapping adds it to all.
|
||||
const allHaveToken = React.useCallback(
|
||||
(token: string) =>
|
||||
selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.[token]),
|
||||
[selectedEmails],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Tag {selectedEmails.length} selected</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{keywords.length === 0 ? (
|
||||
<Text style={styles.empty}>No tags defined. Add tags in Settings.</Text>
|
||||
) : (
|
||||
keywords.map((kw) => {
|
||||
const token = keywordToken(kw.id);
|
||||
const applied = allHaveToken(token);
|
||||
const dot = tokenColors.tags[kw.color]?.dot ?? c.primary;
|
||||
return (
|
||||
<Pressable
|
||||
key={kw.id}
|
||||
onPress={() => onToggle(token, !applied)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Tag size={16} color={dot} fill={dot} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
|
||||
{applied && <Check size={16} color={c.primary} />}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0, right: 0, bottom: 0,
|
||||
maxHeight: '75%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
empty: { ...typography.body, color: c.textMuted, padding: spacing.lg, textAlign: 'center' },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Check, X } from 'lucide-react-native';
|
||||
import { Check, X, Upload, Rss } from 'lucide-react-native';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -24,6 +24,8 @@ interface CalendarSidebarDrawerProps {
|
||||
hiddenCalendarIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onClose: () => void;
|
||||
onImport?: () => void;
|
||||
onManageSubscriptions?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
@@ -32,6 +34,8 @@ export function CalendarSidebarDrawer({
|
||||
hiddenCalendarIds,
|
||||
onToggle,
|
||||
onClose,
|
||||
onImport,
|
||||
onManageSubscriptions,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -122,6 +126,29 @@ export function CalendarSidebarDrawer({
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(onImport || onManageSubscriptions) && (
|
||||
<View style={styles.actionsSection}>
|
||||
{onManageSubscriptions && (
|
||||
<Pressable
|
||||
onPress={onManageSubscriptions}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Rss size={18} color={c.textSecondary} />
|
||||
<Text style={styles.actionText}>Subscriptions</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{onImport && (
|
||||
<Pressable
|
||||
onPress={onImport}
|
||||
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Upload size={18} color={c.textSecondary} />
|
||||
<Text style={styles.actionText}>Import from file</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
@@ -251,5 +278,21 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
rowName: { flex: 1, ...typography.body, color: c.text },
|
||||
rowNameMuted: { color: c.textMuted },
|
||||
|
||||
actionsSection: {
|
||||
marginTop: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 44,
|
||||
},
|
||||
actionText: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,7 +79,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';
|
||||
@@ -33,8 +37,16 @@ import {
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
findParticipantByEmail,
|
||||
isOrganizerParticipant,
|
||||
} from '../../lib/calendar-invitation';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
@@ -42,6 +54,7 @@ interface EventDetailSheetProps {
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
onDuplicate?: (event: CalendarEvent) => void;
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatRange(event: CalendarEvent): string {
|
||||
@@ -84,22 +97,6 @@ function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
return label;
|
||||
}
|
||||
|
||||
function alertLabel(event: CalendarEvent): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first) return null;
|
||||
const offset = first.trigger?.offset;
|
||||
if (!offset) return null;
|
||||
if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event';
|
||||
const min = offset.match(/-?PT(\d+)M$/);
|
||||
if (min) return `${min[1]} minutes before`;
|
||||
const hr = offset.match(/-?PT(\d+)H$/);
|
||||
if (hr) return `${hr[1]} hours before`;
|
||||
const day = offset.match(/-?P(\d+)D/);
|
||||
if (day) return `${day[1]} days before`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
@@ -107,8 +104,11 @@ export function EventDetailSheet({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onRsvp,
|
||||
}: EventDetailSheetProps) {
|
||||
const c = useColors();
|
||||
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
|
||||
const [rsvpBusy, setRsvpBusy] = React.useState(false);
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -158,8 +158,26 @@ export function EventDetailSheet({
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const recurrence = recurrenceLabel(event);
|
||||
const alert = alertLabel(event);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = event.participants ? Object.values(event.participants) : [];
|
||||
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
|
||||
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
|
||||
|
||||
// Can the signed-in user RSVP? Only when they appear as a non-organizer
|
||||
// participant and the caller wired an onRsvp handler.
|
||||
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
|
||||
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
|
||||
const myStatus = me?.participant.participationStatus;
|
||||
|
||||
const doRsvp = async (status: RsvpStatus) => {
|
||||
if (!onRsvp || !me || rsvpBusy) return;
|
||||
setRsvpBusy(true);
|
||||
try {
|
||||
await onRsvp(event, me.id, status);
|
||||
} finally {
|
||||
setRsvpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -198,6 +216,22 @@ export function EventDetailSheet({
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<DetailRow icon={<Clock size={16} color={c.textMuted} />} text={range} />
|
||||
{location ? (
|
||||
<DetailRow icon={<MapPin size={16} color={c.textMuted} />} text={location} />
|
||||
) : null}
|
||||
{videoUri ? (
|
||||
<View style={styles.detailRow}>
|
||||
<View style={styles.detailIcon}>
|
||||
<Video size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.joinBtn}
|
||||
onPress={() => { void Linking.openURL(videoUri); }}
|
||||
>
|
||||
<Text style={styles.joinBtnText}>Join video call</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{event.description ? (
|
||||
<DetailRow
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
@@ -211,10 +245,10 @@ export function EventDetailSheet({
|
||||
text={recurrence}
|
||||
/>
|
||||
)}
|
||||
{alert && (
|
||||
{reminders.length > 0 && (
|
||||
<DetailRow
|
||||
icon={<Bell size={16} color={c.textMuted} />}
|
||||
text={alert}
|
||||
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
|
||||
/>
|
||||
)}
|
||||
{calendar && (
|
||||
@@ -224,6 +258,38 @@ export function EventDetailSheet({
|
||||
/>
|
||||
)}
|
||||
|
||||
{canRsvp && (
|
||||
<View style={styles.rsvpBlock}>
|
||||
<Text style={styles.rsvpPrompt}>Going?</Text>
|
||||
<View style={styles.rsvpButtons}>
|
||||
<RsvpButton
|
||||
label="Yes"
|
||||
icon={<Check size={16} color={myStatus === 'accepted' ? c.textInverse : c.success} />}
|
||||
active={myStatus === 'accepted'}
|
||||
activeColor={c.success}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('accepted')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="Maybe"
|
||||
icon={<HelpCircle size={16} color={myStatus === 'tentative' ? c.textInverse : c.warning} />}
|
||||
active={myStatus === 'tentative'}
|
||||
activeColor={c.warning}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('tentative')}
|
||||
/>
|
||||
<RsvpButton
|
||||
label="No"
|
||||
icon={<X size={16} color={myStatus === 'declined' ? c.textInverse : c.error} />}
|
||||
active={myStatus === 'declined'}
|
||||
activeColor={c.error}
|
||||
disabled={rsvpBusy}
|
||||
onPress={() => doRsvp('declined')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{participants.length > 0 && (
|
||||
<View style={styles.participantsBlock}>
|
||||
<View style={styles.participantsHeader}>
|
||||
@@ -334,6 +400,39 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function RsvpButton({
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
activeColor,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
activeColor: string;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.rsvpBtn,
|
||||
active && { backgroundColor: activeColor, borderColor: activeColor },
|
||||
disabled && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{icon}
|
||||
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(c: ThemePalette, status?: string): string {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
@@ -417,6 +516,33 @@ function makeStyles(c: ThemePalette) {
|
||||
detailIcon: { width: 16, paddingTop: 2 },
|
||||
detailText: { flex: 1, ...typography.body, color: c.text },
|
||||
|
||||
joinBtn: {
|
||||
flex: 1,
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
joinBtnText: { ...typography.bodyMedium, color: c.primary },
|
||||
|
||||
rsvpBlock: { gap: spacing.sm, marginTop: spacing.xs },
|
||||
rsvpPrompt: { ...typography.bodyMedium, color: c.text },
|
||||
rsvpButtons: { flexDirection: 'row', gap: spacing.sm },
|
||||
rsvpBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
rsvpBtnText: { ...typography.caption, color: c.text },
|
||||
|
||||
participantsBlock: {
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
|
||||
@@ -17,13 +17,22 @@ import {
|
||||
Calendar as CalendarIcon,
|
||||
Clock,
|
||||
MapPin,
|
||||
Video,
|
||||
Repeat,
|
||||
Bell,
|
||||
Plus,
|
||||
Users,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
} from 'lucide-react-native';
|
||||
import { addHours, addMinutes, format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent, Participant } from '../../api/types';
|
||||
import type {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
Participant,
|
||||
EventLocation,
|
||||
VirtualLocation,
|
||||
} from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
@@ -33,11 +42,17 @@ import {
|
||||
getEventStartDate,
|
||||
getPrimaryCalendarId,
|
||||
} from '../../lib/calendar-utils';
|
||||
import {
|
||||
alertsToReminders,
|
||||
remindersToAlerts,
|
||||
formatReminder,
|
||||
REMINDER_PRESETS,
|
||||
type Reminder,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
|
||||
|
||||
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'none', label: 'Does not repeat' },
|
||||
@@ -47,16 +62,6 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'yearly', label: 'Yearly' },
|
||||
];
|
||||
|
||||
const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [
|
||||
{ value: 'none', label: 'No reminder' },
|
||||
{ value: 'at_time', label: 'At time of event' },
|
||||
{ value: '5', label: '5 minutes before' },
|
||||
{ value: '15', label: '15 minutes before' },
|
||||
{ value: '30', label: '30 minutes before' },
|
||||
{ value: '60', label: '1 hour before' },
|
||||
{ value: '1440', label: '1 day before' },
|
||||
];
|
||||
|
||||
interface EventModalProps {
|
||||
visible: boolean;
|
||||
event?: CalendarEvent | null;
|
||||
@@ -101,39 +106,30 @@ function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOp
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function detectAlert(event: CalendarEvent | null | undefined): AlertOption {
|
||||
const alerts = event?.alerts;
|
||||
if (!alerts) return 'none';
|
||||
const first = Object.values(alerts)[0];
|
||||
if (!first) return 'none';
|
||||
const offset = first.trigger?.offset;
|
||||
if (!offset) return 'none';
|
||||
if (offset === 'PT0S' || offset === '-PT0S') return 'at_time';
|
||||
const min = offset.match(/-?PT(\d+)M$/);
|
||||
if (min) {
|
||||
const v = min[1];
|
||||
if (v === '5' || v === '15' || v === '30') return v as AlertOption;
|
||||
}
|
||||
const hr = offset.match(/-?PT(\d+)H$/);
|
||||
if (hr && hr[1] === '1') return '60';
|
||||
const day = offset.match(/-?P(\d+)D/);
|
||||
if (day && day[1] === '1') return '1440';
|
||||
return 'none';
|
||||
function detectLocation(event: CalendarEvent | null | undefined): string {
|
||||
const locations = event?.locations;
|
||||
if (!locations) return '';
|
||||
const first = Object.values(locations)[0];
|
||||
return first?.name || '';
|
||||
}
|
||||
|
||||
function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined {
|
||||
if (opt === 'none') return undefined;
|
||||
let offset = '-PT0M';
|
||||
if (opt === 'at_time') offset = 'PT0S';
|
||||
else if (opt === '60') offset = '-PT1H';
|
||||
else if (opt === '1440') offset = '-P1D';
|
||||
else offset = `-PT${opt}M`;
|
||||
return {
|
||||
'a-1': {
|
||||
trigger: { '@type': 'OffsetTrigger', offset },
|
||||
action: 'display',
|
||||
},
|
||||
};
|
||||
function detectVideoUrl(event: CalendarEvent | null | undefined): string {
|
||||
const v = event?.virtualLocations;
|
||||
if (!v) return '';
|
||||
const first = Object.values(v)[0];
|
||||
return first?.uri || '';
|
||||
}
|
||||
|
||||
function buildLocations(name: string): Record<string, EventLocation> | undefined {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return { 'loc-1': { '@type': 'Location', name: trimmed } };
|
||||
}
|
||||
|
||||
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | undefined {
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } };
|
||||
}
|
||||
|
||||
function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined {
|
||||
@@ -163,9 +159,11 @@ export function EventModal({
|
||||
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
|
||||
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
|
||||
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
||||
const [alert, setAlert] = React.useState<AlertOption>('none');
|
||||
const [reminders, setReminders] = React.useState<Reminder[]>([]);
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [videoUrl, setVideoUrl] = React.useState('');
|
||||
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
|
||||
const [alertOpen, setAlertOpen] = React.useState(false);
|
||||
const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false);
|
||||
const [calendarOpen, setCalendarOpen] = React.useState(false);
|
||||
const [showStartDate, setShowStartDate] = React.useState(false);
|
||||
const [showStartTime, setShowStartTime] = React.useState(false);
|
||||
@@ -184,7 +182,9 @@ export function EventModal({
|
||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||
setParticipants(event.participants || {});
|
||||
setRecurrence(detectRecurrence(event));
|
||||
setAlert(detectAlert(event));
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setLocation(detectLocation(event));
|
||||
setVideoUrl(detectVideoUrl(event));
|
||||
} else {
|
||||
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
|
||||
setTitle('');
|
||||
@@ -195,7 +195,9 @@ export function EventModal({
|
||||
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
|
||||
setParticipants({});
|
||||
setRecurrence('none');
|
||||
setAlert('none');
|
||||
setReminders([]);
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
}
|
||||
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
|
||||
|
||||
@@ -240,7 +242,10 @@ export function EventModal({
|
||||
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
|
||||
participants: Object.keys(participants).length > 0 ? participants : undefined,
|
||||
recurrenceRules: recurrenceFromOption(recurrence),
|
||||
alerts: alertFromOption(alert),
|
||||
alerts: remindersToAlerts(reminders),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: buildLocations(location),
|
||||
virtualLocations: buildVirtualLocations(videoUrl),
|
||||
};
|
||||
await onSave(data, calendarId);
|
||||
onClose();
|
||||
@@ -255,7 +260,21 @@ export function EventModal({
|
||||
|
||||
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
||||
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
|
||||
const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.label || 'None';
|
||||
|
||||
const addReminder = (minutesBefore: number) => {
|
||||
setReminderPickerOpen(false);
|
||||
setReminders((prev) =>
|
||||
prev.some((r) => r.minutesBefore === minutesBefore)
|
||||
? prev
|
||||
: [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore),
|
||||
);
|
||||
};
|
||||
const removeReminder = (minutesBefore: number) => {
|
||||
setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore));
|
||||
};
|
||||
const availablePresets = REMINDER_PRESETS.filter(
|
||||
(p) => !reminders.some((r) => r.minutesBefore === p),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -423,42 +442,80 @@ export function EventModal({
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminder">
|
||||
<Pressable
|
||||
style={styles.fieldButton}
|
||||
onPress={() => setAlertOpen((v) => !v)}
|
||||
>
|
||||
<Text style={styles.fieldText}>{alertLabel}</Text>
|
||||
</Pressable>
|
||||
{alertOpen && (
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
|
||||
{reminders.length > 0 && (
|
||||
<View style={{ gap: spacing.xs }}>
|
||||
{reminders.map((r) => (
|
||||
<View key={r.minutesBefore} style={styles.reminderRow}>
|
||||
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
|
||||
<Pressable
|
||||
onPress={() => removeReminder(r.minutesBefore)}
|
||||
hitSlop={8}
|
||||
style={styles.reminderRemove}
|
||||
>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{availablePresets.length > 0 && (
|
||||
<Pressable
|
||||
style={styles.addRow}
|
||||
onPress={() => setReminderPickerOpen((v) => !v)}
|
||||
>
|
||||
<Plus size={16} color={c.primary} />
|
||||
<Text style={styles.addRowText}>
|
||||
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{reminderPickerOpen && (
|
||||
<View style={styles.popover}>
|
||||
{ALERT_OPTIONS.map((opt) => (
|
||||
{availablePresets.map((preset) => (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
style={[
|
||||
styles.popoverRow,
|
||||
opt.value === alert && styles.popoverRowActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setAlert(opt.value);
|
||||
setAlertOpen(false);
|
||||
}}
|
||||
key={preset}
|
||||
style={styles.popoverRow}
|
||||
onPress={() => addReminder(preset)}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="Place or address"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
|
||||
<TextInput
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
icon={<MapPin size={16} color={c.textMuted} />}
|
||||
icon={<AlignLeft size={16} color={c.textMuted} />}
|
||||
label="Description"
|
||||
>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Notes, location, link…"
|
||||
placeholder="Notes, agenda…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
@@ -629,6 +686,26 @@ function makeStyles(c: ThemePalette) {
|
||||
calendarRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
calendarSwatch: { width: 12, height: 12, borderRadius: 6 },
|
||||
|
||||
reminderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
addRowText: { ...typography.body, color: c.primary },
|
||||
|
||||
popover: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { uploadBlob } from '../../api/blob';
|
||||
import { parseCalendarBlob } from '../../api/calendar';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
|
||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||
type DocumentPickerModule = typeof import('expo-document-picker');
|
||||
let documentPickerModule: DocumentPickerModule | null = null;
|
||||
function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
if (documentPickerModule) return documentPickerModule;
|
||||
try {
|
||||
documentPickerModule = require('expo-document-picker') as DocumentPickerModule;
|
||||
return documentPickerModule;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
calendars: Calendar[];
|
||||
/** Imports the selected events into the chosen calendar; returns count. */
|
||||
onImport: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
onImported?: (count: number) => void;
|
||||
}
|
||||
|
||||
function eventDateLabel(event: Partial<CalendarEvent>): string | null {
|
||||
if (!event.start) return null;
|
||||
const d = parseISO(event.start);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return event.showWithoutTime ? format(d, 'EEE, MMM d, yyyy') : format(d, 'EEE, MMM d · HH:mm');
|
||||
}
|
||||
|
||||
export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const writable = React.useMemo(
|
||||
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
const [parsed, setParsed] = React.useState<Partial<CalendarEvent>[]>([]);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(new Set());
|
||||
const [calendarId, setCalendarId] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<number | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const resetState = React.useCallback(() => {
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
resetState();
|
||||
if (writable[0]) setCalendarId(writable[0].id);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, slideY, overlayOpacity, resetState]);
|
||||
|
||||
const pickFile = async () => {
|
||||
const picker = loadDocumentPicker();
|
||||
if (!picker) {
|
||||
Alert.alert(
|
||||
'Import unavailable',
|
||||
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable importing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
let res;
|
||||
try {
|
||||
res = await picker.getDocumentAsync({ copyToCacheDirectory: true, multiple: false });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not open the file picker');
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.assets.length === 0) return;
|
||||
const asset = res.assets[0];
|
||||
if (asset.size && asset.size > 5 * 1024 * 1024) {
|
||||
setError('That file is too large (max 5 MB).');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Upload the .ics so the server can parse it (CalendarEvent/parse).
|
||||
const { blobId } = await uploadBlob(asset.uri, 'text/calendar');
|
||||
const events = await parseCalendarBlob(blobId);
|
||||
const usable = events.filter((e) => !!e.start || e['@type'] === 'Task');
|
||||
if (usable.length === 0) {
|
||||
setError('No events were found in that file.');
|
||||
return;
|
||||
}
|
||||
setParsed(usable);
|
||||
setSelected(new Set(usable.map((_, i) => i)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not read or parse that file.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const selectAll = () => setSelected(new Set(parsed.map((_, i) => i)));
|
||||
const selectNone = () => setSelected(new Set());
|
||||
|
||||
const doImport = async () => {
|
||||
if (!calendarId) {
|
||||
Alert.alert('No calendar', 'Create a calendar before importing events.');
|
||||
return;
|
||||
}
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const count = await onImport(toImport, calendarId);
|
||||
setResult(count);
|
||||
onImported?.(count);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Import calendar</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{result !== null ? (
|
||||
<View style={styles.center}>
|
||||
<View style={styles.successBadge}>
|
||||
<Check size={26} color={c.primaryForeground} />
|
||||
</View>
|
||||
<Text style={styles.successText}>
|
||||
Imported {result} event{result === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<Button variant="outline" size="sm" onPress={onClose}>Done</Button>
|
||||
</View>
|
||||
) : parsed.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Pressable style={styles.dropZone} onPress={() => { void pickFile(); }} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={c.primary} />
|
||||
) : (
|
||||
<Upload size={32} color={c.textMuted} />
|
||||
)}
|
||||
<Text style={styles.dropTitle}>{loading ? 'Parsing…' : 'Choose an .ics file'}</Text>
|
||||
<Text style={styles.dropHint}>iCalendar — single or multiple events</Text>
|
||||
</Pressable>
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{writable.length > 1 && (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
|
||||
{writable.map((cal) => (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => setCalendarId(cal.id)}
|
||||
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
|
||||
>
|
||||
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
|
||||
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.toolbarCount}>
|
||||
{parsed.length} found · {selected.size} selected
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.md }}>
|
||||
<Pressable onPress={selectAll} hitSlop={6}><Text style={styles.link}>All</Text></Pressable>
|
||||
<Pressable onPress={selectNone} hitSlop={6}><Text style={styles.link}>None</Text></Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{parsed.map((event, idx) => {
|
||||
const isSelected = selected.has(idx);
|
||||
const date = eventDateLabel(event);
|
||||
return (
|
||||
<Pressable
|
||||
key={idx}
|
||||
onPress={() => toggle(idx)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
|
||||
{isSelected && <Check size={13} color={c.primaryForeground} />}
|
||||
</View>
|
||||
<Clock size={16} color={c.textMuted} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{event.title || 'Untitled event'}</Text>
|
||||
{!!date && <Text style={styles.rowDate} numberOfLines={1}>{date}</Text>}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{!!error && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.md) }]}>
|
||||
<Button variant="outline" onPress={onClose} disabled={importing}>Cancel</Button>
|
||||
<Button
|
||||
onPress={() => { void doImport(); }}
|
||||
disabled={importing || selected.size === 0}
|
||||
icon={importing ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <CalendarPlus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{importing ? 'Importing…' : `Import ${selected.size}`}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '85%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg, padding: spacing.xl },
|
||||
dropZone: {
|
||||
width: '100%',
|
||||
borderWidth: 2,
|
||||
borderColor: c.border,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: radius.lg,
|
||||
paddingVertical: spacing.xxxl,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
dropTitle: { ...typography.bodyMedium, color: c.text },
|
||||
dropHint: { ...typography.caption, color: c.textMuted },
|
||||
successBadge: {
|
||||
width: 56, height: 56, borderRadius: radius.full,
|
||||
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
successText: { ...typography.bodyMedium, color: c.text },
|
||||
calChips: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.sm },
|
||||
calChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||
calSwatch: { width: 10, height: 10, borderRadius: 5 },
|
||||
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
toolbarCount: { ...typography.caption, color: c.textMuted },
|
||||
link: { ...typography.captionMedium, color: c.primary },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 48,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
checkbox: {
|
||||
width: 20, height: 20, borderRadius: radius.xs,
|
||||
borderWidth: 1, borderColor: c.border,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
checkboxOn: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowDate: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
|
||||
import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const syncing = useCalendarSubscriptionsStore((s) => s.syncing);
|
||||
const addSubscription = useCalendarSubscriptionsStore((s) => s.addSubscription);
|
||||
const removeSubscription = useCalendarSubscriptionsStore((s) => s.removeSubscription);
|
||||
const syncSubscription = useCalendarSubscriptionsStore((s) => s.syncSubscription);
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [color, setColor] = React.useState<string>(CALENDAR_COLOR_PALETTE[0]);
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setName('');
|
||||
setUrl('');
|
||||
setColor(CALENDAR_COLOR_PALETTE[0]);
|
||||
setFormError(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 900, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (!n || !u) {
|
||||
setFormError('Enter a name and a feed URL.');
|
||||
return;
|
||||
}
|
||||
if (!/^(https?|webcal):\/\//i.test(u)) {
|
||||
setFormError('URL must start with https://, http:// or webcal://');
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
const sub = await addSubscription({ name: n, url: u, color });
|
||||
if (sub.lastError) {
|
||||
setFormError(sub.lastError);
|
||||
} else {
|
||||
setName('');
|
||||
setUrl('');
|
||||
}
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Rss size={20} color={c.text} />
|
||||
<Text style={styles.title}>Calendar subscriptions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
|
||||
<Text style={styles.sectionLabel}>Add a feed</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Name (e.g. Holidays)"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
/>
|
||||
<TextInput
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://… or webcal://…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
style={styles.input}
|
||||
/>
|
||||
<View style={styles.colorRow}>
|
||||
{CALENDAR_COLOR_PALETTE.map((col) => (
|
||||
<Pressable
|
||||
key={col}
|
||||
onPress={() => setColor(col)}
|
||||
style={[
|
||||
styles.colorDot,
|
||||
{ backgroundColor: col },
|
||||
color === col && styles.colorDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{!!formError && (
|
||||
<View style={styles.errorBox}>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<Text style={styles.errorText}>{formError}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => { void handleAdd(); }}
|
||||
disabled={adding}
|
||||
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{adding ? 'Subscribing…' : 'Subscribe'}
|
||||
</Button>
|
||||
|
||||
{subscriptions.length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
|
||||
{subscriptions.map((sub) => {
|
||||
const busy = !!syncing[sub.id];
|
||||
return (
|
||||
<View key={sub.id} style={styles.subRow}>
|
||||
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
|
||||
<Text style={styles.subMeta} numberOfLines={1}>
|
||||
{sub.lastError
|
||||
? sub.lastError
|
||||
: sub.lastSyncAt
|
||||
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
|
||||
: 'Not synced yet'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => { void syncSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<RefreshCw size={16} color={c.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { void removeSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '88%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { flex: 1, ...typography.h3, color: c.text },
|
||||
close: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
scroll: { padding: spacing.lg, gap: spacing.sm },
|
||||
sectionLabel: { ...typography.bodySemibold, color: c.text, marginBottom: spacing.xs },
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginBottom: spacing.md },
|
||||
colorDot: { width: 26, height: 26, borderRadius: 13, borderWidth: 2, borderColor: 'transparent' },
|
||||
colorDotActive: { borderColor: c.text },
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.errorBorder,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
errorText: { ...typography.caption, color: c.errorForeground, flex: 1 },
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
subSwatch: { width: 14, height: 14, borderRadius: 7 },
|
||||
subName: { ...typography.body, color: c.text },
|
||||
subMeta: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
subBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,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 },
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
||||
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';
|
||||
@@ -22,6 +23,14 @@ const DAY_OPTIONS = [
|
||||
{ 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;
|
||||
@@ -58,7 +67,10 @@ export function AboutDataSettings() {
|
||||
|
||||
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);
|
||||
@@ -73,7 +85,9 @@ export function AboutDataSettings() {
|
||||
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
|
||||
|
||||
const handleSyncNow = () => {
|
||||
void runOfflineSync({ days: offlineDays });
|
||||
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
|
||||
// Also drain any queued offline changes while we're at it.
|
||||
void flushOutbox();
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
@@ -142,6 +156,16 @@ export function AboutDataSettings() {
|
||||
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}>
|
||||
@@ -161,6 +185,11 @@ export function AboutDataSettings() {
|
||||
? `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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import 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';
|
||||
@@ -13,45 +17,44 @@ import {
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
// ── 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 [currentPwd, setCurrentPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
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 certSupported = isClientCertSupported();
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certSupported) return;
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, [certSupported]);
|
||||
}, []);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
// Hint the picker with the JMAP server host so it can pre-filter to certs
|
||||
// issued for it when the OEM picker honours that.
|
||||
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
|
||||
const alias = await pickClientCertAlias(host);
|
||||
setCertAlias(alias);
|
||||
@@ -62,7 +65,7 @@ export function AccountSecuritySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const onClearCert = async () => {
|
||||
const onClearCert = () => {
|
||||
if (certBusy) return;
|
||||
Alert.alert(
|
||||
'Stop using client certificate?',
|
||||
@@ -87,218 +90,644 @@ export function AccountSecuritySettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && (
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>
|
||||
{certAlias ? `Active: ${certAlias}` : 'Not set'}
|
||||
</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.
|
||||
</Text>
|
||||
</View>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Password change ───────────────────────────────────────
|
||||
function PasswordChangeSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [currentPwd, setCurrentPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
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 style={styles.certActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onPickCert(); }}
|
||||
>
|
||||
{certAlias ? 'Choose another' : 'Pick certificate'}
|
||||
</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>
|
||||
{certAlias && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onClearCert(); }}
|
||||
>
|
||||
Clear
|
||||
<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}>
|
||||
{certSupported && <ClientCertSection />}
|
||||
|
||||
{supported === null && (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator color={c.mutedForeground} />
|
||||
<Text style={styles.panelHint}>Detecting server features…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{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}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
|
||||
<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,
|
||||
},
|
||||
certRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
certStatus: { ...typography.bodyMedium, color: c.text },
|
||||
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
|
||||
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,184 +1,464 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
||||
import 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 styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
||||
const [isOpaque] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
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]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
useEffect(() => {
|
||||
void fetchFilters();
|
||||
}, [fetchFilters]);
|
||||
|
||||
const remove = (id: string) => {
|
||||
setRules((rs) => rs.filter((r) => r.id !== id));
|
||||
setDeleteId(null);
|
||||
};
|
||||
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={(v) => update('filtersExpandedView', v)} />
|
||||
</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,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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ import { useSettingsStore, type NotificationSound } from '../../stores/settings-
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
getStoredRelayBaseUrl,
|
||||
readPushAccountIds,
|
||||
setStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../../lib/push-notifications';
|
||||
|
||||
const SOUNDS: { value: NotificationSound; label: string }[] = [
|
||||
@@ -40,6 +41,7 @@ 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 hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
@@ -106,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({
|
||||
|
||||
@@ -83,6 +83,7 @@ export function ReadingSettings() {
|
||||
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);
|
||||
@@ -177,6 +178,7 @@ export function ReadingSettings() {
|
||||
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' },
|
||||
]}
|
||||
/>
|
||||
@@ -250,6 +252,10 @@ export function ReadingSettings() {
|
||||
<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={(v) => update('plainTextMode', v)} />
|
||||
</SettingItem>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,22 @@ describe('buildInitialHtml', () => {
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('<p><br></p>');
|
||||
expect(html).toContain('Alice <a@b.com> wrote:');
|
||||
// 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' },
|
||||
@@ -41,7 +52,7 @@ describe('buildInitialHtml', () => {
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('Forwarded message');
|
||||
expect(html).toContain('From: Bob <b@c.com>');
|
||||
expect(html).toContain('From: Bob');
|
||||
expect(html).toContain('Subject: Original Subject');
|
||||
expect(html).toContain('forwarded body');
|
||||
});
|
||||
|
||||
@@ -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,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseVCard,
|
||||
generateVCard,
|
||||
contactToVCard,
|
||||
detectDuplicates,
|
||||
} from '../vcard';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
|
||||
describe('parseVCard', () => {
|
||||
it('parses a basic 3.0 card with name, email, and phone', () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Jane Doe',
|
||||
'N:Doe;Jane;;;',
|
||||
'EMAIL;TYPE=WORK:jane@example.com',
|
||||
'TEL;TYPE=CELL:+1-555-0100',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const [card] = parseVCard(vcf);
|
||||
expect(card).toBeTruthy();
|
||||
const emails = Object.values(card.emails ?? {});
|
||||
expect(emails[0].address).toBe('jane@example.com');
|
||||
expect(emails[0].contexts).toEqual({ work: true });
|
||||
const phones = Object.values(card.phones ?? {});
|
||||
expect(phones[0].number).toBe('+1-555-0100');
|
||||
expect(phones[0].features).toEqual({ cell: true });
|
||||
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
|
||||
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
|
||||
expect(given).toBe('Jane');
|
||||
expect(surname).toBe('Doe');
|
||||
});
|
||||
|
||||
it('parses multiple cards in one file', () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD', 'VERSION:3.0', 'FN:A', 'EMAIL:a@x.com', 'END:VCARD',
|
||||
'BEGIN:VCARD', 'VERSION:3.0', 'FN:B', 'EMAIL:b@x.com', 'END:VCARD',
|
||||
].join('\r\n');
|
||||
expect(parseVCard(vcf)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('unfolds folded lines and decodes escaped values', () => {
|
||||
const vcf = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Long Name',
|
||||
'NOTE:line one\\nline two\\, still going',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
const [card] = parseVCard(vcf);
|
||||
const note = Object.values(card.notes ?? {})[0]?.note;
|
||||
expect(note).toBe('line one\nline two, still going');
|
||||
});
|
||||
|
||||
it('parses CATEGORIES into keywords', () => {
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Tagged\r\nEMAIL:t@x.com\r\nCATEGORIES:Friends,VIP\r\nEND:VCARD';
|
||||
const [card] = parseVCard(vcf);
|
||||
expect(card.keywords).toEqual({ Friends: true, VIP: true });
|
||||
});
|
||||
|
||||
it('skips cards with neither a name nor an email', () => {
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nNOTE:orphan\r\nEND:VCARD';
|
||||
expect(parseVCard(vcf)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips mailto:/tel: URI schemes', () => {
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:U\r\nEMAIL:mailto:u@x.com\r\nTEL:tel:+15550111\r\nEND:VCARD';
|
||||
const [card] = parseVCard(vcf);
|
||||
expect(Object.values(card.emails ?? {})[0].address).toBe('u@x.com');
|
||||
expect(Object.values(card.phones ?? {})[0].number).toBe('+15550111');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateVCard', () => {
|
||||
it('round-trips name and email through parse → generate → parse', () => {
|
||||
const original = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nN:Doe;Jane;;;\r\nEMAIL;TYPE=WORK:jane@example.com\r\nEND:VCARD';
|
||||
const [parsed] = parseVCard(original);
|
||||
const generated = generateVCard([parsed]);
|
||||
expect(generated).toContain('FN:Jane Doe');
|
||||
expect(generated).toContain('EMAIL;TYPE=WORK:jane@example.com');
|
||||
|
||||
const [reparsed] = parseVCard(generated);
|
||||
expect(Object.values(reparsed.emails ?? {})[0].address).toBe('jane@example.com');
|
||||
});
|
||||
|
||||
it('contactToVCard emits a single BEGIN/END block', () => {
|
||||
const card: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
name: { full: 'Solo', components: [{ kind: 'given', value: 'Solo' }] },
|
||||
emails: { e0: { address: 'solo@x.com' } },
|
||||
};
|
||||
const out = contactToVCard(card);
|
||||
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
|
||||
expect(out.match(/END:VCARD/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectDuplicates', () => {
|
||||
const existing: ContactCard[] = [
|
||||
{ id: 'x1', addressBookIds: {}, emails: { e0: { address: 'dupe@x.com' } } },
|
||||
];
|
||||
|
||||
it('flags an incoming contact that shares an email (case-insensitive)', () => {
|
||||
const incoming: ContactCard[] = [
|
||||
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'fresh@x.com' } } },
|
||||
{ id: 'i1', addressBookIds: {}, emails: { e0: { address: 'DUPE@x.com' } } },
|
||||
];
|
||||
const dupes = detectDuplicates(existing, incoming);
|
||||
expect(dupes.has(0)).toBe(false);
|
||||
expect(dupes.get(1)).toBe('x1');
|
||||
});
|
||||
|
||||
it('returns an empty map when nothing overlaps', () => {
|
||||
const incoming: ContactCard[] = [
|
||||
{ id: 'i0', addressBookIds: {}, emails: { e0: { address: 'new@x.com' } } },
|
||||
];
|
||||
expect(detectDuplicates(existing, incoming).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { eachYearOfInterval, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent, ContactCard, AnniversaryDate } from '../api/types';
|
||||
import { getContactDisplayName } from './contact-utils';
|
||||
|
||||
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
|
||||
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // yellow
|
||||
|
||||
// A read-only virtual calendar holding generated birthday events.
|
||||
export function createBirthdayCalendar(name = 'Birthdays'): Calendar {
|
||||
return {
|
||||
id: BIRTHDAY_CALENDAR_ID,
|
||||
name,
|
||||
color: BIRTHDAY_CALENDAR_COLOR,
|
||||
sortOrder: 999,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: false,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayWrite: false,
|
||||
mayDelete: false,
|
||||
mayRSVP: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseBirthdayDate(date: AnniversaryDate): { month: number; day: number; year?: number } | null {
|
||||
if (typeof date === 'string') {
|
||||
if (date.startsWith('--')) {
|
||||
const m = date.match(/^--(\d{2})-(\d{2})$/);
|
||||
return m ? { month: parseInt(m[1], 10), day: parseInt(m[2], 10) } : null;
|
||||
}
|
||||
const parsed = parseISO(date);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ('utc' in date && date['@type'] === 'Timestamp') {
|
||||
const parsed = parseISO(date.utc);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return { month: parsed.getMonth() + 1, day: parsed.getDate(), year: parsed.getFullYear() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// PartialDate
|
||||
if (date.month && date.day) {
|
||||
return { month: date.month, day: date.day, year: date.year || undefined };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate virtual all-day birthday events for the given range from contacts
|
||||
// that carry a `birth` anniversary.
|
||||
export function generateBirthdayEvents(
|
||||
contacts: ContactCard[],
|
||||
rangeStart: string,
|
||||
rangeEnd: string,
|
||||
): CalendarEvent[] {
|
||||
const events: CalendarEvent[] = [];
|
||||
const start = parseISO(rangeStart);
|
||||
const end = parseISO(rangeEnd);
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) return events;
|
||||
|
||||
const years = eachYearOfInterval({ start, end });
|
||||
const endYear = end.getFullYear();
|
||||
if (!years.some((y) => y.getFullYear() === endYear)) {
|
||||
years.push(new Date(endYear, 0, 1));
|
||||
}
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (!contact.anniversaries) continue;
|
||||
for (const [key, anniversary] of Object.entries(contact.anniversaries)) {
|
||||
if (anniversary.kind !== 'birth') continue;
|
||||
const parsed = parseBirthdayDate(anniversary.date);
|
||||
if (!parsed) continue;
|
||||
const displayName = getContactDisplayName(contact);
|
||||
if (!displayName) continue;
|
||||
|
||||
for (const yearDate of years) {
|
||||
const year = yearDate.getFullYear();
|
||||
// Feb 29 in a non-leap year: clamp to Feb 28.
|
||||
let occMonth = parsed.month;
|
||||
let occDay = parsed.day;
|
||||
const occurrence = new Date(year, occMonth - 1, occDay);
|
||||
if (occurrence.getMonth() !== occMonth - 1) {
|
||||
occurrence.setDate(0);
|
||||
occMonth = occurrence.getMonth() + 1;
|
||||
occDay = occurrence.getDate();
|
||||
}
|
||||
if (occurrence < start || occurrence > end) continue;
|
||||
|
||||
const mm = String(occMonth).padStart(2, '0');
|
||||
const dd = String(occDay).padStart(2, '0');
|
||||
const age = parsed.year ? year - parsed.year : undefined;
|
||||
const ageText = age && age > 0 ? ` (${age})` : '';
|
||||
|
||||
events.push({
|
||||
id: `birthday-${contact.id}-${key}-${year}`,
|
||||
'@type': 'Event',
|
||||
uid: `birthday-${contact.id}-${key}`,
|
||||
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
|
||||
title: `🎂 ${displayName}${ageText}`,
|
||||
start: `${year}-${mm}-${dd}T00:00:00`,
|
||||
duration: 'P1D',
|
||||
showWithoutTime: true,
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'free',
|
||||
color: BIRTHDAY_CALENDAR_COLOR,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Alert, CalendarEvent } from '../api/types';
|
||||
|
||||
// A reminder, expressed as "minutes before the event start". 0 means "at the
|
||||
// time of the event". Negative values (reminder *after* start) are preserved
|
||||
// on round-trip but the picker only offers non-negative presets.
|
||||
export interface Reminder {
|
||||
/** Minutes before start. 0 = at time of event. */
|
||||
minutesBefore: number;
|
||||
}
|
||||
|
||||
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
||||
|
||||
// Parse an ISO 8601 duration offset ("-PT15M", "-P1D", "PT0S") to minutes.
|
||||
// Positive return value = minutes *before* the anchor (JSCalendar offsets for
|
||||
// reminders are negative, e.g. "-PT15M" fires 15 min before start).
|
||||
export function offsetToMinutesBefore(offset: string | undefined): number | null {
|
||||
if (!offset) return null;
|
||||
const m = DURATION_RE.exec(offset);
|
||||
if (!m) return null;
|
||||
const negative = m[1] === '-';
|
||||
const weeks = parseInt(m[2] || '0', 10);
|
||||
const days = parseInt(m[3] || '0', 10);
|
||||
const hours = parseInt(m[4] || '0', 10);
|
||||
const minutes = parseInt(m[5] || '0', 10);
|
||||
const seconds = parseInt(m[6] || '0', 10);
|
||||
const totalMinutes = weeks * 7 * 24 * 60 + days * 24 * 60 + hours * 60 + minutes + Math.round(seconds / 60);
|
||||
// A negative offset (fires before start) is a positive "minutesBefore".
|
||||
// `|| 0` collapses -0 to 0 so callers get a clean "at time of event".
|
||||
return (negative ? totalMinutes : -totalMinutes) || 0;
|
||||
}
|
||||
|
||||
export function minutesBeforeToOffset(minutesBefore: number): string {
|
||||
if (minutesBefore === 0) return 'PT0S';
|
||||
const sign = minutesBefore > 0 ? '-' : '';
|
||||
let mins = Math.abs(minutesBefore);
|
||||
const days = Math.floor(mins / (24 * 60));
|
||||
mins -= days * 24 * 60;
|
||||
const hours = Math.floor(mins / 60);
|
||||
mins -= hours * 60;
|
||||
let out = 'P';
|
||||
if (days > 0) out += `${days}D`;
|
||||
if (hours > 0 || mins > 0) {
|
||||
out += 'T';
|
||||
if (hours > 0) out += `${hours}H`;
|
||||
if (mins > 0) out += `${mins}M`;
|
||||
}
|
||||
if (out === 'P') out = 'PT0M';
|
||||
return `${sign}${out}`;
|
||||
}
|
||||
|
||||
// Pull the list of reminders out of an event's `alerts` map. Only display-style
|
||||
// offset alerts relative to start are surfaced in the editor; anything exotic is
|
||||
// dropped from the UI list (but the event keeps it until the user saves).
|
||||
export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
|
||||
if (!alerts) return [];
|
||||
const out: Reminder[] = [];
|
||||
for (const alert of Object.values(alerts)) {
|
||||
const trigger = alert?.trigger;
|
||||
if (!trigger) continue;
|
||||
if (trigger.offset === undefined) continue; // skip AbsoluteTriggers in the picker
|
||||
const minutesBefore = offsetToMinutesBefore(trigger.offset);
|
||||
if (minutesBefore === null) continue;
|
||||
out.push({ minutesBefore });
|
||||
}
|
||||
// De-dupe + sort soonest-to-event last (largest lead time first).
|
||||
const seen = new Set<number>();
|
||||
return out
|
||||
.filter((r) => (seen.has(r.minutesBefore) ? false : (seen.add(r.minutesBefore), true)))
|
||||
.sort((a, b) => b.minutesBefore - a.minutesBefore);
|
||||
}
|
||||
|
||||
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
|
||||
if (reminders.length === 0) return undefined;
|
||||
const alerts: Record<string, Alert> = {};
|
||||
reminders.forEach((r, i) => {
|
||||
alerts[`reminder-${i + 1}`] = {
|
||||
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
|
||||
action: 'display',
|
||||
};
|
||||
});
|
||||
return alerts;
|
||||
}
|
||||
|
||||
export function formatReminder(minutesBefore: number): string {
|
||||
if (minutesBefore === 0) return 'At time of event';
|
||||
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
|
||||
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
|
||||
if (minutesBefore < 60 * 24) {
|
||||
const h = minutesBefore / 60;
|
||||
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
|
||||
}
|
||||
if (minutesBefore < 60 * 24 * 7) {
|
||||
const d = minutesBefore / (60 * 24);
|
||||
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
|
||||
}
|
||||
const w = minutesBefore / (60 * 24 * 7);
|
||||
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
|
||||
}
|
||||
|
||||
// Presets offered by the reminder picker (minutes before start).
|
||||
export const REMINDER_PRESETS: number[] = [
|
||||
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,
|
||||
];
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { CalendarEvent, Participant, Email, Attachment } from '../api/types';
|
||||
|
||||
// ─── Address helpers ─────────────────────────────────────
|
||||
|
||||
export function normalizeEmail(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim().replace(/^mailto:/i, '').toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function getParticipantEmail(p: Participant): string | null {
|
||||
const direct = normalizeEmail(p.email);
|
||||
if (direct) return direct;
|
||||
if (p.calendarAddress) {
|
||||
const addr = normalizeEmail(p.calendarAddress);
|
||||
if (addr) return addr;
|
||||
}
|
||||
if (p.sendTo) {
|
||||
for (const addr of Object.values(p.sendTo)) {
|
||||
const n = normalizeEmail(addr);
|
||||
if (n) return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isOrganizerParticipant(p: Participant): boolean {
|
||||
return Boolean(p.roles?.owner || p.roles?.chair);
|
||||
}
|
||||
|
||||
export function getOrganizerEmail(event: Partial<CalendarEvent>): string | null {
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (isOrganizerParticipant(p)) {
|
||||
const e = getParticipantEmail(p);
|
||||
if (e) return e;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.organizerCalendarAddress) return normalizeEmail(event.organizerCalendarAddress);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getOrganizerName(event: Partial<CalendarEvent>): string | null {
|
||||
if (event.participants) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (isOrganizerParticipant(p)) return p.name || getParticipantEmail(p);
|
||||
}
|
||||
if (event.organizerCalendarAddress) {
|
||||
for (const p of Object.values(event.participants)) {
|
||||
if (p.calendarAddress === event.organizerCalendarAddress) return p.name || getParticipantEmail(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return getOrganizerEmail(event);
|
||||
}
|
||||
|
||||
// Find the participant entry that matches one of the given user emails.
|
||||
export function findParticipantByEmail(
|
||||
event: Partial<CalendarEvent>,
|
||||
emails: string[],
|
||||
): { id: string; participant: Participant } | null {
|
||||
if (!event.participants || emails.length === 0) return null;
|
||||
const wanted = new Set(emails.map((e) => e.toLowerCase()).filter(Boolean));
|
||||
for (const [id, p] of Object.entries(event.participants)) {
|
||||
const e = getParticipantEmail(p);
|
||||
if (e && wanted.has(e)) return { id, participant: p };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `replyTo` to attach to an RSVP so the server knows where to deliver the iTIP
|
||||
// reply (Stalwart exposes the organizer as organizerCalendarAddress).
|
||||
export function buildReplyTo(event: Partial<CalendarEvent>): Record<string, string> | null {
|
||||
if (event.replyTo) return event.replyTo;
|
||||
if (event.organizerCalendarAddress) {
|
||||
const addr = event.organizerCalendarAddress.startsWith('mailto:')
|
||||
? event.organizerCalendarAddress
|
||||
: `mailto:${event.organizerCalendarAddress}`;
|
||||
return { imip: addr };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── iMIP method detection ───────────────────────────────
|
||||
|
||||
export type InvitationMethod =
|
||||
| 'publish' | 'request' | 'reply' | 'add'
|
||||
| 'cancel' | 'refresh' | 'counter' | 'declinecounter' | 'unknown';
|
||||
|
||||
const KNOWN_METHODS = new Set<InvitationMethod>([
|
||||
'publish', 'request', 'reply', 'add', 'cancel', 'refresh', 'counter', 'declinecounter',
|
||||
]);
|
||||
|
||||
function normalizeMethod(value?: string | null): InvitationMethod {
|
||||
if (!value) return 'unknown';
|
||||
const v = value.trim().toLowerCase();
|
||||
return KNOWN_METHODS.has(v as InvitationMethod) ? (v as InvitationMethod) : 'unknown';
|
||||
}
|
||||
|
||||
// JMAP strips Content-Type params (RFC 8621), so `text/calendar; method=REQUEST`
|
||||
// arrives as `text/calendar`. The raw ICS METHOD line is the reliable source.
|
||||
export function extractMethodFromRawIcs(rawText: string): InvitationMethod {
|
||||
const m = rawText.match(/^METHOD:(\S+)/m);
|
||||
return m ? normalizeMethod(m[1]) : 'unknown';
|
||||
}
|
||||
|
||||
function looksLikeReply(event: Partial<CalendarEvent>): boolean {
|
||||
if (!event.participants) return false;
|
||||
return Object.values(event.participants).some(
|
||||
(p) =>
|
||||
p.roles?.attendee &&
|
||||
!isOrganizerParticipant(p) &&
|
||||
(p.participationStatus !== 'needs-action' || !!p.participationComment),
|
||||
);
|
||||
}
|
||||
|
||||
export function inferInvitationMethod(event: Partial<CalendarEvent>): InvitationMethod {
|
||||
if (event.status === 'cancelled') return 'cancel';
|
||||
if (looksLikeReply(event)) return 'reply';
|
||||
if (event.participants && Object.keys(event.participants).length > 0) {
|
||||
if (Object.values(event.participants).some(isOrganizerParticipant) || event.organizerCalendarAddress) {
|
||||
return 'request';
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ─── Calendar attachment detection ───────────────────────
|
||||
|
||||
const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']);
|
||||
|
||||
function isCalendarType(type?: string | null): boolean {
|
||||
if (!type) return false;
|
||||
const base = type.split(';')[0].trim().toLowerCase();
|
||||
return CAL_MIME.has(base);
|
||||
}
|
||||
|
||||
export function findCalendarAttachment(email: Pick<Email, 'attachments'>): Attachment | null {
|
||||
if (!email.attachments) return null;
|
||||
for (const att of email.attachments) {
|
||||
const name = att.name?.toLowerCase() || '';
|
||||
if (isCalendarType(att.type) || name.endsWith('.ics') || name.endsWith('.ical')) {
|
||||
return att;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CAPABILITIES } from '../api/types';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
// When the session is null (cold start, offline restore) we assume features
|
||||
// are available so they don't flicker off mid-restore. Once the live session
|
||||
// arrives, the real capability set takes over.
|
||||
function sessionHasCapability(capability: string): boolean {
|
||||
const session = useAuthStore.getState().session;
|
||||
if (!session) return true;
|
||||
return capability in session.capabilities;
|
||||
}
|
||||
|
||||
export function hasCalendarCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.CALENDARS);
|
||||
}
|
||||
|
||||
export function hasContactsCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.CONTACTS);
|
||||
}
|
||||
|
||||
export function hasFilesCapability(): boolean {
|
||||
return sessionHasCapability(CAPABILITIES.FILES);
|
||||
}
|
||||
|
||||
export function useHasCalendar(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
|
||||
}
|
||||
|
||||
export function useHasContacts(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
|
||||
}
|
||||
|
||||
export function useHasFiles(): boolean {
|
||||
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
|
||||
}
|
||||
+45
-18
@@ -3,6 +3,7 @@
|
||||
// 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 };
|
||||
@@ -14,35 +15,62 @@ export interface ReplyMeta {
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
export interface QuoteHeaderOptions {
|
||||
timeFormat?: TimeFormat;
|
||||
locale?: string;
|
||||
unknownLabel?: string;
|
||||
}
|
||||
|
||||
function escapeForHtmlBody(text: string): string {
|
||||
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function senderLabel(from?: { email?: string; name?: string }): string {
|
||||
if (!from) return '';
|
||||
if (from.name && from.email) return `${from.name} <${from.email}>`;
|
||||
return from.name || from.email || '';
|
||||
// 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;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
// 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 '';
|
||||
return d.toLocaleString();
|
||||
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 date = formatDate(reply.receivedAt);
|
||||
const fromStr = senderLabel(reply.from) || 'Unknown';
|
||||
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
|
||||
@@ -54,27 +82,26 @@ export function buildInitialHtml(
|
||||
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 ? `Date: ${escapeHtml(date)}<br>` : '',
|
||||
subject ? `Subject: ${escapeHtml(subject)}<br>` : '',
|
||||
reply.to && reply.to.length
|
||||
? `To: ${escapeHtml(reply.to.map(senderLabel).filter(Boolean).join(', '))}<br>`
|
||||
: '',
|
||||
`Date: ${escapeHtml(date)}<br>`,
|
||||
`Subject: ${escapeHtml(subject)}<br><br>`,
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
quotedBody,
|
||||
].join('');
|
||||
}
|
||||
|
||||
// reply / replyAll
|
||||
const headerLine = date
|
||||
? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:`
|
||||
: `${escapeHtml(fromStr)} wrote:`;
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>',
|
||||
`On ${escapeHtml(date || 'a previous date')}, ${escapeHtml(fromStr)} wrote:`,
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
`<div>${headerLine}<br></div>`,
|
||||
`<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+45
-4
@@ -2,10 +2,38 @@ 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 {
|
||||
return jmapClient.authHeader;
|
||||
}
|
||||
@@ -60,8 +88,11 @@ export async function shareAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
email?: Email | null,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
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);
|
||||
@@ -84,8 +115,11 @@ export async function downloadAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
email?: Email | null,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
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);
|
||||
@@ -111,8 +145,15 @@ 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));
|
||||
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 downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { File } from 'expo-file-system';
|
||||
|
||||
// Mirrors the webmail `lib/eml-import.ts`: turn picked files into a flat list of
|
||||
// importable `.eml` messages, expanding `.zip` archives along the way. On RN the
|
||||
// picker hands us file URIs (file:// or content://) rather than File objects, so
|
||||
// we read bytes via expo-file-system and upload those.
|
||||
|
||||
export interface ImportableEml {
|
||||
name: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export const EML_IMPORT_TYPES = [
|
||||
'message/rfc822',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/octet-stream', // some pickers report .eml/.zip as octet-stream
|
||||
];
|
||||
|
||||
function isEmlName(name: string): boolean {
|
||||
return /\.eml$/i.test(name);
|
||||
}
|
||||
|
||||
function isZip(name: string, mime?: string): boolean {
|
||||
return /\.zip$/i.test(name) || mime === 'application/zip' || mime === 'application/x-zip-compressed';
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(bytes: Uint8Array): Promise<ImportableEml[]> {
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = await JSZip.loadAsync(bytes);
|
||||
const out: ImportableEml[] = [];
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
if (entry.dir || !isEmlName(entry.name)) continue;
|
||||
const data = await entry.async('uint8array');
|
||||
out.push({ name: entry.name.split(/[\\/]/).pop() || entry.name, bytes: data });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a single picked file (by URI) into its importable `.eml` messages.
|
||||
* A `.eml` yields one entry; a `.zip` yields one per `.eml` it contains.
|
||||
*/
|
||||
export async function expandImportableEml(
|
||||
uri: string,
|
||||
name: string,
|
||||
mime?: string,
|
||||
): Promise<ImportableEml[]> {
|
||||
const bytes = await new File(uri).bytes();
|
||||
if (isZip(name, mime)) return extractEmlsFromZip(bytes);
|
||||
return [{ name, bytes }];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Classify an error thrown by a JMAP call as a *transient* connectivity
|
||||
// failure (server unreachable, offline, session not yet up) versus a
|
||||
// *permanent* one (the server received the request and rejected it, e.g. a
|
||||
// 4xx). The offline outbox uses this to decide whether to keep an operation
|
||||
// queued for a later retry or to give up on it.
|
||||
//
|
||||
// We err on the side of "transient" only for signals we recognise as
|
||||
// connectivity-related; anything else (including bare 4xx/5xx responses) is
|
||||
// treated as permanent so a poison operation can't wedge the queue forever.
|
||||
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
|
||||
const TRANSIENT_NAMES = new Set([
|
||||
'NetworkError', // jmap-client transport wrapper
|
||||
'AbortError', // request aborted (e.g. app backgrounded)
|
||||
'TypeError', // RN fetch throws TypeError("Network request failed")
|
||||
]);
|
||||
|
||||
const TRANSIENT_MESSAGE_HINTS = [
|
||||
'network request failed',
|
||||
'network error',
|
||||
'failed to fetch',
|
||||
'not connected',
|
||||
'session expired', // token refresh will happen on the next live request
|
||||
'timeout',
|
||||
'timed out',
|
||||
'connection',
|
||||
];
|
||||
|
||||
export function isTransientNetworkError(err: unknown): boolean {
|
||||
// If the device itself reports offline, treat any failure as transient.
|
||||
if (!useNetworkStore.getState().online) return true;
|
||||
|
||||
if (!(err instanceof Error)) return false;
|
||||
if (TRANSIENT_NAMES.has(err.name)) return true;
|
||||
|
||||
const msg = err.message?.toLowerCase() ?? '';
|
||||
return TRANSIENT_MESSAGE_HINTS.some((hint) => msg.includes(hint));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { secureFetch } from './client-cert';
|
||||
|
||||
// Webmail-mediated login. The app opens the webmail's normal login page with
|
||||
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
|
||||
// uses its existing password and OAuth flows, and once the user is signed
|
||||
// in, redirects back to the app's custom scheme with credentials packed into
|
||||
// the URL fragment. Fragments aren't sent to the server, so password and
|
||||
// token material don't appear in HTTP access logs along the way.
|
||||
|
||||
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
|
||||
|
||||
export interface OAuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // epoch ms
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export type HandoffResult =
|
||||
| {
|
||||
flow: 'password';
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
| {
|
||||
flow: 'oauth';
|
||||
serverUrl: string;
|
||||
tokens: OAuthTokens;
|
||||
};
|
||||
|
||||
export class HandoffError extends Error {}
|
||||
export class HandoffCancelledError extends HandoffError {
|
||||
constructor() {
|
||||
super('Sign-in cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function randomState(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
let s = '';
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildHandoffUrl(webmailUrl: string, state: string): string {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
const params = new URLSearchParams({
|
||||
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
|
||||
mobile_state: state,
|
||||
});
|
||||
return `${base}/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseFragment(url: string): URLSearchParams {
|
||||
const hashIdx = url.indexOf('#');
|
||||
if (hashIdx === -1) return new URLSearchParams();
|
||||
return new URLSearchParams(url.slice(hashIdx + 1));
|
||||
}
|
||||
|
||||
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResult> {
|
||||
const state = randomState();
|
||||
const handoffUrl = buildHandoffUrl(webmailUrl, state);
|
||||
|
||||
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
|
||||
|
||||
if (result.type === 'cancel' || result.type === 'dismiss') {
|
||||
throw new HandoffCancelledError();
|
||||
}
|
||||
if (result.type !== 'success' || !result.url) {
|
||||
throw new HandoffError(`Sign-in failed: ${result.type}`);
|
||||
}
|
||||
|
||||
const params = parseFragment(result.url);
|
||||
const err = params.get('error');
|
||||
if (err) throw new HandoffError(err);
|
||||
|
||||
// CSRF guard: the state we generated must round-trip through the webmail
|
||||
// unchanged. A mismatch means the redirect didn't come from the flow we
|
||||
// started, and the rest of the fragment shouldn't be trusted.
|
||||
if (params.get('state') !== state) {
|
||||
throw new HandoffError('State mismatch');
|
||||
}
|
||||
|
||||
const flow = params.get('flow');
|
||||
const serverUrl = params.get('server_url');
|
||||
if (!serverUrl) throw new HandoffError('Sign-in response missing server URL');
|
||||
|
||||
if (flow === 'password') {
|
||||
const username = params.get('username');
|
||||
const password = params.get('password');
|
||||
if (!username || !password) {
|
||||
throw new HandoffError('Sign-in response missing credentials');
|
||||
}
|
||||
return { flow: 'password', serverUrl, username, password };
|
||||
}
|
||||
|
||||
if (flow === 'oauth') {
|
||||
const accessToken = params.get('access_token');
|
||||
const tokenEndpoint = params.get('token_endpoint');
|
||||
const clientId = params.get('client_id');
|
||||
if (!accessToken || !tokenEndpoint || !clientId) {
|
||||
throw new HandoffError('Sign-in response missing OAuth tokens');
|
||||
}
|
||||
const refreshToken = params.get('refresh_token') ?? undefined;
|
||||
const expiresIn = params.get('expires_in');
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl,
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
|
||||
tokenEndpoint,
|
||||
clientId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
|
||||
}
|
||||
|
||||
// OAuth refresh — exchanges the refresh token at the original token endpoint
|
||||
// for a new access token. Returns the updated bundle so the caller can
|
||||
// persist it.
|
||||
export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAuthTokens> {
|
||||
if (!tokens.refreshToken) {
|
||||
throw new HandoffError('No refresh token available');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HandoffError(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
@@ -30,6 +30,8 @@ const FETCH_CHUNK_FALLBACK = 25;
|
||||
|
||||
export interface RunOptions {
|
||||
days: number;
|
||||
/** Hard cap on the cache in megabytes; oldest mail is evicted to fit. */
|
||||
maxMB?: number;
|
||||
}
|
||||
|
||||
export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
@@ -77,6 +79,7 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
cache.setSyncState({ completed: skipped });
|
||||
|
||||
if (toFetch.length === 0) {
|
||||
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
finishedAt: Date.now(),
|
||||
@@ -131,6 +134,10 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
cache.setSyncState({ completed, fetched, bytes });
|
||||
}
|
||||
|
||||
// Trim the cache back under its size cap, shedding the oldest mail. Done
|
||||
// after fetching so we never evict something we're about to keep.
|
||||
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
|
||||
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
completed: total,
|
||||
|
||||
+132
-48
@@ -8,13 +8,22 @@ import {
|
||||
getFaviconDomain,
|
||||
getFaviconUrl,
|
||||
} from './avatar-utils';
|
||||
import {
|
||||
deviceClientIdKey,
|
||||
lastNotifiedKey,
|
||||
migrateLegacyPushKeys,
|
||||
readPushAccountIds,
|
||||
} from './push-notifications';
|
||||
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
||||
// import the Zustand store (would pull in React) so we read AsyncStorage
|
||||
// directly. Keep this in sync if the store key ever changes.
|
||||
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
|
||||
// Mirrors the persist name in `stores/account-store.ts`. We use it to find
|
||||
// the user's active account at the end of the headless task so we can leave
|
||||
// the jmapClient singleton in a state consistent with what the UI expects
|
||||
// when the app resumes.
|
||||
const ACCOUNT_REGISTRY_KEY = 'account-registry';
|
||||
|
||||
interface PushPersistedSettings {
|
||||
emailNotificationsEnabled?: boolean;
|
||||
@@ -41,76 +50,151 @@ interface ShowNotificationOptions {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface BulwarkFcmNative {
|
||||
showNotification(opts: ShowNotificationOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// The relay forwards each JMAP push to FCM tagged with the deviceClientId it
|
||||
// received the push on (the URL slot from createPushSubscription). The exact
|
||||
// key the relay uses isn't part of any client-controlled contract, so rather
|
||||
// than guessing a field name we scan all string values in the data payload
|
||||
// and look for one that matches a deviceClientId we've registered locally.
|
||||
// Returns null if nothing matches (e.g. legacy relay payload format) so the
|
||||
// caller falls back to iterating every known account.
|
||||
async function identifyAccountFromFcmData(
|
||||
data: unknown,
|
||||
accountIds: string[],
|
||||
): Promise<string | null> {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const reverseMap = new Map<string, string>();
|
||||
for (const accountId of accountIds) {
|
||||
const dcid = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||
if (dcid) reverseMap.set(dcid, accountId);
|
||||
}
|
||||
if (reverseMap.size === 0) return null;
|
||||
|
||||
for (const value of Object.values(data as Record<string, unknown>)) {
|
||||
if (typeof value === 'string') {
|
||||
const match = reverseMap.get(value);
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the persisted active account directly from AsyncStorage (Zustand's
|
||||
// persist middleware stores the JSON-serialised state under its `name`).
|
||||
async function readActiveAccountId(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(ACCOUNT_REGISTRY_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { state?: { activeAccountId?: string | null } };
|
||||
return parsed.state?.activeAccountId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
|
||||
// short-lived headless JS runtime - keep it fast, catch all errors, and always
|
||||
// resolve so the native service can release its wake lock.
|
||||
export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
export async function pushBackgroundTask(data: unknown): Promise<void> {
|
||||
let activeAccountId: string | null = null;
|
||||
try {
|
||||
if (!(await emailNotificationsAllowed())) return;
|
||||
|
||||
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
|
||||
if (!accountId) return;
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const loaded = await jmapClient.loadAccount(accountId);
|
||||
if (!loaded) return;
|
||||
const accountIds = await readPushAccountIds();
|
||||
if (accountIds.length === 0) return;
|
||||
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
||||
if (!inbox) return;
|
||||
activeAccountId = await readActiveAccountId();
|
||||
|
||||
const { ids } = await queryEmails(inbox.id, {
|
||||
filter: { notKeyword: '$seen' },
|
||||
limit: 1,
|
||||
});
|
||||
if (ids.length === 0) return;
|
||||
const matched = await identifyAccountFromFcmData(data, accountIds);
|
||||
const accountsToCheck = matched ? [matched] : accountIds;
|
||||
|
||||
const emailId = ids[0];
|
||||
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
if (previouslyNotified === emailId) return;
|
||||
|
||||
const [email] = await getEmails([emailId]);
|
||||
if (!email) return;
|
||||
|
||||
const from = email.from?.[0];
|
||||
const name = from?.name ?? '';
|
||||
const address = from?.email ?? '';
|
||||
const title = name || address || 'New mail';
|
||||
const body = email.subject || '(no subject)';
|
||||
const initials = getEmailInitials(name, address);
|
||||
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
|
||||
const faviconDomain = getFaviconDomain(address);
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||
if (!native?.showNotification) return;
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${emailId}`,
|
||||
title,
|
||||
body,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
|
||||
for (const accountId of accountsToCheck) {
|
||||
try {
|
||||
await processAccountForPush(accountId);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[push] background check failed for account',
|
||||
accountId,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[push] background task failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
} finally {
|
||||
// Leave the singleton bound to the user's active account so the UI sees
|
||||
// a consistent jmapClient state when the app resumes. Without this, the
|
||||
// last account processed above would remain loaded and the UI's cached
|
||||
// email-store data would not match what the next JMAP request returns.
|
||||
if (activeAccountId) {
|
||||
await jmapClient.loadAccount(activeAccountId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processAccountForPush(accountId: string): Promise<void> {
|
||||
const loaded = await jmapClient.loadAccount(accountId);
|
||||
if (!loaded) return;
|
||||
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox');
|
||||
if (!inbox) return;
|
||||
|
||||
const { ids } = await queryEmails(inbox.id, {
|
||||
filter: { notKeyword: '$seen' },
|
||||
limit: 1,
|
||||
});
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const emailId = ids[0];
|
||||
const lastKey = lastNotifiedKey(accountId);
|
||||
const previouslyNotified = await AsyncStorage.getItem(lastKey);
|
||||
if (previouslyNotified === emailId) return;
|
||||
|
||||
const [email] = await getEmails([emailId]);
|
||||
if (!email) return;
|
||||
|
||||
const from = email.from?.[0];
|
||||
const name = from?.name ?? '';
|
||||
const address = from?.email ?? '';
|
||||
const title = name || address || 'New mail';
|
||||
const body = email.subject || '(no subject)';
|
||||
const initials = getEmailInitials(name, address);
|
||||
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
|
||||
const faviconDomain = getFaviconDomain(address);
|
||||
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
|
||||
|
||||
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
|
||||
if (!native?.showNotification) return;
|
||||
|
||||
await native.showNotification({
|
||||
notificationId: `mail:${emailId}`,
|
||||
title,
|
||||
body,
|
||||
initials,
|
||||
bgColorHex,
|
||||
iconUrl,
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
accountId,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(lastKey, emailId);
|
||||
}
|
||||
|
||||
function hslToHex(hsl: string): string {
|
||||
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
|
||||
if (!match) return '#2563eb';
|
||||
|
||||
+205
-43
@@ -11,12 +11,88 @@ import { jmapClient } from '../api/jmap-client';
|
||||
import { generateAccountId } from './account-utils';
|
||||
|
||||
// Persist identifiers across launches so we reuse the same JMAP subscription
|
||||
// after app restarts. deviceClientId also routes to the relay slot.
|
||||
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
|
||||
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
|
||||
// after app restarts. Each account gets its own deviceClientId so the hosted
|
||||
// relay can distinguish per-account pushes via the URL slot it forwards.
|
||||
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
|
||||
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
|
||||
const DEVICE_CLIENT_ID_PREFIX = 'push:deviceClientId:v2:';
|
||||
const SUBSCRIPTION_ID_PREFIX = 'push:subscriptionId:v2:';
|
||||
export const LAST_NOTIFIED_EMAIL_ID_PREFIX = 'push:lastNotifiedEmailId:v2:';
|
||||
|
||||
// Legacy single-account keys (pre-multi-account). Migrated lazily on the next
|
||||
// setupPushNotifications / pushBackgroundTask call, then deleted.
|
||||
const LEGACY_DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
|
||||
const LEGACY_SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
|
||||
const LEGACY_PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
|
||||
const LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
|
||||
|
||||
export function deviceClientIdKey(accountId: string): string {
|
||||
return DEVICE_CLIENT_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function subscriptionIdKey(accountId: string): string {
|
||||
return SUBSCRIPTION_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export function lastNotifiedKey(accountId: string): string {
|
||||
return LAST_NOTIFIED_EMAIL_ID_PREFIX + accountId;
|
||||
}
|
||||
|
||||
export async function readPushAccountIds(): Promise<string[]> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writePushAccountIds(ids: string[]): Promise<void> {
|
||||
const deduped = Array.from(new Set(ids));
|
||||
if (deduped.length === 0) {
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
} else {
|
||||
await AsyncStorage.setItem(PUSH_ACCOUNT_IDS_KEY, JSON.stringify(deduped));
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot migration from the pre-multi-account schema. If the legacy
|
||||
// PUSH_ACCOUNT_ID_KEY exists, treat that account as the only pre-existing
|
||||
// setup: reuse the legacy deviceClientId and JMAP subscription id under
|
||||
// the new per-account keys so the user doesn't lose push on upgrade.
|
||||
export async function migrateLegacyPushKeys(): Promise<void> {
|
||||
const legacyAccountId = await AsyncStorage.getItem(LEGACY_PUSH_ACCOUNT_ID_KEY);
|
||||
if (!legacyAccountId) return;
|
||||
|
||||
const legacyDcid = await AsyncStorage.getItem(LEGACY_DEVICE_CLIENT_ID_KEY);
|
||||
const legacySubId = await AsyncStorage.getItem(LEGACY_SUBSCRIPTION_ID_KEY);
|
||||
const legacyLastId = await AsyncStorage.getItem(LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
|
||||
if (legacyDcid) {
|
||||
await AsyncStorage.setItem(deviceClientIdKey(legacyAccountId), legacyDcid);
|
||||
}
|
||||
if (legacySubId) {
|
||||
await AsyncStorage.setItem(subscriptionIdKey(legacyAccountId), legacySubId);
|
||||
}
|
||||
if (legacyLastId) {
|
||||
await AsyncStorage.setItem(lastNotifiedKey(legacyAccountId), legacyLastId);
|
||||
}
|
||||
|
||||
const ids = await readPushAccountIds();
|
||||
if (!ids.includes(legacyAccountId)) {
|
||||
await writePushAccountIds([...ids, legacyAccountId]);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiRemove([
|
||||
LEGACY_PUSH_ACCOUNT_ID_KEY,
|
||||
LEGACY_DEVICE_CLIENT_ID_KEY,
|
||||
LEGACY_SUBSCRIPTION_ID_KEY,
|
||||
LEGACY_LAST_NOTIFIED_EMAIL_ID_KEY,
|
||||
]);
|
||||
}
|
||||
|
||||
// Hosted relay so users don't need to run their own Firebase project. The
|
||||
// relay only ever sees FCM tokens + JMAP state-id hashes - no mail content.
|
||||
@@ -72,11 +148,12 @@ function randomClientId(): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function getOrCreateDeviceClientId(): Promise<string> {
|
||||
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
async function getOrCreateDeviceClientId(accountId: string): Promise<string> {
|
||||
const key = deviceClientIdKey(accountId);
|
||||
const existing = await AsyncStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const next = randomClientId();
|
||||
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
|
||||
await AsyncStorage.setItem(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -184,20 +261,24 @@ export async function setupPushNotifications(
|
||||
const fcmToken = await native.getToken();
|
||||
if (!fcmToken) throw new Error('FCM token unavailable');
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId();
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Persist the account id so the background push task can reload credentials
|
||||
// from SecureStore without the main app being running.
|
||||
// setupPushNotifications operates on the currently-loaded jmapClient. We
|
||||
// need its username/serverUrl up-front so we can key per-account state.
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (username && serverUrl) {
|
||||
await AsyncStorage.setItem(
|
||||
PUSH_ACCOUNT_ID_KEY,
|
||||
generateAccountId(username, serverUrl),
|
||||
);
|
||||
if (!username || !serverUrl) {
|
||||
throw new Error('No account loaded - cannot set up push');
|
||||
}
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId(accountId);
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Register this account's device-client-id with the relay. Multiple
|
||||
// accounts on the same device end up as separate registrations sharing
|
||||
// one fcmToken - the relay forwards each push individually so the headless
|
||||
// task can identify the source account via the FCM data payload.
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
@@ -208,26 +289,31 @@ export async function setupPushNotifications(
|
||||
// Reuse the previous JMAP subscription when the server still has it, but
|
||||
// push the expiry forward so it doesn't time out before the next app start.
|
||||
const existingSubs = await listPushSubscriptions().catch(() => []);
|
||||
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const storedServerId = await AsyncStorage.getItem(subKey);
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(match);
|
||||
if (refreshed) {
|
||||
await addPushAccountId(accountId);
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
}
|
||||
// Server rejected the refresh (likely the subscription was already
|
||||
// deleted server-side) - drop the stale id and recreate below.
|
||||
await destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this device.
|
||||
// These pile up when a previous enable attempt failed mid-flow (verify
|
||||
// race, network blip, app killed). Stalwart per-account rate-limits
|
||||
// PushVerification posts, so leaving stragglers around blocks the new
|
||||
// one's verification - the symptom is a confusing "code does not match".
|
||||
// Reap any leftover Stalwart subscriptions still bound to this account's
|
||||
// deviceClientId. These pile up when a previous enable attempt failed
|
||||
// mid-flow (verify race, network blip, app killed). Stalwart per-account
|
||||
// rate-limits PushVerification posts, so leaving stragglers around blocks
|
||||
// the new one's verification - the symptom is a confusing "code does not
|
||||
// match". Only reap matches for THIS account's deviceClientId; other
|
||||
// accounts' subscriptions on this server (rare but possible if the same
|
||||
// JMAP server backs multiple accounts) must be left alone.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
@@ -245,11 +331,19 @@ export async function setupPushNotifications(
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
|
||||
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
|
||||
await AsyncStorage.setItem(subKey, serverAssignedId);
|
||||
await addPushAccountId(accountId);
|
||||
|
||||
return { subscriptionId: serverAssignedId, verified: true };
|
||||
}
|
||||
|
||||
async function addPushAccountId(accountId: string): Promise<void> {
|
||||
const ids = await readPushAccountIds();
|
||||
if (!ids.includes(accountId)) {
|
||||
await writePushAccountIds([...ids, accountId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Push the subscription's expires forward when it's getting close to the
|
||||
// server's ceiling. Returns false if the server rejects the update, which the
|
||||
// caller treats as "recreate".
|
||||
@@ -273,28 +367,92 @@ async function refreshSubscriptionExpires(
|
||||
}
|
||||
}
|
||||
|
||||
async function deregisterFromRelay(
|
||||
relayBaseUrl: string,
|
||||
deviceClientId: string,
|
||||
): Promise<void> {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push on logout / disable. Removes the server-side JMAP
|
||||
* subscription, tells the relay to drop the mapping, and clears the local
|
||||
* FCM token. Never throws - callers treat teardown as best effort.
|
||||
* Tear down push for a single account. Destroys that account's JMAP
|
||||
* subscription (assumes the jmapClient is currently authenticated to that
|
||||
* account; the active-account logout flow guarantees this) and tells the
|
||||
* relay to drop its mapping. Other accounts' push setups are untouched.
|
||||
*
|
||||
* If no accounts have push left after removal, also deletes the FCM token
|
||||
* so the device stops receiving FCM messages entirely. Never throws -
|
||||
* callers treat teardown as best effort.
|
||||
*/
|
||||
export async function teardownPushNotificationsForAccount(
|
||||
accountId: string,
|
||||
): Promise<void> {
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
||||
const dcidKey = deviceClientIdKey(accountId);
|
||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
|
||||
if (storedSubId) {
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||
}
|
||||
|
||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
||||
|
||||
const remaining = (await readPushAccountIds()).filter((id) => id !== accountId);
|
||||
await writePushAccountIds(remaining);
|
||||
|
||||
// If this was the last account with push, kill the FCM token so the device
|
||||
// truly goes silent. Otherwise the token stays alive so the remaining
|
||||
// accounts keep receiving pushes.
|
||||
if (remaining.length === 0) {
|
||||
const native = getNative();
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push for ALL accounts on this device. Used by the logout-all
|
||||
* flow; best-effort because we typically aren't authenticated to every
|
||||
* account's JMAP server at the moment we need to call destroy on it. The
|
||||
* FCM token is always deleted so no push gets through regardless.
|
||||
*/
|
||||
export async function teardownPushNotifications(): Promise<void> {
|
||||
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
if (storedId) {
|
||||
await destroyPushSubscription(storedId).catch(() => undefined);
|
||||
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
|
||||
const accountIds = await readPushAccountIds();
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const subKey = subscriptionIdKey(accountId);
|
||||
const dcidKey = deviceClientIdKey(accountId);
|
||||
const storedSubId = await AsyncStorage.getItem(subKey);
|
||||
const storedDcid = await AsyncStorage.getItem(dcidKey);
|
||||
|
||||
if (storedSubId) {
|
||||
// Will only succeed if the jmapClient happens to be authenticated to
|
||||
// this account right now. We don't switch the client to attempt each
|
||||
// one; the subscription will expire server-side instead (90-day TTL).
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
await deregisterFromRelay(relayBaseUrl, storedDcid);
|
||||
}
|
||||
await AsyncStorage.multiRemove([subKey, dcidKey, lastNotifiedKey(accountId)]);
|
||||
}
|
||||
if (relayBaseUrl && deviceClientId) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
|
||||
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
|
||||
|
||||
await AsyncStorage.removeItem(PUSH_ACCOUNT_IDS_KEY);
|
||||
|
||||
const native = getNative();
|
||||
if (native) {
|
||||
await native.deleteToken().catch(() => undefined);
|
||||
@@ -327,6 +485,10 @@ export interface NotificationTapPayload {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
// Identifies which logged-in account the notification was generated for.
|
||||
// Optional for back-compat: older notifications already on the system tray
|
||||
// won't carry this and will fall back to the active account on tap.
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parseScript } from '../parser';
|
||||
import { generateScript } from '../generator';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'bw-1',
|
||||
name: 'Bulwark Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('external rule preservation (issue #201)', () => {
|
||||
describe('parser - external rule recognition', () => {
|
||||
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
|
||||
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
const rule = result.rules[0];
|
||||
expect(rule.origin).toBe('external');
|
||||
expect(rule.originLabel).toBe('Roundcube');
|
||||
expect(rule.name).toBe('Archive Newsletters');
|
||||
expect(rule.conditions[0]).toMatchObject({
|
||||
field: 'header',
|
||||
comparator: 'contains',
|
||||
value: 'news',
|
||||
headerName: 'List-Id',
|
||||
});
|
||||
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
|
||||
});
|
||||
|
||||
it('labels rules near a Nextcloud marker comment', () => {
|
||||
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].originLabel).toBe('Nextcloud');
|
||||
});
|
||||
|
||||
it('falls back to "External" label when no known marker is present', () => {
|
||||
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].originLabel).toBe('External');
|
||||
});
|
||||
|
||||
it('parses anyof/allof conditions in external rules', () => {
|
||||
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].matchType).toBe('any');
|
||||
expect(result.rules[0].conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('parses negated conditions (not header :is)', () => {
|
||||
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].conditions[0]).toMatchObject({
|
||||
field: 'from',
|
||||
comparator: 'not_is',
|
||||
value: 'spam@x.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
|
||||
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
const rule = result.rules[0];
|
||||
expect(rule.origin).toBe('opaque');
|
||||
expect(rule.rawBlock).toContain('if header :value');
|
||||
});
|
||||
|
||||
it('collects all external require tokens', () => {
|
||||
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser - mixed Bulwark + external', () => {
|
||||
it('returns Bulwark rules from metadata and external rules from the rest', () => {
|
||||
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
|
||||
const bulwarkScript = generateScript(bulwark);
|
||||
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
|
||||
|
||||
const result = parseScript(mixedScript);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
|
||||
const externalParsed = result.rules.filter(r => r.origin === 'external');
|
||||
expect(bulwarkParsed).toHaveLength(1);
|
||||
expect(bulwarkParsed[0].name).toBe('Bulwark A');
|
||||
expect(externalParsed).toHaveLength(1);
|
||||
expect(externalParsed[0].originLabel).toBe('Nextcloud');
|
||||
});
|
||||
|
||||
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
|
||||
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
|
||||
const script = generateScript(bulwark);
|
||||
const result = parseScript(script);
|
||||
|
||||
// Only the metadata-sourced rule, no duplicate "external" entry.
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not duplicate a Bulwark rule whose values contain literal braces', () => {
|
||||
// Regression for an issue where a value like "Report Domain: {x}" caused
|
||||
// parseIfBlockToRule's naive indexOf('{') to point inside the value
|
||||
// instead of at the body, so the if-block fell back to an opaque rule
|
||||
// that escaped the bulwark-name dedup filter.
|
||||
const bulwark = [
|
||||
makeBulwarkRule({
|
||||
name: 'DMARC reports',
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'subject', comparator: 'contains', value: 'Report Domain: mydomain.com' },
|
||||
{ field: 'subject', comparator: 'contains', value: 'Report Domain: {mydomain.com}' },
|
||||
],
|
||||
actions: [{ type: 'mark_read' }, { type: 'move', value: 'DMARC' }],
|
||||
}),
|
||||
];
|
||||
const script = generateScript(bulwark);
|
||||
const result = parseScript(script);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].origin).toBeUndefined();
|
||||
expect(result.rules[0].name).toBe('DMARC reports');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generator - external splice', () => {
|
||||
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
|
||||
const externalRule: FilterRule = {
|
||||
id: 'ext-0',
|
||||
name: 'External',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
|
||||
actions: [{ type: 'move', value: 'Lists' }],
|
||||
stopProcessing: false,
|
||||
origin: 'external',
|
||||
originLabel: 'Nextcloud',
|
||||
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
|
||||
};
|
||||
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
|
||||
const script = generateScript(rules);
|
||||
|
||||
expect(script).toContain('# Rule: Bulwark Rule');
|
||||
expect(script).toContain('# Nextcloud Mail');
|
||||
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
|
||||
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
|
||||
const externalIdx = script.indexOf('# Nextcloud Mail');
|
||||
expect(bulwarkIdx).toBeLessThan(externalIdx);
|
||||
});
|
||||
|
||||
it('unions external requires into the top-level require line', () => {
|
||||
const script = generateScript([makeBulwarkRule()], undefined, {
|
||||
externalRequires: ['fileinto', 'imap4flags', 'body'],
|
||||
});
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
||||
expect(requireLine).toContain('"fileinto"');
|
||||
expect(requireLine).toContain('"imap4flags"');
|
||||
expect(requireLine).toContain('"body"');
|
||||
});
|
||||
|
||||
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
|
||||
const bulwarkWithJunk: FilterRule = {
|
||||
...makeBulwarkRule(),
|
||||
origin: 'bulwark',
|
||||
originLabel: 'shouldnotbehere',
|
||||
rawBlock: 'shouldnotbehere',
|
||||
};
|
||||
const script = generateScript([bulwarkWithJunk]);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.rules[0]).not.toHaveProperty('origin');
|
||||
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
|
||||
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
|
||||
});
|
||||
|
||||
it('never writes external rules into metadata', () => {
|
||||
const ext: FilterRule = {
|
||||
id: 'ext-0',
|
||||
name: 'Ext',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
|
||||
actions: [{ type: 'keep' }],
|
||||
stopProcessing: false,
|
||||
origin: 'external',
|
||||
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
|
||||
};
|
||||
const script = generateScript([makeBulwarkRule(), ext]);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.rules).toHaveLength(1);
|
||||
expect(metadata.rules[0].name).toBe('Bulwark Rule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixture: mixed-origins.sieve', () => {
|
||||
const fixture = readFileSync(
|
||||
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
|
||||
const result = parseScript(fixture);
|
||||
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.vacation).toBeUndefined();
|
||||
|
||||
const byOrigin = {
|
||||
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
|
||||
external: result.rules.filter(r => r.origin === 'external'),
|
||||
opaque: result.rules.filter(r => r.origin === 'opaque'),
|
||||
};
|
||||
|
||||
expect(byOrigin.bulwark).toHaveLength(2);
|
||||
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
|
||||
expect(byOrigin.opaque).toHaveLength(1);
|
||||
|
||||
const labels = byOrigin.external.map(r => r.originLabel);
|
||||
expect(labels).toContain('Roundcube');
|
||||
expect(labels).toContain('Nextcloud');
|
||||
expect(labels).toContain('External');
|
||||
|
||||
const opaqueRule = byOrigin.opaque[0];
|
||||
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
|
||||
});
|
||||
|
||||
it('preserves unknown-Sieve content through save round-trip', () => {
|
||||
const parsed = parseScript(fixture);
|
||||
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
|
||||
// The unparseable construct must appear verbatim in the regenerated script.
|
||||
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
|
||||
// Require tokens from the external content are preserved.
|
||||
expect(regenerated).toContain('"relational"');
|
||||
// Bulwark rules are still present.
|
||||
expect(regenerated).toContain('# Rule: Archive newsletters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves external rules through parse → generate → parse', () => {
|
||||
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||
|
||||
const firstParse = parseScript(initial);
|
||||
expect(firstParse.rules).toHaveLength(2);
|
||||
|
||||
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
|
||||
externalRequires: firstParse.externalRequires,
|
||||
});
|
||||
const secondParse = parseScript(regenerated);
|
||||
|
||||
expect(secondParse.rules).toHaveLength(2);
|
||||
const names = secondParse.rules.map(r => r.name).sort();
|
||||
expect(names).toContain('VIP');
|
||||
});
|
||||
|
||||
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
|
||||
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
|
||||
|
||||
const parsed = parseScript(initial);
|
||||
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
|
||||
expect(externalBefore).toHaveLength(1);
|
||||
|
||||
// Simulate a user edit - update the Bulwark rule name
|
||||
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
|
||||
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
|
||||
const reparsed = parseScript(regenerated);
|
||||
|
||||
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
|
||||
expect(externalAfter).toHaveLength(1);
|
||||
expect(externalAfter[0].name).toBe('Untouchable');
|
||||
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nextcloud Mail marker regions', () => {
|
||||
const NEXTCLOUD_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
|
||||
|
||||
const nextcloudScript = [
|
||||
NEXTCLOUD_MARKER,
|
||||
'require ["imap4flags", "fileinto"];',
|
||||
NEXTCLOUD_MARKER,
|
||||
'',
|
||||
NEXTCLOUD_MARKER,
|
||||
'# FILTER: [{"name":"Wetterwarnungen"}]',
|
||||
'if header :contains "From" "weather@x.com" {',
|
||||
' fileinto "Weather";',
|
||||
'}',
|
||||
'',
|
||||
'# PayPal',
|
||||
'if header :contains "From" "paypal.de" {',
|
||||
' addflag "$paypal";',
|
||||
'}',
|
||||
NEXTCLOUD_MARKER,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('collapses a Nextcloud marker-pair region into one opaque rule labeled "Nextcloud"', () => {
|
||||
const result = parseScript(nextcloudScript);
|
||||
const nextcloud = result.rules.filter(r => r.originLabel === 'Nextcloud');
|
||||
expect(nextcloud).toHaveLength(1);
|
||||
expect(nextcloud[0].origin).toBe('opaque');
|
||||
// Every marker that wrapped actual rule content survives in the rawBlock.
|
||||
const markerCount = (nextcloud[0].rawBlock || '').split(NEXTCLOUD_MARKER).length - 1;
|
||||
expect(markerCount).toBe(2);
|
||||
// Inner if-blocks are not exposed as separate external rules.
|
||||
expect(result.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('merges require tokens from inside Nextcloud regions into externalRequires', () => {
|
||||
const result = parseScript(nextcloudScript);
|
||||
expect(result.externalRequires).toEqual(expect.arrayContaining(['imap4flags', 'fileinto']));
|
||||
});
|
||||
|
||||
it('drops require-only Nextcloud regions (no separate opaque rule for them)', () => {
|
||||
const script = [
|
||||
NEXTCLOUD_MARKER,
|
||||
'require ["fileinto"];',
|
||||
NEXTCLOUD_MARKER,
|
||||
'',
|
||||
].join('\n');
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toHaveLength(0);
|
||||
expect(result.externalRequires).toContain('fileinto');
|
||||
});
|
||||
|
||||
it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => {
|
||||
const bulwark = makeBulwarkRule({ name: 'Test' });
|
||||
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
|
||||
|
||||
const parsed = parseScript(initial);
|
||||
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
|
||||
// Both wrapping markers remain, in the right positions.
|
||||
const firstMarker = regenerated.indexOf(NEXTCLOUD_MARKER);
|
||||
const lastMarker = regenerated.lastIndexOf(NEXTCLOUD_MARKER);
|
||||
expect(firstMarker).toBeGreaterThanOrEqual(0);
|
||||
expect(lastMarker).toBeGreaterThan(firstMarker);
|
||||
expect(regenerated).toContain('# FILTER: [{"name":"Wetterwarnungen"}]');
|
||||
expect(regenerated).toContain('# PayPal');
|
||||
|
||||
const reparsed = parseScript(regenerated);
|
||||
expect(reparsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(1);
|
||||
expect(reparsed.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit duplicate "External rules" headers across repeated saves', () => {
|
||||
const bulwark = makeBulwarkRule({ name: 'Test' });
|
||||
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
|
||||
|
||||
let script = initial;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const parsed = parseScript(script);
|
||||
script = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
}
|
||||
|
||||
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
|
||||
expect(headerCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* @metadata:begin
|
||||
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
|
||||
@metadata:end */
|
||||
|
||||
require ["body", "copy", "fileinto", "imap4flags", "relational"];
|
||||
|
||||
# Rule: Archive newsletters
|
||||
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
|
||||
fileinto "Newsletters";
|
||||
}
|
||||
|
||||
# Rule: Flag VIP senders
|
||||
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
|
||||
addflag "\\Flagged";
|
||||
addflag "\\Seen";
|
||||
}
|
||||
|
||||
# --- External rules (managed outside Bulwark) ---
|
||||
|
||||
# rule:[Finance - auto-file invoices]
|
||||
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
|
||||
fileinto :copy "Finance/Invoices";
|
||||
keep;
|
||||
}
|
||||
|
||||
# Nextcloud Mail - begin
|
||||
# Filter installed by Nextcloud Mail app
|
||||
if header :contains "Subject" "[Support]" {
|
||||
fileinto "Support";
|
||||
}
|
||||
# Nextcloud Mail - end
|
||||
|
||||
# A handwritten rule without a tool-specific marker.
|
||||
# Bulwark should recognize this as generic "External" and preserve it.
|
||||
if not header :is "X-Spam-Status" "No" {
|
||||
fileinto "Junk";
|
||||
}
|
||||
|
||||
# A rule using a Sieve construct Bulwark's visual editor does not understand.
|
||||
# It must survive round-trips verbatim, shown to the user as read-only.
|
||||
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
|
||||
fileinto "LowPriority";
|
||||
stop;
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
name: 'Test Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('generateScript', () => {
|
||||
it('outputs metadata and no require for empty rules', () => {
|
||||
const script = generateScript([]);
|
||||
expect(script).toContain('/* @metadata:begin');
|
||||
expect(script).toContain('@metadata:end */');
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
|
||||
it('embeds compact metadata JSON', () => {
|
||||
const rules = [makeRule()];
|
||||
const script = generateScript(rules);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
expect(match).not.toBeNull();
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.version).toBe(1);
|
||||
expect(metadata.rules).toHaveLength(1);
|
||||
expect(metadata.rules[0].id).toBe('rule-1');
|
||||
});
|
||||
|
||||
it('generates single rule with from/contains', () => {
|
||||
const script = generateScript([makeRule()]);
|
||||
expect(script).toContain('# Rule: Test Rule');
|
||||
expect(script).toContain('if header :contains "From" "test@example.com"');
|
||||
expect(script).toContain('fileinto "Archive";');
|
||||
});
|
||||
|
||||
describe('condition fields', () => {
|
||||
it('maps to field to "To" header', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "To" "me@x.com"');
|
||||
});
|
||||
|
||||
it('maps cc field to "Cc" header', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }],
|
||||
})]);
|
||||
expect(script).toContain('header :is "Cc" "cc@x.com"');
|
||||
});
|
||||
|
||||
it('maps subject field to "Subject" header', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "Subject" "hello"');
|
||||
});
|
||||
|
||||
it('maps header field with custom headerName', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "X-Custom" "test"');
|
||||
});
|
||||
|
||||
it('handles size greater_than', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
|
||||
})]);
|
||||
expect(script).toContain('size :over 1000000');
|
||||
});
|
||||
|
||||
it('handles size less_than', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'size', comparator: 'less_than', value: '500' }],
|
||||
})]);
|
||||
expect(script).toContain('size :under 500');
|
||||
});
|
||||
|
||||
it('handles body contains', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }],
|
||||
})]);
|
||||
expect(script).toContain('body :contains "keyword"');
|
||||
expect(script).toContain('"body"');
|
||||
});
|
||||
|
||||
it('handles body is', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'body', comparator: 'is', value: 'exact' }],
|
||||
})]);
|
||||
expect(script).toContain('body :is "exact"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparators', () => {
|
||||
it('generates not_contains with not wrapper', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }],
|
||||
})]);
|
||||
expect(script).toContain('not header :contains "From" "spam"');
|
||||
});
|
||||
|
||||
it('generates not_is with not wrapper', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }],
|
||||
})]);
|
||||
expect(script).toContain('not header :is "From" "bad@x.com"');
|
||||
});
|
||||
|
||||
it('generates starts_with as :matches with trailing *', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }],
|
||||
})]);
|
||||
expect(script).toContain('header :matches "Subject" "Re:*"');
|
||||
});
|
||||
|
||||
it('generates ends_with as :matches with leading *', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }],
|
||||
})]);
|
||||
expect(script).toContain('header :matches "Subject" "*urgent"');
|
||||
});
|
||||
|
||||
it('generates matches as :matches', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }],
|
||||
})]);
|
||||
expect(script).toContain('header :matches "From" "*@company.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('match types', () => {
|
||||
it('wraps multiple conditions with allof for matchType all', () => {
|
||||
const script = generateScript([makeRule({
|
||||
matchType: 'all',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'a' },
|
||||
{ field: 'subject', comparator: 'contains', value: 'b' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")');
|
||||
});
|
||||
|
||||
it('wraps multiple conditions with anyof for matchType any', () => {
|
||||
const script = generateScript([makeRule({
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'x' },
|
||||
{ field: 'to', comparator: 'contains', value: 'y' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")');
|
||||
});
|
||||
|
||||
it('uses no wrapper for single condition', () => {
|
||||
const script = generateScript([makeRule()]);
|
||||
expect(script).not.toContain('allof');
|
||||
expect(script).not.toContain('anyof');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('generates move as fileinto', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]);
|
||||
expect(script).toContain('fileinto "Spam";');
|
||||
});
|
||||
|
||||
it('generates copy as fileinto :copy', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]);
|
||||
expect(script).toContain('fileinto :copy "Backup";');
|
||||
});
|
||||
|
||||
it('generates forward as redirect', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]);
|
||||
expect(script).toContain('redirect "fwd@x.com";');
|
||||
});
|
||||
|
||||
it('generates mark_read as addflag \\Seen', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
});
|
||||
|
||||
it('generates star as addflag \\Flagged', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
|
||||
expect(script).toContain('addflag "\\\\Flagged";');
|
||||
});
|
||||
|
||||
it('generates add_label as addflag $label:Label', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
|
||||
expect(script).toContain('addflag "$label:Important";');
|
||||
});
|
||||
|
||||
it('generates discard', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]);
|
||||
expect(script).toContain('discard;');
|
||||
});
|
||||
|
||||
it('generates reject with message', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]);
|
||||
expect(script).toContain('reject "Go away";');
|
||||
});
|
||||
|
||||
it('generates keep', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
|
||||
expect(script).toContain('keep;');
|
||||
});
|
||||
|
||||
it('generates stop', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]);
|
||||
expect(script).toContain('stop;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProcessing', () => {
|
||||
it('appends stop when stopProcessing is true', () => {
|
||||
const script = generateScript([makeRule({ stopProcessing: true })]);
|
||||
const ifBlock = script.slice(script.indexOf('if '));
|
||||
expect(ifBlock).toContain('stop;');
|
||||
});
|
||||
|
||||
it('does not duplicate stop if last action is stop', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'move', value: 'X' }, { type: 'stop' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not append stop after discard', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'discard' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toBeNull();
|
||||
});
|
||||
|
||||
it('does not append stop after reject', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'reject', value: 'No' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled rules', () => {
|
||||
it('excludes disabled rules from Sieve code', () => {
|
||||
const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]);
|
||||
expect(script).not.toContain('# Rule: Hidden');
|
||||
expect(script).not.toContain('if header');
|
||||
});
|
||||
|
||||
it('preserves disabled rules in metadata', () => {
|
||||
const rules = [makeRule({ enabled: false })];
|
||||
const script = generateScript(rules);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.rules[0].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles mixed enabled and disabled rules', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', name: 'Active', enabled: true }),
|
||||
makeRule({ id: '2', name: 'Inactive', enabled: false }),
|
||||
makeRule({ id: '3', name: 'Also Active', enabled: true }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
expect(script).toContain('# Rule: Active');
|
||||
expect(script).not.toContain('# Rule: Inactive');
|
||||
expect(script).toContain('# Rule: Also Active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('require extensions', () => {
|
||||
it('includes fileinto for move', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]);
|
||||
expect(script).toContain('"fileinto"');
|
||||
});
|
||||
|
||||
it('includes fileinto and copy for copy action', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]);
|
||||
expect(script).toContain('"copy"');
|
||||
expect(script).toContain('"fileinto"');
|
||||
});
|
||||
|
||||
it('includes imap4flags for mark_read', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
|
||||
expect(script).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('includes imap4flags for star', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
|
||||
expect(script).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('includes imap4flags for add_label', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]);
|
||||
expect(script).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('includes reject for reject action', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]);
|
||||
expect(script).toContain('"reject"');
|
||||
});
|
||||
|
||||
it('includes body extension for body conditions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'body', comparator: 'contains', value: 'test' }],
|
||||
})]);
|
||||
expect(script).toContain('"body"');
|
||||
});
|
||||
|
||||
it('deduplicates extensions', () => {
|
||||
const script = generateScript([
|
||||
makeRule({ id: '1', actions: [{ type: 'star' }] }),
|
||||
makeRule({ id: '2', actions: [{ type: 'mark_read' }] }),
|
||||
]);
|
||||
const matches = script.match(/"imap4flags"/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('only considers enabled rules for requires', () => {
|
||||
const script = generateScript([
|
||||
makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }),
|
||||
makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }),
|
||||
]);
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'));
|
||||
expect(requireLine).not.toContain('reject');
|
||||
expect(requireLine).toContain('fileinto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaping', () => {
|
||||
it('escapes double quotes in values', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }],
|
||||
})]);
|
||||
expect(script).toContain('say \\"hello\\"');
|
||||
});
|
||||
|
||||
it('escapes backslashes in values', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }],
|
||||
})]);
|
||||
expect(script).toContain('path\\\\to\\\\file');
|
||||
});
|
||||
|
||||
it('escapes folder names in actions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'move', value: 'My "Folder"' }],
|
||||
})]);
|
||||
expect(script).toContain('fileinto "My \\"Folder\\"";');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves rules through generate → parse cycle', () => {
|
||||
const rules: FilterRule[] = [
|
||||
makeRule({ id: '1', name: 'Rule A', enabled: true }),
|
||||
makeRule({ id: '2', name: 'Rule B', enabled: false }),
|
||||
makeRule({
|
||||
id: '3',
|
||||
name: 'Complex',
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'boss' },
|
||||
{ field: 'subject', comparator: 'starts_with', value: 'URGENT' },
|
||||
],
|
||||
actions: [{ type: 'star' }, { type: 'mark_read' }],
|
||||
stopProcessing: true,
|
||||
}),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
it('generates multiple rules in order', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }),
|
||||
makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const firstIdx = script.indexOf('# Rule: First');
|
||||
const secondIdx = script.indexOf('# Rule: Second');
|
||||
expect(firstIdx).toBeLessThan(secondIdx);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('skips rules with empty conditions', () => {
|
||||
const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]);
|
||||
expect(script).not.toContain('# Rule: Empty');
|
||||
expect(script).not.toContain('if ');
|
||||
});
|
||||
|
||||
it('skips rules with empty actions', () => {
|
||||
const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]);
|
||||
expect(script).not.toContain('# Rule: NoAction');
|
||||
expect(script).not.toContain('if ');
|
||||
});
|
||||
|
||||
it('uses X-Unknown for header field without headerName', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "X-Unknown" "test"');
|
||||
});
|
||||
|
||||
it('handles all enabled rules with different extensions combined', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }),
|
||||
makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }),
|
||||
makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
||||
expect(requireLine).toContain('"fileinto"');
|
||||
expect(requireLine).toContain('"reject"');
|
||||
expect(requireLine).toContain('"body"');
|
||||
expect(requireLine).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('generates no require line when only keep/discard/stop/forward actions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }],
|
||||
})]);
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
|
||||
it('handles multiple actions on same rule', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [
|
||||
{ type: 'move', value: 'Folder' },
|
||||
{ type: 'mark_read' },
|
||||
{ type: 'star' },
|
||||
{ type: 'stop' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('fileinto "Folder";');
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
expect(script).toContain('addflag "\\\\Flagged";');
|
||||
expect(script).toContain('stop;');
|
||||
});
|
||||
|
||||
it('generates valid script for all-disabled rules', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', enabled: false }),
|
||||
makeRule({ id: '2', enabled: false }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
expect(script).toContain('@metadata:begin');
|
||||
expect(script).not.toContain('if ');
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseScript } from '../parser';
|
||||
import { generateScript } from '../generator';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
name: 'Test Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseScript', () => {
|
||||
it('extracts rules from valid metadata', () => {
|
||||
const rules = [makeRule()];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('parses external rules when no Bulwark metadata is present', () => {
|
||||
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].origin).toBe('external');
|
||||
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
|
||||
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
|
||||
expect(result.externalRequires).toContain('fileinto');
|
||||
});
|
||||
|
||||
it('returns isOpaque for corrupted JSON', () => {
|
||||
const script = '/* @metadata:begin\n{not valid json\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for version mismatch', () => {
|
||||
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for empty metadata block', () => {
|
||||
const script = '/* @metadata:begin\n\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for missing rules array', () => {
|
||||
const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for invalid rule objects', () => {
|
||||
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles metadata with extra whitespace', () => {
|
||||
const rules = [makeRule()];
|
||||
const json = JSON.stringify({ version: 1, rules });
|
||||
const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('handles script with only metadata block', () => {
|
||||
const rules = [makeRule({ enabled: false })];
|
||||
const json = JSON.stringify({ version: 1, rules });
|
||||
const script = `/* @metadata:begin\n${json}\n@metadata:end */`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('returns isOpaque for missing end marker', () => {
|
||||
const script = '/* @metadata:begin\n{"version":1,"rules":[]}';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty string as an empty, editable script (not opaque)', () => {
|
||||
const result = parseScript('');
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves complex rules through generate → parse', () => {
|
||||
const rules: FilterRule[] = [
|
||||
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
|
||||
makeRule({
|
||||
id: '2',
|
||||
name: 'VIP',
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'is', value: 'boss@company.com' },
|
||||
{ field: 'from', comparator: 'is', value: 'ceo@company.com' },
|
||||
],
|
||||
actions: [{ type: 'star' }, { type: 'mark_read' }],
|
||||
}),
|
||||
makeRule({ id: '3', name: 'Disabled', enabled: false }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves rules with special characters', () => {
|
||||
const rules = [makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }],
|
||||
actions: [{ type: 'move', value: 'My "Folder"' }],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves all action types', () => {
|
||||
const rules = [makeRule({
|
||||
actions: [
|
||||
{ type: 'move', value: 'Folder' },
|
||||
{ type: 'copy', value: 'Backup' },
|
||||
{ type: 'forward', value: 'fwd@x.com' },
|
||||
{ type: 'mark_read' },
|
||||
{ type: 'star' },
|
||||
{ type: 'add_label', value: 'Tag' },
|
||||
{ type: 'keep' },
|
||||
],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation edge cases', () => {
|
||||
it('returns isOpaque when rule has non-string id', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when rule has non-boolean enabled', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when rule has invalid matchType', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when condition missing value', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when action missing type', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid empty rules array', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves all comparator types through round-trip', () => {
|
||||
const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const;
|
||||
const rules = comparators.map((comparator, i) => makeRule({
|
||||
id: `r${i}`,
|
||||
name: `Rule ${comparator}`,
|
||||
conditions: [{ field: 'from', comparator, value: 'test' }],
|
||||
}));
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves size comparators through round-trip', () => {
|
||||
const rules = [
|
||||
makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }),
|
||||
makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves header field with custom headerName', () => {
|
||||
const rules = [makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
FilterRule,
|
||||
FilterCondition,
|
||||
FilterAction,
|
||||
FilterMetadata,
|
||||
VacationSieveConfig,
|
||||
} from './types';
|
||||
|
||||
// Ported from the webmail's lib/sieve/generator.ts. Emits an RFC 5228 Sieve
|
||||
// script with a leading `@metadata` JSON comment that lets the parser round-trip
|
||||
// the visual rules losslessly. Keep this in sync with the web generator so a
|
||||
// script saved on one client reads back cleanly on the other.
|
||||
|
||||
const HEADER_MAP: Record<string, string> = {
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
cc: 'Cc',
|
||||
subject: 'Subject',
|
||||
};
|
||||
|
||||
function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
if (field === 'size') {
|
||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||
return `size ${op} ${value}`;
|
||||
}
|
||||
|
||||
if (field === 'body') {
|
||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
||||
return `body ${matchType} "${escapeString(value)}"`;
|
||||
}
|
||||
|
||||
const headerName = field === 'header'
|
||||
? (condition.headerName || 'X-Unknown')
|
||||
: HEADER_MAP[field];
|
||||
|
||||
const escaped = escapeString(value);
|
||||
|
||||
switch (comparator) {
|
||||
case 'contains':
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
case 'not_contains':
|
||||
return `not header :contains "${headerName}" "${escaped}"`;
|
||||
case 'is':
|
||||
return `header :is "${headerName}" "${escaped}"`;
|
||||
case 'not_is':
|
||||
return `not header :is "${headerName}" "${escaped}"`;
|
||||
case 'starts_with':
|
||||
return `header :matches "${headerName}" "${escaped}*"`;
|
||||
case 'ends_with':
|
||||
return `header :matches "${headerName}" "*${escaped}"`;
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" "${escaped}"`;
|
||||
default:
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
}
|
||||
}
|
||||
|
||||
function generateActions(actions: FilterAction[]): string[] {
|
||||
return actions.map(action => {
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
return `fileinto "${escapeString(action.value || '')}";`;
|
||||
case 'copy':
|
||||
return `fileinto :copy "${escapeString(action.value || '')}";`;
|
||||
case 'forward':
|
||||
return `redirect "${escapeString(action.value || '')}";`;
|
||||
case 'mark_read':
|
||||
return 'addflag "\\\\Seen";';
|
||||
case 'star':
|
||||
return 'addflag "\\\\Flagged";';
|
||||
case 'add_label':
|
||||
return `addflag "$label:${escapeString(action.value || '')}";`;
|
||||
case 'discard':
|
||||
return 'discard;';
|
||||
case 'reject':
|
||||
return `reject "${escapeString(action.value || '')}";`;
|
||||
case 'keep':
|
||||
return 'keep;';
|
||||
case 'stop':
|
||||
return 'stop;';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): string[] {
|
||||
const extensions = new Set<string>();
|
||||
const enabledRules = rules.filter(r => r.enabled);
|
||||
|
||||
if (vacation?.isEnabled) {
|
||||
extensions.add('vacation');
|
||||
}
|
||||
|
||||
for (const rule of enabledRules) {
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
extensions.add('fileinto');
|
||||
break;
|
||||
case 'copy':
|
||||
extensions.add('fileinto');
|
||||
extensions.add('copy');
|
||||
break;
|
||||
case 'mark_read':
|
||||
case 'star':
|
||||
case 'add_label':
|
||||
extensions.add('imap4flags');
|
||||
break;
|
||||
case 'reject':
|
||||
extensions.add('reject');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...extensions];
|
||||
}
|
||||
|
||||
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
enabled: r.enabled,
|
||||
matchType: r.matchType,
|
||||
conditions: r.conditions,
|
||||
actions: r.actions,
|
||||
stopProcessing: r.stopProcessing,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
/**
|
||||
* Require extensions used by external (non-Bulwark) rules that we must
|
||||
* preserve in the top-level `require` directive. Duplicates with Bulwark's
|
||||
* own requires are deduplicated.
|
||||
*/
|
||||
externalRequires?: string[];
|
||||
}
|
||||
|
||||
export function generateScript(
|
||||
rules: FilterRule[],
|
||||
vacation?: VacationSieveConfig,
|
||||
options: GenerateOptions = {},
|
||||
): string {
|
||||
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
|
||||
const bulwarkRules: FilterRule[] = [];
|
||||
const externalRules: FilterRule[] = [];
|
||||
for (const r of rules) {
|
||||
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
|
||||
else bulwarkRules.push(r);
|
||||
}
|
||||
|
||||
const metadata: FilterMetadata = {
|
||||
version: 1,
|
||||
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
|
||||
};
|
||||
if (vacation?.isEnabled) {
|
||||
metadata.vacation = vacation;
|
||||
}
|
||||
const metadataJson = JSON.stringify(metadata);
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('/* @metadata:begin');
|
||||
lines.push(metadataJson);
|
||||
lines.push('@metadata:end */');
|
||||
lines.push('');
|
||||
|
||||
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
|
||||
const externalRequires = options.externalRequires ?? [];
|
||||
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
|
||||
|
||||
if (allRequires.length > 0) {
|
||||
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
|
||||
}
|
||||
|
||||
if (vacation?.isEnabled) {
|
||||
lines.push('');
|
||||
lines.push('# Vacation auto-reply');
|
||||
const vacationParts: string[] = [];
|
||||
if (vacation.subject) {
|
||||
vacationParts.push(`:subject "${escapeString(vacation.subject)}"`);
|
||||
}
|
||||
vacationParts.push(`"${escapeString(vacation.textBody || '')}"`);
|
||||
lines.push(`vacation ${vacationParts.join(' ')};`);
|
||||
}
|
||||
|
||||
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
|
||||
|
||||
for (const rule of enabledBulwarkRules) {
|
||||
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
||||
// Empty conditions/actions can't form a valid `if`; the rule modal
|
||||
// prevents this, but skip defensively rather than emit broken Sieve.
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`# Rule: ${rule.name}`);
|
||||
|
||||
const conditions = rule.conditions.map(generateCondition);
|
||||
let conditionStr: string;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
conditionStr = 'true';
|
||||
} else if (conditions.length === 1) {
|
||||
conditionStr = conditions[0];
|
||||
} else {
|
||||
const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof';
|
||||
conditionStr = `${wrapper}(${conditions.join(', ')})`;
|
||||
}
|
||||
|
||||
const actionLines = generateActions(rule.actions);
|
||||
|
||||
if (rule.stopProcessing) {
|
||||
const lastAction = rule.actions[rule.actions.length - 1];
|
||||
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
|
||||
actionLines.push('stop;');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`if ${conditionStr} {`);
|
||||
for (const actionLine of actionLines) {
|
||||
lines.push(` ${actionLine}`);
|
||||
}
|
||||
lines.push('}');
|
||||
}
|
||||
|
||||
// Append preserved external rules verbatim. Each rawBlock already carries its
|
||||
// own leading comments and trailing whitespace from the source script.
|
||||
if (externalRules.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('# --- External rules (managed outside Bulwark) ---');
|
||||
for (const ext of externalRules) {
|
||||
if (!ext.rawBlock) continue;
|
||||
lines.push(ext.rawBlock.replace(/\s+$/, ''));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
import type {
|
||||
FilterAction,
|
||||
FilterCondition,
|
||||
FilterComparator,
|
||||
FilterConditionField,
|
||||
FilterMetadata,
|
||||
FilterRule,
|
||||
VacationSieveConfig,
|
||||
} from './types';
|
||||
|
||||
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
|
||||
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
|
||||
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
|
||||
// are best-effort parsed into read-only "external" rules or preserved opaquely.
|
||||
|
||||
export interface ParseResult {
|
||||
rules: FilterRule[];
|
||||
isOpaque: boolean;
|
||||
vacation?: VacationSieveConfig;
|
||||
externalRequires: string[];
|
||||
}
|
||||
|
||||
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
|
||||
|
||||
const METADATA_BEGIN = '/* @metadata:begin';
|
||||
const METADATA_END = '@metadata:end */';
|
||||
|
||||
const NEXTCLOUD_BLOCK_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
|
||||
|
||||
const BULWARK_EXTERNAL_HEADER_RE =
|
||||
/^[ \t]*#[ \t]*---[ \t]*External rules \(managed outside Bulwark\)[ \t]*---[ \t]*\r?\n/m;
|
||||
|
||||
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||
from: 'from',
|
||||
to: 'to',
|
||||
cc: 'cc',
|
||||
subject: 'subject',
|
||||
};
|
||||
|
||||
function isValidCondition(c: unknown): boolean {
|
||||
if (!c || typeof c !== 'object') return false;
|
||||
const cond = c as Record<string, unknown>;
|
||||
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
|
||||
}
|
||||
|
||||
function isValidAction(a: unknown): boolean {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const act = a as Record<string, unknown>;
|
||||
return typeof act.type === 'string';
|
||||
}
|
||||
|
||||
function isValidRule(rule: unknown): rule is FilterRule {
|
||||
if (!rule || typeof rule !== 'object') return false;
|
||||
const r = rule as Record<string, unknown>;
|
||||
if (
|
||||
typeof r.id !== 'string' ||
|
||||
typeof r.name !== 'string' ||
|
||||
typeof r.enabled !== 'boolean' ||
|
||||
(r.matchType !== 'all' && r.matchType !== 'any') ||
|
||||
!Array.isArray(r.conditions) ||
|
||||
!Array.isArray(r.actions) ||
|
||||
typeof r.stopProcessing !== 'boolean'
|
||||
) return false;
|
||||
|
||||
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Stalwart-generated vacation-only scripts (no metadata).
|
||||
*/
|
||||
function detectVacationOnlyScript(content: string): ParseResult | null {
|
||||
if (!/\bvacation\b/.test(content)) return null;
|
||||
|
||||
const stripped = content
|
||||
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
|
||||
.replace(/#[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
|
||||
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
||||
|
||||
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
|
||||
if (!/\bvacation\b/.test(structural)) return null;
|
||||
|
||||
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
|
||||
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
|
||||
|
||||
let textBody = '';
|
||||
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
|
||||
if (mimeBodyMatch) {
|
||||
textBody = mimeBodyMatch[1].trim();
|
||||
} else {
|
||||
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
|
||||
const last = allQuoted[allQuoted.length - 1];
|
||||
if (last) textBody = unescapeSieveString(last[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
rules: [],
|
||||
isOpaque: false,
|
||||
vacation: { isEnabled: true, subject, textBody },
|
||||
externalRequires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function unescapeSieveString(s: string): string {
|
||||
return s.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
function skipStringLit(s: string, i: number): number {
|
||||
i++;
|
||||
while (i < s.length) {
|
||||
if (s[i] === '\\') { i += 2; continue; }
|
||||
if (s[i] === '"') return i + 1;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipHashComment(s: string, i: number): number {
|
||||
while (i < s.length && s[i] !== '\n') i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipBlockComment(s: string, i: number): number {
|
||||
const end = s.indexOf('*/', i + 2);
|
||||
return end === -1 ? s.length : end + 2;
|
||||
}
|
||||
|
||||
function skipStatement(s: string, i: number): number {
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
if (c === ';') return i + 1;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipBalanced(s: string, i: number, open: string, close: string): number {
|
||||
let depth = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
if (c === open) { depth++; i++; continue; }
|
||||
if (c === close) {
|
||||
depth--;
|
||||
i++;
|
||||
if (depth === 0) return i;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipIfStatement(s: string, i: number): number {
|
||||
// positioned after 'if' keyword; skip through condition expression and body braces
|
||||
while (i < s.length && s[i] !== '{') {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
i++;
|
||||
}
|
||||
if (i >= s.length) return i;
|
||||
return skipBalanced(s, i, '{', '}');
|
||||
}
|
||||
|
||||
interface TopBlock {
|
||||
kind: 'require' | 'if' | 'vacation' | 'other';
|
||||
raw: string; // from start-of-leading-text to end of statement
|
||||
statement: string; // the statement itself (no leading comments/whitespace)
|
||||
startIdx: number;
|
||||
endIdx: number;
|
||||
}
|
||||
|
||||
function scanTopLevel(content: string): TopBlock[] {
|
||||
const blocks: TopBlock[] = [];
|
||||
let i = 0;
|
||||
let segmentStart = 0;
|
||||
|
||||
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
|
||||
blocks.push({
|
||||
kind,
|
||||
raw: content.slice(segmentStart, stmtEnd),
|
||||
statement: content.slice(stmtStart, stmtEnd),
|
||||
startIdx: segmentStart,
|
||||
endIdx: stmtEnd,
|
||||
});
|
||||
segmentStart = stmtEnd;
|
||||
};
|
||||
|
||||
while (i < content.length) {
|
||||
// Skip whitespace
|
||||
while (i < content.length && /\s/.test(content[i])) i++;
|
||||
if (i >= content.length) break;
|
||||
|
||||
const c = content[i];
|
||||
|
||||
// Comments (stay attached to next block as leading text)
|
||||
if (c === '#') { i = skipHashComment(content, i); continue; }
|
||||
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
|
||||
|
||||
// Identifier
|
||||
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
|
||||
if (!m) { i++; continue; }
|
||||
|
||||
const ident = m[0];
|
||||
const stmtStart = i;
|
||||
i += ident.length;
|
||||
|
||||
if (ident === 'require') {
|
||||
i = skipStatement(content, i);
|
||||
consume('require', stmtStart, i);
|
||||
} else if (ident === 'if') {
|
||||
i = skipIfStatement(content, i);
|
||||
consume('if', stmtStart, i);
|
||||
} else if (ident === 'vacation') {
|
||||
i = skipStatement(content, i);
|
||||
consume('vacation', stmtStart, i);
|
||||
} else {
|
||||
i = skipStatement(content, i);
|
||||
consume('other', stmtStart, i);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractRequireTokens(stmt: string): string[] {
|
||||
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
|
||||
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
|
||||
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
|
||||
return mSingle ? [mSingle[1]] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last contiguous block of comments immediately preceding a
|
||||
* statement - comments separated from the statement by a blank line are not
|
||||
* considered its leading commentary (they likely belong to the previous
|
||||
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
|
||||
*/
|
||||
function lastCommentChunk(leading: string): string {
|
||||
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||
return parts.length ? parts[parts.length - 1] : '';
|
||||
}
|
||||
|
||||
function detectOriginLabel(leading: string): string {
|
||||
const chunk = lastCommentChunk(leading);
|
||||
const lower = chunk.toLowerCase();
|
||||
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
|
||||
if (/nextcloud/.test(lower)) return 'Nextcloud';
|
||||
if (/horde|ingo/.test(lower)) return 'Horde';
|
||||
if (/kolab/.test(lower)) return 'Kolab';
|
||||
if (/dovecot/.test(lower)) return 'Dovecot';
|
||||
if (/thunderbird/.test(lower)) return 'Thunderbird';
|
||||
return 'External';
|
||||
}
|
||||
|
||||
function extractName(leading: string, fallback: string): string {
|
||||
// Roundcube: "# rule:[Name]"
|
||||
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
|
||||
if (rc) return rc[1].trim();
|
||||
|
||||
// "# Rule: Name"
|
||||
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
|
||||
if (rr) return rr[1].trim();
|
||||
|
||||
// Last non-empty trimmed comment line
|
||||
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function splitTopLevelComma(s: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
|
||||
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
|
||||
if (c === ',' && depth === 0) {
|
||||
parts.push(s.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
parts.push(s.slice(start));
|
||||
return parts.map(p => p.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function splitStatements(body: string): string[] {
|
||||
const stmts: string[] = [];
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
while (i < body.length) {
|
||||
const c = body[i];
|
||||
if (c === '"') { i = skipStringLit(body, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(body, i); continue; }
|
||||
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
|
||||
if (c === ';') {
|
||||
stmts.push(body.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const tail = body.slice(start).trim();
|
||||
if (tail) stmts.push(tail);
|
||||
return stmts.map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
|
||||
const lc = name.toLowerCase();
|
||||
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
|
||||
return { field: 'header', headerName: name };
|
||||
}
|
||||
|
||||
function parseAtom(raw: string): FilterCondition | null {
|
||||
let s = raw.trim();
|
||||
let negated = false;
|
||||
|
||||
if (/^not\b/.test(s)) {
|
||||
negated = true;
|
||||
s = s.replace(/^not\s*/, '').trim();
|
||||
if (s.startsWith('(') && s.endsWith(')')) {
|
||||
s = s.slice(1, -1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawValue] = m;
|
||||
const value = unescapeSieveString(rawValue);
|
||||
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||
|
||||
let comparator: FilterComparator;
|
||||
if (tag === 'contains') {
|
||||
comparator = negated ? 'not_contains' : 'contains';
|
||||
} else if (tag === 'is') {
|
||||
comparator = negated ? 'not_is' : 'is';
|
||||
} else {
|
||||
// :matches - distinguish starts_with / ends_with / matches
|
||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||
comparator = 'starts_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
if (starPositions.length === 1 && starPositions[0] === 0) {
|
||||
comparator = 'ends_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
comparator = 'matches';
|
||||
}
|
||||
|
||||
const cond: FilterCondition = { field, comparator, value };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
|
||||
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
|
||||
}
|
||||
|
||||
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
|
||||
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
|
||||
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
|
||||
let matchType: 'all' | 'any' = 'all';
|
||||
let inner: string;
|
||||
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
|
||||
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
|
||||
else inner = s;
|
||||
|
||||
const parts = splitTopLevelComma(inner);
|
||||
const conditions: FilterCondition[] = [];
|
||||
for (const part of parts) {
|
||||
const atom = parseAtom(part);
|
||||
if (!atom) return null;
|
||||
conditions.push(atom);
|
||||
}
|
||||
return { matchType, conditions };
|
||||
}
|
||||
|
||||
function parseAction(raw: string): FilterAction | null {
|
||||
const s = raw.trim();
|
||||
|
||||
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
|
||||
|
||||
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
|
||||
|
||||
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
|
||||
|
||||
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
const flag = unescapeSieveString(m[1]);
|
||||
if (flag === '\\Seen') return { type: 'mark_read' };
|
||||
if (flag === '\\Flagged') return { type: 'star' };
|
||||
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
|
||||
return null;
|
||||
}
|
||||
|
||||
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
|
||||
|
||||
if (/^discard$/.test(s)) return { type: 'discard' };
|
||||
if (/^keep$/.test(s)) return { type: 'keep' };
|
||||
if (/^stop$/.test(s)) return { type: 'stop' };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findBodyOpenBrace(s: string): number {
|
||||
// Locate the first `{` that introduces the if-block body, skipping over
|
||||
// string literals and comments. A naive indexOf('{') would otherwise pick
|
||||
// up braces inside condition values (e.g. `:contains "{foo}"`).
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
if (c === '{') return i;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
|
||||
const stmt = block.statement;
|
||||
const afterIf = stmt.replace(/^if\s+/, '');
|
||||
const braceIdx = findBodyOpenBrace(afterIf);
|
||||
// scanTopLevel's skipIfStatement uses balanced-brace scanning, so the
|
||||
// statement always terminates at the matching `}`.
|
||||
const lastBraceIdx = afterIf.length - 1;
|
||||
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
|
||||
|
||||
const condStr = afterIf.slice(0, braceIdx).trim();
|
||||
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
|
||||
|
||||
const cond = parseCondition(condStr);
|
||||
if (!cond || cond.conditions.length === 0) return null;
|
||||
|
||||
const actionStmts = splitStatements(bodyStr);
|
||||
const actions: FilterAction[] = [];
|
||||
for (const st of actionStmts) {
|
||||
const a = parseAction(st);
|
||||
if (!a) return null;
|
||||
actions.push(a);
|
||||
}
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
let stopProcessing = false;
|
||||
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
|
||||
const hasNonStop = actions.some(a => a.type !== 'stop');
|
||||
if (hasNonStop) {
|
||||
stopProcessing = true;
|
||||
actions.pop();
|
||||
}
|
||||
}
|
||||
|
||||
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
|
||||
const originLabel = detectOriginLabel(leading);
|
||||
const name = extractName(leading, `Rule ${index + 1}`);
|
||||
|
||||
return {
|
||||
id: `${idPrefix}-${index}`,
|
||||
name,
|
||||
enabled: true,
|
||||
matchType: cond.matchType,
|
||||
conditions: cond.conditions,
|
||||
actions,
|
||||
stopProcessing,
|
||||
origin: 'external',
|
||||
originLabel,
|
||||
rawBlock: block.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
|
||||
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
|
||||
const originLabel = detectOriginLabel(leading);
|
||||
const name = extractName(leading, `External rule ${index + 1}`);
|
||||
return {
|
||||
id: `${idPrefix}-${index}`,
|
||||
name,
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
stopProcessing: false,
|
||||
origin: 'opaque',
|
||||
originLabel,
|
||||
rawBlock: block.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nextcloud Mail wraps its managed filter region with a pair of
|
||||
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
|
||||
* emits two such regions - one enclosing its own `require [...]` line and
|
||||
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
|
||||
* JSON comments. Parsing the interior blocks individually loses the outer
|
||||
* markers (causing later rules to fall back to "External") and mis-attaches
|
||||
* them to neighboring blocks on round-trip.
|
||||
*
|
||||
* Treat each marker-pair as one opaque external rule so the whole region
|
||||
* round-trips verbatim, merge any require tokens into the top-level list,
|
||||
* and drop regions that carry nothing but a require (their extensions are
|
||||
* already represented in the merged require line).
|
||||
*/
|
||||
function extractNextcloudRegions(content: string): {
|
||||
cleaned: string;
|
||||
rules: FilterRule[];
|
||||
requires: string[];
|
||||
} {
|
||||
const marker = NEXTCLOUD_BLOCK_MARKER;
|
||||
const positions: number[] = [];
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(marker, searchFrom);
|
||||
if (idx === -1) break;
|
||||
const atLineStart = idx === 0 || content[idx - 1] === '\n';
|
||||
if (atLineStart) positions.push(idx);
|
||||
searchFrom = idx + marker.length;
|
||||
}
|
||||
|
||||
if (positions.length < 2) {
|
||||
return { cleaned: content, rules: [], requires: [] };
|
||||
}
|
||||
|
||||
const rules: FilterRule[] = [];
|
||||
const requires: string[] = [];
|
||||
const markerRe = new RegExp(escapeRegex(marker), 'g');
|
||||
|
||||
let cleaned = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (let i = 0; i + 1 < positions.length; i += 2) {
|
||||
const start = positions[i];
|
||||
const closeStart = positions[i + 1];
|
||||
const lineEnd = content.indexOf('\n', closeStart + marker.length);
|
||||
const end = lineEnd === -1 ? content.length : lineEnd + 1;
|
||||
|
||||
cleaned += content.slice(cursor, start);
|
||||
const raw = content.slice(start, end);
|
||||
|
||||
for (const m of raw.matchAll(/require\s+\[([\s\S]*?)\]\s*;/g)) {
|
||||
for (const tok of m[1].matchAll(/"([^"]+)"/g)) {
|
||||
if (!requires.includes(tok[1])) requires.push(tok[1]);
|
||||
}
|
||||
}
|
||||
const singleReq = /require\s+"([^"]+)"\s*;/.exec(raw);
|
||||
if (singleReq && !requires.includes(singleReq[1])) requires.push(singleReq[1]);
|
||||
|
||||
const stripped = raw
|
||||
.replace(markerRe, '')
|
||||
.replace(/require\s+\[[\s\S]*?\]\s*;/g, '')
|
||||
.replace(/require\s+"[^"]+"\s*;/g, '')
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/#[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
|
||||
if (stripped.length > 0) {
|
||||
rules.push({
|
||||
id: `nextcloud-${rules.length}`,
|
||||
name: 'Nextcloud Mail filters',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
stopProcessing: false,
|
||||
origin: 'opaque',
|
||||
originLabel: 'Nextcloud',
|
||||
rawBlock: raw,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
cleaned += content.slice(cursor);
|
||||
return { cleaned, rules, requires };
|
||||
}
|
||||
|
||||
function stripBulwarkExternalHeader(content: string): string {
|
||||
return content.replace(BULWARK_EXTERNAL_HEADER_RE, '');
|
||||
}
|
||||
|
||||
function parseExternalRules(
|
||||
content: string,
|
||||
idPrefix: string,
|
||||
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
|
||||
const blocks = scanTopLevel(content);
|
||||
const rules: FilterRule[] = [];
|
||||
const externalRequires: string[] = [];
|
||||
let index = 0;
|
||||
let sawAnyStatement = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'require') {
|
||||
sawAnyStatement = true;
|
||||
for (const tok of extractRequireTokens(block.statement)) {
|
||||
if (!externalRequires.includes(tok)) externalRequires.push(tok);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.kind === 'if') {
|
||||
sawAnyStatement = true;
|
||||
const rule = parseIfBlockToRule(block, idPrefix, index);
|
||||
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// vacation/other: treat as opaque preserved block
|
||||
sawAnyStatement = true;
|
||||
rules.push(makeOpaqueRule(block, idPrefix, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return { rules, externalRequires, hasContent: sawAnyStatement };
|
||||
}
|
||||
|
||||
export function parseScript(content: string): ParseResult {
|
||||
const beginIdx = content.indexOf(METADATA_BEGIN);
|
||||
|
||||
if (beginIdx !== -1) {
|
||||
const endIdx = content.indexOf(METADATA_END, beginIdx);
|
||||
if (endIdx === -1) return OPAQUE;
|
||||
|
||||
const jsonStart = beginIdx + METADATA_BEGIN.length;
|
||||
const jsonStr = content.slice(jsonStart, endIdx).trim();
|
||||
|
||||
let metadata: FilterMetadata;
|
||||
try {
|
||||
metadata = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.warn('[filters] Failed to parse Sieve metadata JSON:', e);
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
if (!metadata || metadata.version !== 1) return OPAQUE;
|
||||
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
||||
|
||||
for (const rule of metadata.rules) {
|
||||
if (!isValidRule(rule)) return OPAQUE;
|
||||
}
|
||||
|
||||
// Scan the portion AFTER the metadata block for external rules. A prior
|
||||
// Bulwark save may have emitted its "External rules" header here; strip
|
||||
// it so it does not get re-attached to the first external rule's rawBlock
|
||||
// and written out twice on the next save.
|
||||
const afterMetadata = stripBulwarkExternalHeader(
|
||||
content.slice(endIdx + METADATA_END.length),
|
||||
);
|
||||
const nextcloud = extractNextcloudRegions(afterMetadata);
|
||||
const external = parseExternalRules(nextcloud.cleaned, 'ext');
|
||||
|
||||
// Parsed bulwark rules intentionally omit an explicit `origin` field so
|
||||
// round-trip equality with metadata-only callers holds. Absence of origin
|
||||
// is treated as 'bulwark' everywhere downstream.
|
||||
const bulwarkRules: FilterRule[] = metadata.rules;
|
||||
|
||||
const externalRequires = [
|
||||
...external.externalRequires,
|
||||
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
|
||||
];
|
||||
|
||||
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
|
||||
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
|
||||
// This applies regardless of whether the block parsed as a structured rule or
|
||||
// fell back to opaque - a Bulwark-emitted block may fail to round-trip cleanly
|
||||
// (e.g. a value with literal braces) but the `# Rule: <name>` marker still
|
||||
// identifies it as ours.
|
||||
const filteredExternal = external.rules.filter(r => {
|
||||
const raw = r.rawBlock || '';
|
||||
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
|
||||
if (match) {
|
||||
const name = match[1].trim();
|
||||
if (bulwarkRules.some(b => b.name === name)) return false;
|
||||
}
|
||||
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal],
|
||||
isOpaque: false,
|
||||
vacation: metadata.vacation,
|
||||
externalRequires,
|
||||
};
|
||||
}
|
||||
|
||||
// No metadata - check vacation-only first
|
||||
const vacationOnly = detectVacationOnlyScript(content);
|
||||
if (vacationOnly) return vacationOnly;
|
||||
|
||||
// Extract Nextcloud-managed marker regions first so their interior is not
|
||||
// parsed as a series of loose if-blocks (which would lose the outer markers
|
||||
// and mis-label later blocks as generic "External").
|
||||
const nextcloud = extractNextcloudRegions(content);
|
||||
const external = parseExternalRules(nextcloud.cleaned, 'ext');
|
||||
|
||||
const allRules = [...nextcloud.rules, ...external.rules];
|
||||
const allRequires = [
|
||||
...external.externalRequires,
|
||||
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
|
||||
];
|
||||
|
||||
if (!external.hasContent && allRules.length === 0) {
|
||||
// Entirely empty or whitespace/comments only - treat as empty, editable.
|
||||
// Preserve any require tokens lifted out of Nextcloud marker regions so
|
||||
// they can be re-emitted in the top-level require line.
|
||||
return { rules: [], isOpaque: false, externalRequires: allRequires };
|
||||
}
|
||||
|
||||
// If at least one block parsed into a structured rule, expose them as external.
|
||||
const anyParsed = external.rules.some(r => r.origin === 'external');
|
||||
if (anyParsed || allRules.length > 0) {
|
||||
return { rules: allRules, isOpaque: false, externalRequires: allRequires };
|
||||
}
|
||||
|
||||
return OPAQUE;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Sieve filter data model. Ported from the webmail's lib/jmap/sieve-types.ts so
|
||||
// the generated scripts round-trip byte-for-byte between the two clients (a
|
||||
// rule authored on the web reads back as a structured rule on mobile and vice
|
||||
// versa, because both embed the same `@metadata` JSON block).
|
||||
|
||||
export interface SieveScript {
|
||||
id: string;
|
||||
name: string;
|
||||
blobId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SieveCapabilities {
|
||||
implementation: string;
|
||||
maxSizeScript: number;
|
||||
sieveExtensions: string[];
|
||||
notificationMethods: string[];
|
||||
externalLists: string[];
|
||||
}
|
||||
|
||||
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
|
||||
|
||||
export type FilterComparator =
|
||||
| 'contains' | 'not_contains'
|
||||
| 'is' | 'not_is'
|
||||
| 'starts_with' | 'ends_with'
|
||||
| 'matches'
|
||||
| 'greater_than' | 'less_than';
|
||||
|
||||
export type FilterActionType =
|
||||
| 'move' | 'copy' | 'forward'
|
||||
| 'mark_read' | 'star' | 'add_label'
|
||||
| 'discard' | 'reject' | 'keep' | 'stop';
|
||||
|
||||
export interface FilterCondition {
|
||||
field: FilterConditionField;
|
||||
comparator: FilterComparator;
|
||||
value: string;
|
||||
headerName?: string;
|
||||
}
|
||||
|
||||
export interface FilterAction {
|
||||
type: FilterActionType;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
|
||||
|
||||
export interface FilterRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
matchType: 'all' | 'any';
|
||||
conditions: FilterCondition[];
|
||||
actions: FilterAction[];
|
||||
stopProcessing: boolean;
|
||||
origin?: FilterOrigin;
|
||||
originLabel?: string;
|
||||
rawBlock?: string;
|
||||
}
|
||||
|
||||
export interface VacationSieveConfig {
|
||||
isEnabled: boolean;
|
||||
subject: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
export interface FilterMetadata {
|
||||
version: 1;
|
||||
rules: FilterRule[];
|
||||
vacation?: VacationSieveConfig;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// TOTP enrolment helper. Mirrors the webmail's use of `otpauth` (Settings →
|
||||
// Security → Two-Factor) but without pulling in a native crypto dependency:
|
||||
// we only need to mint a fresh shared secret and the `otpauth://` enrolment
|
||||
// URL. The 6-digit code the user types is verified server-side by Stalwart
|
||||
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
|
||||
// device — that keeps this file dependency-free.
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
// 20 bytes = 160 bits, matching the webmail's `new OTPAuth.Secret({ size: 20 })`
|
||||
// and the SHA-1 HMAC block expectation of virtually every authenticator app.
|
||||
const SECRET_BYTES = 20;
|
||||
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
const c = (globalThis as {
|
||||
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array; randomUUID?: () => string };
|
||||
}).crypto;
|
||||
|
||||
// Best: a real CSPRNG (web / a polyfilled RN).
|
||||
if (c?.getRandomValues) {
|
||||
try {
|
||||
c.getRandomValues(bytes);
|
||||
return bytes;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Good enough: derive bytes from crypto.randomUUID, which Hermes exposes
|
||||
// even when getRandomValues isn't polyfilled (~122 bits of entropy each).
|
||||
if (c?.randomUUID) {
|
||||
try {
|
||||
let hex = '';
|
||||
while (hex.length < length * 2) hex += c.randomUUID().replace(/-/g, '');
|
||||
for (let i = 0; i < length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return bytes;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: Math.random. Same trade-off the rest of the app accepts in
|
||||
// lib/uuid.ts and lib/oauth.ts; the secret is one-off and server-validated.
|
||||
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// RFC 4648 base32, no padding — the form authenticator apps expect in the
|
||||
// `secret` query parameter.
|
||||
function base32Encode(bytes: Uint8Array): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
value = (value << 8) | bytes[i];
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface TotpEnrolment {
|
||||
// Base32 secret, shown to the user for manual entry.
|
||||
secret: string;
|
||||
// Same secret grouped in fours for easier manual typing.
|
||||
secretFormatted: string;
|
||||
// otpauth:// URL — feeds the "Add to authenticator app" deep link and the
|
||||
// value sent to the server as otpUrl.
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function generateTotpEnrolment(accountLabel: string): TotpEnrolment {
|
||||
const secret = base32Encode(randomBytes(SECRET_BYTES));
|
||||
const issuer = 'Stalwart';
|
||||
const label = accountLabel || 'account';
|
||||
const params = new URLSearchParams({
|
||||
secret,
|
||||
issuer,
|
||||
algorithm: 'SHA1',
|
||||
digits: '6',
|
||||
period: '30',
|
||||
});
|
||||
// Label is conventionally "Issuer:account"; both label path and issuer param
|
||||
// are encoded so authenticators that read either render a sensible name.
|
||||
const url = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?${params.toString()}`;
|
||||
const secretFormatted = secret.replace(/(.{4})/g, '$1 ').trim();
|
||||
return { secret, secretFormatted, url };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// RFC 4122 v4 UUID generator. Prefers the platform crypto.randomUUID when
|
||||
// available (Hermes/React Native 0.81 exposes it), falling back to a
|
||||
// Math.random implementation. These IDs are only used as temporary client-side
|
||||
// keys (e.g. parsed-vCard import rows) before the server assigns real ids, so
|
||||
// the Math.random fallback's weaker entropy is acceptable.
|
||||
export function generateUUID(): string {
|
||||
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
||||
if (c?.randomUUID) {
|
||||
try {
|
||||
return c.randomUUID();
|
||||
} catch {
|
||||
// fall through to the manual implementation
|
||||
}
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
+1203
-116
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import type { EmailAddress } from '../api/types';
|
||||
|
||||
export type RootStackParamList = {
|
||||
MainTabs: undefined;
|
||||
EmailThread: { emailId: string; threadId: string; subject?: string };
|
||||
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
|
||||
EmailSource: { emailId: string; blobId: string; subject?: string };
|
||||
Compose:
|
||||
| {
|
||||
@@ -13,6 +13,7 @@ export type RootStackParamList = {
|
||||
cc?: EmailAddress[];
|
||||
subject: string;
|
||||
body?: string;
|
||||
receivedAt?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
};
|
||||
@@ -23,6 +24,8 @@ export type RootStackParamList = {
|
||||
ContactForm: { contactId?: string; addressBookId?: string; asGroup?: boolean };
|
||||
GroupDetail: { groupId: string };
|
||||
AddAccount: undefined;
|
||||
Scheduled: undefined;
|
||||
UnifiedInbox: undefined;
|
||||
};
|
||||
|
||||
export type MainTabsParamList = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CalendarDays,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
ListChecks,
|
||||
Menu,
|
||||
} from 'lucide-react-native';
|
||||
import {
|
||||
@@ -47,11 +48,17 @@ import {
|
||||
type RecurrenceEditScope,
|
||||
} from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer';
|
||||
import { TasksSheet } from '../components/calendar/TasksSheet';
|
||||
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
|
||||
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
type EventDayIndex,
|
||||
} from '../lib/calendar-utils';
|
||||
import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'agenda';
|
||||
@@ -108,6 +115,9 @@ export default function CalendarScreen() {
|
||||
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
|
||||
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
|
||||
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = React.useState(new Date());
|
||||
@@ -125,6 +135,9 @@ export default function CalendarScreen() {
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = React.useState(false);
|
||||
const [tasksVisible, setTasksVisible] = React.useState(false);
|
||||
const [importVisible, setImportVisible] = React.useState(false);
|
||||
const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const hydrate = useCalendarStore((s) => s.hydrate);
|
||||
@@ -134,12 +147,36 @@ export default function CalendarScreen() {
|
||||
const createEvent = useCalendarStore((s) => s.createEvent);
|
||||
const updateEvent = useCalendarStore((s) => s.updateEvent);
|
||||
const deleteEvent = useCalendarStore((s) => s.deleteEvent);
|
||||
const rsvpEvent = useCalendarStore((s) => s.rsvpEvent);
|
||||
const importEvents = useCalendarStore((s) => s.importEvents);
|
||||
const tasks = useCalendarStore((s) => s.tasks);
|
||||
const createTask = useCalendarStore((s) => s.createTask);
|
||||
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
|
||||
const deleteTask = useCalendarStore((s) => s.deleteTask);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const allCalendars = useCalendarStore((s) => s.calendars);
|
||||
const storeCalendars = useCalendarStore((s) => s.calendars);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
const loading = useCalendarStore((s) => s.loading);
|
||||
const error = useCalendarStore((s) => s.error);
|
||||
const allEvents = useCalendarStore((s) => s.events);
|
||||
const storeEvents = useCalendarStore((s) => s.events);
|
||||
|
||||
// The birthday calendar is a client-side virtual calendar: its events are
|
||||
// generated from contacts for the visible range and merged in alongside the
|
||||
// server calendars. Toggling it on/off is instant (no refetch).
|
||||
const birthdayEvents = React.useMemo(() => {
|
||||
if (!showBirthdayCalendar) return [];
|
||||
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
|
||||
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
|
||||
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
|
||||
|
||||
const allCalendars = React.useMemo(
|
||||
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
|
||||
[storeCalendars, showBirthdayCalendar],
|
||||
);
|
||||
const allEvents = React.useMemo(
|
||||
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
|
||||
[storeEvents, birthdayEvents],
|
||||
);
|
||||
|
||||
const calendars = React.useMemo(
|
||||
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
|
||||
@@ -210,23 +247,30 @@ export default function CalendarScreen() {
|
||||
setModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const isReadOnlyEvent = React.useCallback(
|
||||
(event: CalendarEvent) => !!event.calendarIds?.[BIRTHDAY_CALENDAR_ID],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect]);
|
||||
}, [openEditDirect, isReadOnlyEvent]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent]);
|
||||
}, [deleteEvent, isReadOnlyEvent]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
@@ -293,6 +337,15 @@ export default function CalendarScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
{enableCalendarTasks && (
|
||||
<Pressable
|
||||
style={styles.headerBtn}
|
||||
onPress={() => setTasksVisible(true)}
|
||||
hitSlop={6}
|
||||
>
|
||||
<ListChecks size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.viewToggle}>
|
||||
{(['month', 'week', 'agenda'] as ViewMode[]).map((mode) => {
|
||||
const Icon =
|
||||
@@ -397,6 +450,20 @@ export default function CalendarScreen() {
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
onRsvp={async (ev, participantId, status) => {
|
||||
await rsvpEvent(ev.id, participantId, status, buildReplyTo(ev));
|
||||
setDetailEvent((cur) =>
|
||||
cur && cur.id === ev.id && cur.participants?.[participantId]
|
||||
? {
|
||||
...cur,
|
||||
participants: {
|
||||
...cur.participants,
|
||||
[participantId]: { ...cur.participants[participantId], participationStatus: status },
|
||||
},
|
||||
}
|
||||
: cur,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
@@ -422,6 +489,30 @@ export default function CalendarScreen() {
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
|
||||
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
|
||||
/>
|
||||
|
||||
<TasksSheet
|
||||
visible={tasksVisible}
|
||||
tasks={tasks}
|
||||
calendars={allCalendars}
|
||||
onClose={() => setTasksVisible(false)}
|
||||
onCreate={createTask}
|
||||
onToggle={toggleTaskComplete}
|
||||
onDelete={deleteTask}
|
||||
/>
|
||||
|
||||
<ICalImportSheet
|
||||
visible={importVisible}
|
||||
calendars={storeCalendars}
|
||||
onClose={() => setImportVisible(false)}
|
||||
onImport={importEvents}
|
||||
/>
|
||||
|
||||
<ICalSubscriptionSheet
|
||||
visible={subscriptionsVisible}
|
||||
onClose={() => setSubscriptionsVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
|
||||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||
Undo2, Redo2, FileText,
|
||||
Undo2, Redo2, FileText, Clock, Check,
|
||||
} from 'lucide-react-native';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { File as FsFile } from 'expo-file-system';
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from '../lib/contact-utils';
|
||||
import { getIdentities } from '../api/identity';
|
||||
import { sendEmail, type OutgoingAttachment } from '../api/email';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { uploadBlob } from '../api/blob';
|
||||
import { buildInitialHtml, htmlToPlainText, rewriteInlineImages } from '../lib/compose-html';
|
||||
import { stripDangerousTags } from '../lib/email-html';
|
||||
@@ -210,6 +212,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const insets = useSafeAreaInsets();
|
||||
// Track the visible keyboard obstruction so the format bar stays above it.
|
||||
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
|
||||
@@ -256,11 +260,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
|
||||
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
|
||||
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
|
||||
// can only show one field at a time, so we walk date → time.
|
||||
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
||||
const customDraftRef = React.useRef<Date>(new Date());
|
||||
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||
|
||||
const initialTo = React.useMemo<Recipient[]>(() => {
|
||||
if (!replyTo) {
|
||||
@@ -309,10 +319,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
cc: replyTo.cc,
|
||||
subject: replyTo.subject,
|
||||
body: replyTo.body,
|
||||
receivedAt: replyTo.receivedAt,
|
||||
}
|
||||
: null,
|
||||
{ timeFormat, locale, unknownLabel: t('common.unknown', 'Unknown') },
|
||||
),
|
||||
[mode, replyTo],
|
||||
[mode, replyTo, timeFormat, locale, t],
|
||||
);
|
||||
|
||||
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
||||
@@ -737,7 +749,96 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const onSend = async () => {
|
||||
// Translate an absolute "send at" time into the HOLDFOR seconds the server
|
||||
// expects, clamping to what it actually supports. Returns null (and alerts)
|
||||
// when scheduling isn't possible so the caller can abort.
|
||||
const resolveHoldForScheduledAt = (date: Date): number | null => {
|
||||
const seconds = Math.ceil((date.getTime() - Date.now()) / 1000);
|
||||
if (seconds <= 0) {
|
||||
Alert.alert(
|
||||
t('email_composer.schedule_past_title', 'Pick a future time'),
|
||||
t('email_composer.schedule_past_body', 'The scheduled time must be in the future.'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!jmapClient.hasDelayedSend()) {
|
||||
Alert.alert(
|
||||
t('email_composer.schedule_unsupported_title', 'Scheduling unavailable'),
|
||||
t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const max = jmapClient.getMaxDelayedSend();
|
||||
if (max > 0 && seconds > max) {
|
||||
Alert.alert(
|
||||
t('email_composer.schedule_too_late_title', 'Too far ahead'),
|
||||
t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return seconds;
|
||||
};
|
||||
|
||||
// The Send button: applies the global undo-send delay when the server
|
||||
// supports it, otherwise sends immediately.
|
||||
const onSend = () => {
|
||||
const holdFor = sendDelaySeconds > 0 && jmapClient.hasDelayedSend() ? sendDelaySeconds : undefined;
|
||||
void performSend(holdFor);
|
||||
};
|
||||
|
||||
const onScheduleConfirm = (date: Date) => {
|
||||
const holdFor = resolveHoldForScheduledAt(date);
|
||||
if (holdFor == null) return;
|
||||
setScheduleSheetOpen(false);
|
||||
void performSend(holdFor, date);
|
||||
};
|
||||
|
||||
const schedulePresets = React.useMemo(() => {
|
||||
const now = new Date();
|
||||
const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000);
|
||||
const tomorrowMorning = new Date(now);
|
||||
tomorrowMorning.setDate(tomorrowMorning.getDate() + 1);
|
||||
tomorrowMorning.setHours(8, 0, 0, 0);
|
||||
return [
|
||||
{ label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) },
|
||||
{ label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) },
|
||||
{ label: t('email_composer.schedule_tomorrow', 'Tomorrow, 8:00 AM'), date: tomorrowMorning },
|
||||
];
|
||||
}, [t, scheduleSheetOpen]);
|
||||
|
||||
const startCustomPicker = () => {
|
||||
customDraftRef.current = new Date(Date.now() + 3600 * 1000);
|
||||
setScheduleSheetOpen(false);
|
||||
setCustomStage(Platform.OS === 'ios' ? 'datetime' : 'date');
|
||||
};
|
||||
|
||||
const onCustomPickerChange = (event: DateTimePickerEvent, selected?: Date) => {
|
||||
if (event.type === 'dismissed' || !selected) {
|
||||
setCustomStage(null);
|
||||
return;
|
||||
}
|
||||
if (Platform.OS === 'ios') {
|
||||
// Single spinner — keep it open, just remember the latest value.
|
||||
customDraftRef.current = selected;
|
||||
return;
|
||||
}
|
||||
// Android: combine the date step with the existing time, then ask for time.
|
||||
if (customStage === 'date') {
|
||||
const d = new Date(customDraftRef.current);
|
||||
d.setFullYear(selected.getFullYear(), selected.getMonth(), selected.getDate());
|
||||
customDraftRef.current = d;
|
||||
setCustomStage('time');
|
||||
return;
|
||||
}
|
||||
if (customStage === 'time') {
|
||||
const d = new Date(customDraftRef.current);
|
||||
d.setHours(selected.getHours(), selected.getMinutes(), 0, 0);
|
||||
setCustomStage(null);
|
||||
onScheduleConfirm(d);
|
||||
}
|
||||
};
|
||||
|
||||
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||
if (!(await passesAttachmentReminder())) return;
|
||||
setSending(true);
|
||||
@@ -775,7 +876,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
const outgoing = [...inlineFromBody, ...fileAttachments];
|
||||
|
||||
await sendEmail(
|
||||
const result = await sendEmail(
|
||||
{
|
||||
from,
|
||||
to: finalTo.map((r) => ({ name: r.name || undefined, email: r.email })),
|
||||
@@ -793,7 +894,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
},
|
||||
primaryIdentity.id,
|
||||
sentMailbox.id,
|
||||
holdForSeconds,
|
||||
);
|
||||
// Confirm an explicit "send later" so the user knows it didn't go out
|
||||
// now. The brief undo-send delay stays silent — it's meant to be
|
||||
// invisible unless the user cancels from the Scheduled view.
|
||||
if (scheduledAt && result.scheduled) {
|
||||
const when = result.sendAt ? new Date(result.sendAt) : scheduledAt;
|
||||
Alert.alert(
|
||||
t('email_composer.scheduled_title', 'Scheduled'),
|
||||
t('email_composer.scheduled_body', 'Your message will be sent at {time}.').replace(
|
||||
'{time}',
|
||||
when.toLocaleString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
navigation.goBack();
|
||||
} catch (e) {
|
||||
Alert.alert(
|
||||
@@ -828,10 +943,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
>
|
||||
<Paperclip size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setScheduleSheetOpen(true)}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
disabled={!canSend}
|
||||
>
|
||||
<Clock size={20} color={canSend ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={() => { void onSend(); }}
|
||||
onPress={onSend}
|
||||
disabled={!canSend}
|
||||
icon={
|
||||
sending ? (
|
||||
@@ -1083,6 +1206,93 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Schedule send sheet */}
|
||||
<Modal
|
||||
visible={scheduleSheetOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setScheduleSheetOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t('email_composer.schedule_send', 'Schedule send')}
|
||||
</Text>
|
||||
{!jmapClient.hasDelayedSend() && (
|
||||
<Text style={styles.scheduleWarning}>
|
||||
{t('email_composer.schedule_unsupported_body', 'This mail server does not support scheduled send.')}
|
||||
</Text>
|
||||
)}
|
||||
{schedulePresets.map((preset) => (
|
||||
<Pressable
|
||||
key={preset.label}
|
||||
style={styles.scheduleRow}
|
||||
onPress={() => onScheduleConfirm(preset.date)}
|
||||
>
|
||||
<Clock size={16} color={c.textSecondary} />
|
||||
<Text style={styles.scheduleRowLabel}>{preset.label}</Text>
|
||||
<Text style={styles.scheduleRowTime}>
|
||||
{preset.date.toLocaleString(undefined, {
|
||||
weekday: 'short', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable style={styles.scheduleRow} onPress={startCustomPicker}>
|
||||
<Check size={16} color={c.textSecondary} />
|
||||
<Text style={styles.scheduleRowLabel}>
|
||||
{t('email_composer.schedule_custom', 'Pick date & time…')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.scheduleCancel}
|
||||
onPress={() => setScheduleSheetOpen(false)}
|
||||
>
|
||||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{customStage !== null && Platform.OS === 'ios' && (
|
||||
<Modal transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode="datetime"
|
||||
display="spinner"
|
||||
minimumDate={new Date()}
|
||||
onChange={onCustomPickerChange}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable style={styles.modalCancel} onPress={() => setCustomStage(null)}>
|
||||
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.modalConfirm}
|
||||
onPress={() => { setCustomStage(null); onScheduleConfirm(customDraftRef.current); }}
|
||||
>
|
||||
<Text style={styles.modalConfirmText}>
|
||||
{t('email_composer.schedule_send', 'Schedule send')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{customStage !== null && Platform.OS !== 'ios' && (
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode={customStage === 'time' ? 'time' : 'date'}
|
||||
display="default"
|
||||
minimumDate={customStage === 'date' ? new Date() : undefined}
|
||||
onChange={onCustomPickerChange}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1302,5 +1512,34 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
scheduleCard: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: c.background,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
scheduleWarning: {
|
||||
...typography.caption,
|
||||
color: c.error,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
scheduleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
scheduleRowLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
scheduleRowTime: { ...typography.caption, color: c.textMuted },
|
||||
scheduleCancel: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ArrowLeft, Pencil, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
|
||||
Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
|
||||
Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages,
|
||||
Clock, KeyRound,
|
||||
Clock, KeyRound, FolderInput,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
@@ -27,6 +27,7 @@ import { contactToVCard } from '../lib/vcard';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import Dialog from '../components/Dialog';
|
||||
import { ContactActivity } from '../components/contacts/ContactActivity';
|
||||
import AddressBookPickerSheet from '../components/contacts/AddressBookPickerSheet';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
@@ -84,9 +85,11 @@ export default function ContactDetailScreen() {
|
||||
const allContacts = useContactsStore((s) => s.contacts);
|
||||
const deleteContact = useContactsStore((s) => s.deleteContact);
|
||||
const createContact = useContactsStore((s) => s.createContact);
|
||||
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const [moreOpen, setMoreOpen] = React.useState(false);
|
||||
const [moveOpen, setMoveOpen] = React.useState(false);
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
@@ -232,12 +235,26 @@ export default function ContactDetailScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const doMove = async (bookId: string) => {
|
||||
setMoveOpen(false);
|
||||
try {
|
||||
await moveContactsToAddressBook([contact.id], bookId);
|
||||
} catch (err) {
|
||||
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const moreItems = [
|
||||
!isGroup(contact) && {
|
||||
icon: <Copy size={16} color={c.text} />,
|
||||
label: 'Duplicate',
|
||||
onPress: () => { void doDuplicate(); },
|
||||
},
|
||||
addressBooks.length > 0 && {
|
||||
icon: <FolderInput size={16} color={c.text} />,
|
||||
label: 'Move to address book',
|
||||
onPress: () => setMoveOpen(true),
|
||||
},
|
||||
{
|
||||
icon: <Share2 size={16} color={c.text} />,
|
||||
label: 'Export vCard',
|
||||
@@ -614,6 +631,13 @@ export default function ContactDetailScreen() {
|
||||
items={moreItems}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
/>
|
||||
|
||||
<AddressBookPickerSheet
|
||||
visible={moveOpen}
|
||||
onClose={() => setMoveOpen(false)}
|
||||
currentBookId={bookIds[0] ?? null}
|
||||
onPick={(id) => { void doMove(id); }}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput,
|
||||
Search, Plus, UserCircle, X, Menu, Trash2, Tag, FolderInput, Upload,
|
||||
} from 'lucide-react-native';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import type { ContactCard } from '../api/types';
|
||||
@@ -25,7 +26,12 @@ import {
|
||||
type ContactCategory,
|
||||
} from '../stores/contacts-store';
|
||||
import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/contact-utils';
|
||||
import { ContactListRow } from '../components/contacts';
|
||||
import {
|
||||
ContactListRow,
|
||||
AddressBookPickerSheet,
|
||||
ContactImportSheet,
|
||||
TagAssignSheet,
|
||||
} from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
@@ -91,6 +97,8 @@ export default function ContactsScreen() {
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const hydrate = useContactsStore((s) => s.hydrate);
|
||||
const bulkDelete = useContactsStore((s) => s.bulkDelete);
|
||||
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
|
||||
const addKeywordToContacts = useContactsStore((s) => s.addKeywordToContacts);
|
||||
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
|
||||
@@ -99,6 +107,11 @@ export default function ContactsScreen() {
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = React.useState(false);
|
||||
const [moveSheetOpen, setMoveSheetOpen] = React.useState(false);
|
||||
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
|
||||
const [importOpen, setImportOpen] = React.useState(false);
|
||||
const [importTargetOpen, setImportTargetOpen] = React.useState(false);
|
||||
const [importTargetBookId, setImportTargetBookId] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void hydrate();
|
||||
@@ -183,6 +196,45 @@ export default function ContactsScreen() {
|
||||
await bulkDelete(ids);
|
||||
};
|
||||
|
||||
// Default target for moves/imports: the currently-viewed address book, else
|
||||
// the first available book.
|
||||
const defaultBookId = React.useMemo(() => {
|
||||
if (selectedCategory.type === 'addressBook') return selectedCategory.addressBookId;
|
||||
return addressBooks[0]?.id ?? null;
|
||||
}, [selectedCategory, addressBooks]);
|
||||
|
||||
const handleBulkMove = async (bookId: string) => {
|
||||
const ids = Array.from(selection);
|
||||
setMoveSheetOpen(false);
|
||||
clearSelection();
|
||||
try {
|
||||
await moveContactsToAddressBook(ids, bookId);
|
||||
} catch (err) {
|
||||
Alert.alert('Move failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkTag = async (keyword: string) => {
|
||||
const ids = Array.from(selection);
|
||||
setTagSheetOpen(false);
|
||||
clearSelection();
|
||||
try {
|
||||
await addKeywordToContacts(ids, keyword);
|
||||
} catch (err) {
|
||||
Alert.alert('Tagging failed', err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
const openImport = () => {
|
||||
setImportTargetBookId(defaultBookId);
|
||||
setImportOpen(true);
|
||||
};
|
||||
|
||||
const importTargetName = React.useMemo(
|
||||
() => addressBooks.find((b) => b.id === importTargetBookId)?.name,
|
||||
[addressBooks, importTargetBookId],
|
||||
);
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
@@ -234,6 +286,22 @@ export default function ContactsScreen() {
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
{addressBooks.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setMoveSheetOpen(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<FolderInput size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => setTagSheetOpen(true)}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Tag size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setConfirmBulkDelete(true)}
|
||||
style={styles.headerIconBtn}
|
||||
@@ -258,6 +326,13 @@ export default function ContactsScreen() {
|
||||
>
|
||||
<Search size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={openImport}
|
||||
style={styles.headerIconBtn}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Upload size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('ContactForm', {})}
|
||||
style={styles.addBtn}
|
||||
@@ -380,6 +455,35 @@ export default function ContactsScreen() {
|
||||
onConfirm={doBulkDelete}
|
||||
onCancel={() => setConfirmBulkDelete(false)}
|
||||
/>
|
||||
|
||||
<AddressBookPickerSheet
|
||||
visible={moveSheetOpen}
|
||||
onClose={() => setMoveSheetOpen(false)}
|
||||
onPick={(id) => { void handleBulkMove(id); }}
|
||||
/>
|
||||
|
||||
<TagAssignSheet
|
||||
visible={tagSheetOpen}
|
||||
onClose={() => setTagSheetOpen(false)}
|
||||
onPick={(kw) => { void handleBulkTag(kw); }}
|
||||
/>
|
||||
|
||||
<ContactImportSheet
|
||||
visible={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
targetBookId={importTargetBookId}
|
||||
targetBookName={importTargetName}
|
||||
onChangeTarget={addressBooks.length > 1 ? () => setImportTargetOpen(true) : undefined}
|
||||
onImported={() => { void fetchContacts(); }}
|
||||
/>
|
||||
|
||||
<AddressBookPickerSheet
|
||||
visible={importTargetOpen}
|
||||
onClose={() => setImportTargetOpen(false)}
|
||||
currentBookId={importTargetBookId}
|
||||
title="Import into address book"
|
||||
onPick={(id) => { setImportTargetBookId(id); setImportTargetOpen(false); }}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
+140
-18
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback } from 'react-native';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import {
|
||||
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||
Archive, FolderInput, Tag, Import,
|
||||
} from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -13,11 +15,15 @@ import SidebarDrawer from '../components/SidebarDrawer';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { TagSheet } from '../components/TagSheet';
|
||||
import { UndoSnackbar } from '../components/UndoSnackbar';
|
||||
import { OfflineBanner } from '../components/OfflineBanner';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import { useKeywordsStore } from '../stores/keywords-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
function getSenderName(email: Email): string {
|
||||
@@ -40,20 +46,6 @@ function isPinned(email: Email): boolean {
|
||||
return !!email.keywords?.$important;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHr = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMin < 1) return 'now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay === 1) return 'Yesterday';
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const EmailRow = React.memo(function EmailRow({
|
||||
item,
|
||||
@@ -76,6 +68,10 @@ const EmailRow = React.memo(function EmailRow({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dyn = useTypography();
|
||||
const density = useDensity();
|
||||
// Read the date-rendering prefs here so each row re-renders when they change.
|
||||
const dateFormat = useSettingsStore((s) => s.dateFormat);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const senderName = getSenderName(item);
|
||||
const senderEmail = getSenderEmail(item);
|
||||
const unread = isUnread(item);
|
||||
@@ -130,7 +126,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
<Text style={styles.threadBadgeText}>{threadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
|
||||
<Text style={[styles.emailDate, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -181,6 +177,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const selectMailbox = useEmailStore((s) => s.selectMailbox);
|
||||
const loadMoreEmails = useEmailStore((s) => s.loadMoreEmails);
|
||||
const refreshEmails = useEmailStore((s) => s.refreshEmails);
|
||||
const importEmails = useEmailStore((s) => s.importEmails);
|
||||
const setSearchQuery = useEmailStore((s) => s.setSearchQuery);
|
||||
const setFilters = useEmailStore((s) => s.setFilters);
|
||||
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
|
||||
@@ -191,6 +188,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
|
||||
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
|
||||
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
|
||||
const archiveEmailsBatch = useEmailStore((s) => s.archiveEmailsBatch);
|
||||
const moveEmailsToMailbox = useEmailStore((s) => s.moveEmailsToMailbox);
|
||||
const deleteEmailsBatch = useEmailStore((s) => s.deleteEmailsBatch);
|
||||
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
||||
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
||||
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
@@ -238,8 +244,55 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
// Import .eml / .zip files into the current mailbox (falls back to Inbox).
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const handleImport = React.useCallback(async () => {
|
||||
const targetMailboxId =
|
||||
currentMailboxId ?? mailboxes.find((m) => m.role === 'inbox')?.id ?? null;
|
||||
if (!targetMailboxId || importing) return;
|
||||
let result: DocumentPicker.DocumentPickerResult;
|
||||
try {
|
||||
result = await DocumentPicker.getDocumentAsync({
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
type: [
|
||||
'message/rfc822',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/octet-stream',
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
return; // picker dismissed / unavailable
|
||||
}
|
||||
if (result.canceled) return;
|
||||
if (result.assets.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const files = result.assets.map((a) => ({
|
||||
uri: a.uri,
|
||||
name: a.name,
|
||||
mimeType: a.mimeType,
|
||||
}));
|
||||
const { imported, failed } = await importEmails(files, targetMailboxId);
|
||||
Alert.alert(
|
||||
'Import',
|
||||
failed > 0
|
||||
? `${imported} imported, ${failed} failed.`
|
||||
: imported === 0
|
||||
? 'No messages were imported.'
|
||||
: `${imported} message${imported === 1 ? '' : 's'} imported.`,
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [currentMailboxId, mailboxes, importEmails, importing]);
|
||||
|
||||
// Move-to-folder picker triggered by the swipe action.
|
||||
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
|
||||
// Batch (multi-select) sheets.
|
||||
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
|
||||
const [tagSheetOpen, setTagSheetOpen] = React.useState(false);
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
@@ -385,11 +438,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
await Promise.all(ids.map((id) => deleteEmailAction(id, trash.id, currentMailboxId)));
|
||||
await deleteEmailsBatch(Array.from(selectedIds), trash.id, currentMailboxId);
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleBulkArchive = async () => {
|
||||
await archiveEmailsBatch(Array.from(selectedIds));
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const canArchiveSelection =
|
||||
archiveMailboxId != null && currentMailboxId !== archiveMailboxId;
|
||||
|
||||
const handleBatchMovePick = (toId: string) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchMoveOpen(false);
|
||||
clearSelection();
|
||||
void moveEmailsToMailbox(ids, toId);
|
||||
};
|
||||
|
||||
const handleBatchTagToggle = (token: string, on: boolean) => {
|
||||
void setKeywordForEmails(Array.from(selectedIds), token, on);
|
||||
};
|
||||
|
||||
// Local input state for uninterrupted typing; debounce into the store.
|
||||
const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
|
||||
React.useEffect(() => {
|
||||
@@ -486,6 +557,29 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<MailOpen size={20} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setTagSheetOpen(true)}
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Tag size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setBatchMoveOpen(true)}
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<FolderInput size={20} color={c.text} />
|
||||
</Pressable>
|
||||
{canArchiveSelection && (
|
||||
<Pressable
|
||||
onPress={() => { void handleBulkArchive(); }}
|
||||
style={styles.headerButton}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Archive size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => { void handleBulkDelete(); }}
|
||||
style={styles.headerButton}
|
||||
@@ -501,6 +595,18 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable
|
||||
onPress={() => { void handleImport(); }}
|
||||
style={styles.headerButton}
|
||||
disabled={importing}
|
||||
hitSlop={6}
|
||||
>
|
||||
{importing ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Import size={20} color={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Image
|
||||
source={require('../../assets/logos/Bulwark Logo White.png')}
|
||||
style={styles.headerLogo}
|
||||
@@ -891,6 +997,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}}
|
||||
/>
|
||||
|
||||
<MoveSheet
|
||||
visible={batchMoveOpen}
|
||||
onClose={() => setBatchMoveOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={handleBatchMovePick}
|
||||
/>
|
||||
|
||||
<TagSheet
|
||||
visible={tagSheetOpen}
|
||||
onClose={() => setTagSheetOpen(false)}
|
||||
keywords={keywordDefs}
|
||||
selectedEmails={selectedEmails}
|
||||
onToggle={handleBatchTagToggle}
|
||||
/>
|
||||
|
||||
<UndoSnackbar />
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
|
||||
const onShare = async () => {
|
||||
try {
|
||||
await shareEmailEml(blobId, subject);
|
||||
await shareEmailEml(blobId, undefined, subject);
|
||||
} catch (e) {
|
||||
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import EmailBodyView from '../components/EmailBodyView';
|
||||
import { CalendarInvitationBanner } from '../components/email/CalendarInvitationBanner';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
@@ -53,7 +54,7 @@ function plainTextBody(email: Email): string {
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { emailId, subject: subjectParam } = route.params;
|
||||
const { emailId, subject: subjectParam, jmapAccountId } = route.params;
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const getEmailDetail = useEmailStore((s) => s.getEmailDetail);
|
||||
@@ -96,9 +97,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setDownloadingBlobId(blobId);
|
||||
try {
|
||||
if (mailAttachmentAction === 'download') {
|
||||
await downloadAttachment(blobId, name, type);
|
||||
await downloadAttachment(blobId, name, type, email);
|
||||
} else {
|
||||
await shareAttachment(blobId, name, type);
|
||||
await shareAttachment(blobId, name, type, email);
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
@@ -106,7 +107,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setDownloadingBlobId(null);
|
||||
}
|
||||
},
|
||||
[downloadingBlobId, mailAttachmentAction],
|
||||
[downloadingBlobId, mailAttachmentAction, email],
|
||||
);
|
||||
|
||||
const renderAttachments = () => {
|
||||
@@ -178,16 +179,16 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const fetched = await getEmailDetail(emailId);
|
||||
const fetched = await getEmailDetail(emailId, jmapAccountId);
|
||||
if (cancelled) return;
|
||||
setEmail(fetched);
|
||||
if (fetched && !fetched.keywords?.$seen) {
|
||||
if (markAsReadDelay > 0) {
|
||||
readTimer = setTimeout(() => {
|
||||
if (!cancelled) void markRead(emailId);
|
||||
if (!cancelled) void markRead(emailId, jmapAccountId);
|
||||
}, markAsReadDelay);
|
||||
} else {
|
||||
void markRead(emailId);
|
||||
void markRead(emailId, jmapAccountId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -202,7 +203,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
cancelled = true;
|
||||
if (readTimer) clearTimeout(readTimer);
|
||||
};
|
||||
}, [emailId, getEmailDetail, markRead, markAsReadDelay]);
|
||||
}, [emailId, jmapAccountId, getEmailDetail, markRead, markAsReadDelay]);
|
||||
|
||||
const starred = !!email?.keywords?.$flagged;
|
||||
const unread = !!email && !email.keywords?.$seen;
|
||||
@@ -305,6 +306,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
cc: email.cc,
|
||||
subject: email.subject ?? '',
|
||||
body: plainTextBody(email),
|
||||
receivedAt: email.receivedAt,
|
||||
inReplyTo: email.id,
|
||||
},
|
||||
});
|
||||
@@ -434,6 +436,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
{/* Attachments chips (full-width below header) */}
|
||||
{attachmentPosition === 'below-header' && renderAttachments()}
|
||||
|
||||
{/* Calendar invitation (auto-detected .ics) */}
|
||||
<CalendarInvitationBanner email={email} />
|
||||
|
||||
{/* Body */}
|
||||
<View style={styles.bodyBlock}>
|
||||
<EmailBodyView email={email} senderEmail={from?.email} />
|
||||
@@ -504,7 +509,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
setMoreMenuOpen(false);
|
||||
if (!email?.blobId) return;
|
||||
try {
|
||||
await shareEmailEml(email.blobId, email.subject);
|
||||
await shareEmailEml(email.blobId, email);
|
||||
} catch (e) {
|
||||
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
+617
-85
@@ -1,31 +1,60 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {
|
||||
ChevronLeft, FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio,
|
||||
FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
||||
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
||||
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
||||
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
|
||||
} from 'lucide-react-native';
|
||||
import { getFileNodes } from '../api/files';
|
||||
|
||||
// expo-document-picker is loaded lazily on first upload. Its native module
|
||||
// is registered at app launch via Expo autolinking; on builds that predate
|
||||
// the dep being added, requiring it at import time crashes the whole bundle
|
||||
// with "Cannot find native module 'ExpoDocumentPicker'". Deferring the
|
||||
// require keeps every other Files tab feature working until the user
|
||||
// rebuilds the native shell.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
|
||||
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
|
||||
uploadFileNode,
|
||||
} from '../api/files';
|
||||
import { downloadAttachment, shareAttachment } from '../lib/email-export';
|
||||
import type { FileNode } from '../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
|
||||
import Dialog from '../components/Dialog';
|
||||
|
||||
interface FolderFrame {
|
||||
id: string | null;
|
||||
name: string;
|
||||
// A file row carries the resolved display name alongside the original
|
||||
// server-side name (the path-encoded `node.name` field).
|
||||
interface FileRow extends FileNode {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const ROOT_FRAME: FolderFrame = { id: null, name: 'Files' };
|
||||
|
||||
function pickFileIcon(name: string) {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
|
||||
@@ -49,10 +78,6 @@ function fileIconColor(name: string, c: ThemePalette): string {
|
||||
return c.textMuted;
|
||||
}
|
||||
|
||||
function isHidden(node: FileNode): boolean {
|
||||
return node.name.startsWith('.');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -82,12 +107,24 @@ function formatModified(iso?: string): string {
|
||||
export default function FilesScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [stack, setStack] = useState<FolderFrame[]>([ROOT_FRAME]);
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ rows: FileRow[] } | null>(null);
|
||||
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
|
||||
|
||||
const showIcons = useSettingsStore((s) => s.filesShowIcons);
|
||||
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
|
||||
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
|
||||
@@ -96,21 +133,21 @@ export default function FilesScreen() {
|
||||
const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode);
|
||||
const setSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
// Local override so the user can flip view from the toolbar without
|
||||
// changing their persisted default. Falls back to the setting.
|
||||
const [viewOverride, setViewOverride] = useState<FilesViewMode | null>(null);
|
||||
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
|
||||
|
||||
const current = stack[stack.length - 1];
|
||||
const selectionMode = selection.size > 0;
|
||||
const prefix = useMemo(() => pathToPrefix(path), [path]);
|
||||
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (parentId: string | null, mode: 'initial' | 'refresh' = 'initial') => {
|
||||
async (mode: 'initial' | 'refresh' = 'initial') => {
|
||||
if (mode === 'refresh') setRefreshing(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nodes = await getFileNodes(parentId);
|
||||
setFiles(nodes);
|
||||
const nodes = await getAllFileNodes();
|
||||
setAllNodes(nodes);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load files');
|
||||
} finally {
|
||||
@@ -122,18 +159,36 @@ export default function FilesScreen() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles(current.id);
|
||||
}, [loadFiles, current.id]);
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const visibleFiles = useMemo(() => {
|
||||
let out = files;
|
||||
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
|
||||
// Directories always sort to the top regardless of sort key, then secondary
|
||||
// sort by the requested column. This matches macOS Finder / web file pickers.
|
||||
// If we navigated into a folder that no longer exists (it was deleted, or
|
||||
// we never had it), walk back up until we land on one that does. The check
|
||||
// looks for a directory node whose serverName matches the encoded path.
|
||||
useEffect(() => {
|
||||
if (allNodes.length === 0) return;
|
||||
setPath((prev) => {
|
||||
let next = prev;
|
||||
while (next.length > 0) {
|
||||
const parentPrefix = pathToPrefix(next.slice(0, -1));
|
||||
const expectedName = parentPrefix + next[next.length - 1];
|
||||
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
|
||||
if (exists) break;
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [allNodes]);
|
||||
|
||||
const visibleFiles = useMemo<FileRow[]>(() => {
|
||||
const rows = allNodes
|
||||
.filter((n) => isDirectChildOfPrefix(n, prefix))
|
||||
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
|
||||
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
|
||||
const dir = sortDir === 'desc' ? -1 : 1;
|
||||
return [...out].sort((a, b) => {
|
||||
const aDir = a.type === 'directory' ? 0 : 1;
|
||||
const bDir = b.type === 'directory' ? 0 : 1;
|
||||
return rows.sort((a, b) => {
|
||||
const aDir = isDirectory(a) ? 0 : 1;
|
||||
const bDir = isDirectory(b) ? 0 : 1;
|
||||
if (aDir !== bDir) return aDir - bDir;
|
||||
switch (sortKey) {
|
||||
case 'size':
|
||||
@@ -145,22 +200,69 @@ export default function FilesScreen() {
|
||||
}
|
||||
case 'name':
|
||||
default:
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
return a.displayName.localeCompare(b.displayName) * dir;
|
||||
}
|
||||
});
|
||||
}, [files, showHiddenFiles, sortKey, sortDir]);
|
||||
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
|
||||
|
||||
const openNode = useCallback((node: FileNode) => {
|
||||
if (node.type === 'directory') {
|
||||
setStack((s) => [...s, { id: node.id, name: node.name }]);
|
||||
}
|
||||
// Files are no-ops for now — opening a blob would need a download flow
|
||||
// wired to the JMAP download endpoint.
|
||||
const clearSelection = useCallback(() => setSelection(new Set()), []);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const previewFile = useCallback(async (row: FileRow) => {
|
||||
if (!row.blobId) return;
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
await shareAttachment(row.blobId, row.displayName, row.type);
|
||||
} catch (e) {
|
||||
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const downloadFile = useCallback(async (row: FileRow) => {
|
||||
if (!row.blobId) return;
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
await downloadAttachment(row.blobId, row.displayName, row.type);
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRowPress = useCallback(
|
||||
(row: FileRow) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(row.id);
|
||||
return;
|
||||
}
|
||||
if (isDirectory(row)) {
|
||||
setPath((p) => [...p, row.displayName]);
|
||||
return;
|
||||
}
|
||||
void previewFile(row);
|
||||
},
|
||||
[selectionMode, toggleSelect, previewFile],
|
||||
);
|
||||
|
||||
const handleRowLongPress = useCallback((row: FileRow) => {
|
||||
toggleSelect(row.id);
|
||||
}, [toggleSelect]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
||||
}, []);
|
||||
setPath((p) => (p.length > 0 ? p.slice(0, -1) : p));
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
|
||||
const toggleView = useCallback(() => {
|
||||
const next: FilesViewMode = viewMode === 'list' ? 'grid' : 'list';
|
||||
@@ -169,12 +271,130 @@ export default function FilesScreen() {
|
||||
}, [viewMode, setSetting]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
void loadFiles(current.id, 'refresh');
|
||||
}, [loadFiles, current.id]);
|
||||
void loadFiles('refresh');
|
||||
}, [loadFiles]);
|
||||
|
||||
const submitNewFolder = useCallback(async () => {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
if (name.includes(PATH_SEP) || name.includes('/')) {
|
||||
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
|
||||
return;
|
||||
}
|
||||
setNewFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
try {
|
||||
await createFolder(name, path);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [newFolderName, path, loadFiles]);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (!renameTarget) return;
|
||||
const name = renameValue.trim();
|
||||
if (!name || name === renameTarget.displayName) {
|
||||
setRenameTarget(null);
|
||||
return;
|
||||
}
|
||||
if (name.includes(PATH_SEP) || name.includes('/')) {
|
||||
Alert.alert('Invalid name', 'Names cannot contain "/".');
|
||||
return;
|
||||
}
|
||||
const target = renameTarget;
|
||||
setRenameTarget(null);
|
||||
setRenameValue('');
|
||||
try {
|
||||
await renameFileNode(target, name, path, allNodes);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (uploading) return;
|
||||
const picker = loadDocumentPicker();
|
||||
if (!picker) {
|
||||
Alert.alert(
|
||||
'Upload unavailable',
|
||||
'The file picker is not installed in this build. Rebuild the app (expo run:android) to enable uploads.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await picker.getDocumentAsync({
|
||||
copyToCacheDirectory: true,
|
||||
multiple: false,
|
||||
});
|
||||
} catch (e) {
|
||||
Alert.alert('Pick failed', e instanceof Error ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
if (result.canceled || result.assets.length === 0) return;
|
||||
const asset = result.assets[0];
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadFileNode(
|
||||
asset.uri,
|
||||
asset.name,
|
||||
asset.mimeType || 'application/octet-stream',
|
||||
path,
|
||||
);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Upload failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [uploading, path, loadFiles]);
|
||||
|
||||
const requestDelete = useCallback((rows: FileRow[]) => {
|
||||
if (rows.length === 0) return;
|
||||
setConfirmDelete({ rows });
|
||||
}, []);
|
||||
|
||||
const performDelete = useCallback(async () => {
|
||||
if (!confirmDelete) return;
|
||||
const targets = confirmDelete.rows;
|
||||
setConfirmDelete(null);
|
||||
clearSelection();
|
||||
try {
|
||||
await deleteWithDescendants(targets, allNodes);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
|
||||
|
||||
const renderHeader = () => {
|
||||
const canBack = stack.length > 1;
|
||||
const canBack = path.length > 0;
|
||||
const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon;
|
||||
if (selectionMode) {
|
||||
const selectedRows = visibleFiles.filter((r) => selection.has(r.id));
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable onPress={clearSelection} hitSlop={8} style={styles.headerBtn}>
|
||||
<X size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>{selection.size} selected</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => requestDelete(selectedRows)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="Delete selected"
|
||||
>
|
||||
<Trash2 size={20} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerRow}>
|
||||
@@ -185,49 +405,76 @@ export default function FilesScreen() {
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{current.name}
|
||||
{headerName}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={toggleView}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
|
||||
>
|
||||
<ToggleIcon size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setNewFolderOpen(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="New folder"
|
||||
>
|
||||
<FolderPlus size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void startUpload()}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="Upload file"
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<Upload size={20} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={toggleView}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
|
||||
>
|
||||
<ToggleIcon size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{canBack ? (
|
||||
<Text style={styles.breadcrumb} numberOfLines={1}>
|
||||
{stack.map((f) => f.name).join(' / ')}
|
||||
{['Files', ...path].join(' / ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRow = (item: FileNode) => {
|
||||
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
|
||||
const tint = item.type === 'directory'
|
||||
const renderRow = (item: FileRow) => {
|
||||
const isDir = isDirectory(item);
|
||||
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
|
||||
const tint = isDir
|
||||
? (coloredIcons ? c.calendar.blue : c.textMuted)
|
||||
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
|
||||
const tappable = item.type === 'directory';
|
||||
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
|
||||
const selected = selection.has(item.id);
|
||||
const busy = busyId === item.id;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => openNode(item)}
|
||||
disabled={!tappable}
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleRowLongPress(item)}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [
|
||||
styles.fileRow,
|
||||
pressed && tappable && { backgroundColor: c.surfaceHover },
|
||||
selected && { backgroundColor: c.primaryBg },
|
||||
pressed && !selected && { backgroundColor: c.surfaceHover },
|
||||
]}
|
||||
>
|
||||
{showIcons && <Icon size={20} color={tint} />}
|
||||
<View style={styles.fileInfo}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{item.name}
|
||||
{item.displayName}
|
||||
</Text>
|
||||
<View style={styles.fileMetaRow}>
|
||||
{item.size != null && item.type !== 'directory' ? (
|
||||
{item.size != null && !isDir ? (
|
||||
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
|
||||
) : null}
|
||||
{item.updated ? (
|
||||
@@ -235,23 +482,40 @@ export default function FilesScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : !selectionMode ? (
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setActionsTarget(item);
|
||||
}}
|
||||
hitSlop={8}
|
||||
style={styles.rowMore}
|
||||
>
|
||||
<MoreVertical size={18} color={c.textMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGridCell = (item: FileNode) => {
|
||||
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
|
||||
const tint = item.type === 'directory'
|
||||
const renderGridCell = (item: FileRow) => {
|
||||
const isDir = isDirectory(item);
|
||||
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
|
||||
const tint = isDir
|
||||
? (coloredIcons ? c.calendar.blue : c.textMuted)
|
||||
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
|
||||
const tappable = item.type === 'directory';
|
||||
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
|
||||
const selected = selection.has(item.id);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => openNode(item)}
|
||||
disabled={!tappable}
|
||||
onPress={() => handleRowPress(item)}
|
||||
onLongPress={() => handleRowLongPress(item)}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [
|
||||
styles.gridItem,
|
||||
pressed && tappable && { backgroundColor: c.surfaceHover },
|
||||
selected && { backgroundColor: c.primaryBg },
|
||||
pressed && !selected && { backgroundColor: c.surfaceHover },
|
||||
]}
|
||||
>
|
||||
{showIcons ? (
|
||||
@@ -260,9 +524,9 @@ export default function FilesScreen() {
|
||||
<View style={{ width: 36, height: 36 }} />
|
||||
)}
|
||||
<Text style={styles.gridName} numberOfLines={2}>
|
||||
{item.name}
|
||||
{item.displayName}
|
||||
</Text>
|
||||
{item.size != null && item.type !== 'directory' ? (
|
||||
{item.size != null && !isDir ? (
|
||||
<Text style={styles.gridMeta} numberOfLines={1}>
|
||||
{formatFileSize(item.size)}
|
||||
</Text>
|
||||
@@ -284,7 +548,7 @@ export default function FilesScreen() {
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => void loadFiles(current.id)}
|
||||
onPress={() => void loadFiles()}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -295,7 +559,7 @@ export default function FilesScreen() {
|
||||
<View style={styles.center}>
|
||||
<HardDrive size={48} color={c.textMuted} />
|
||||
<Text style={styles.emptyText}>
|
||||
{stack.length > 1 ? 'This folder is empty' : 'No files yet'}
|
||||
{path.length > 0 ? 'This folder is empty' : 'No files yet'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -310,11 +574,7 @@ export default function FilesScreen() {
|
||||
contentContainerStyle={styles.gridContent}
|
||||
renderItem={({ item }) => renderGridCell(item)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={c.primary}
|
||||
/>
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -326,11 +586,7 @@ export default function FilesScreen() {
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => renderRow(item)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={c.primary}
|
||||
/>
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={c.primary} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -340,10 +596,181 @@ export default function FilesScreen() {
|
||||
<View style={styles.container}>
|
||||
{renderHeader()}
|
||||
{body}
|
||||
|
||||
<PromptModal
|
||||
visible={newFolderOpen}
|
||||
title="New folder"
|
||||
placeholder="Folder name"
|
||||
confirmText="Create"
|
||||
value={newFolderName}
|
||||
onChange={setNewFolderName}
|
||||
onCancel={() => {
|
||||
setNewFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
onSubmit={submitNewFolder}
|
||||
/>
|
||||
|
||||
<PromptModal
|
||||
visible={renameTarget != null}
|
||||
title="Rename"
|
||||
placeholder="Name"
|
||||
confirmText="Rename"
|
||||
value={renameValue}
|
||||
onChange={setRenameValue}
|
||||
onCancel={() => {
|
||||
setRenameTarget(null);
|
||||
setRenameValue('');
|
||||
}}
|
||||
onSubmit={submitRename}
|
||||
/>
|
||||
|
||||
<ActionsSheet
|
||||
target={actionsTarget}
|
||||
onClose={() => setActionsTarget(null)}
|
||||
onPreview={(r) => {
|
||||
setActionsTarget(null);
|
||||
void previewFile(r);
|
||||
}}
|
||||
onDownload={(r) => {
|
||||
setActionsTarget(null);
|
||||
void downloadFile(r);
|
||||
}}
|
||||
onRename={(r) => {
|
||||
setActionsTarget(null);
|
||||
setRenameTarget(r);
|
||||
setRenameValue(r.displayName);
|
||||
}}
|
||||
onDelete={(r) => {
|
||||
setActionsTarget(null);
|
||||
requestDelete([r]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={confirmDelete != null}
|
||||
title={
|
||||
confirmDelete && confirmDelete.rows.length > 1
|
||||
? `Delete ${confirmDelete.rows.length} items?`
|
||||
: 'Delete this item?'
|
||||
}
|
||||
message="Folders are deleted along with everything inside them. This can't be undone."
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
onConfirm={() => void performDelete()}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromptModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
confirmText: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
function PromptModal(props: PromptModalProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makePromptStyles(c), [c]);
|
||||
return (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={props.onCancel}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={props.onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={styles.dialog}>
|
||||
<Text style={styles.title}>{props.title}</Text>
|
||||
<TextInput
|
||||
value={props.value}
|
||||
onChangeText={props.onChange}
|
||||
placeholder={props.placeholder}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={props.onSubmit}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={props.onCancel} style={styles.btnGhost}>
|
||||
<Text style={styles.btnGhostText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={props.onSubmit}
|
||||
style={[styles.btnPrimary, !props.value.trim() && { opacity: 0.5 }]}
|
||||
disabled={!props.value.trim()}
|
||||
>
|
||||
<Text style={styles.btnPrimaryText}>{props.confirmText}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionsSheetProps {
|
||||
target: FileRow | null;
|
||||
onClose: () => void;
|
||||
onPreview: (r: FileRow) => void;
|
||||
onDownload: (r: FileRow) => void;
|
||||
onRename: (r: FileRow) => void;
|
||||
onDelete: (r: FileRow) => void;
|
||||
}
|
||||
|
||||
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
|
||||
if (!target) return null;
|
||||
const isDir = isDirectory(target);
|
||||
return (
|
||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.backdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{target.displayName}
|
||||
</Text>
|
||||
{!isDir ? (
|
||||
<Pressable style={styles.action} onPress={() => onPreview(target)}>
|
||||
<Share2 size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>Preview / Share</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!isDir ? (
|
||||
<Pressable style={styles.action} onPress={() => onDownload(target)}>
|
||||
<Download size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>Save to device</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.action} onPress={() => onRename(target)}>
|
||||
<Pencil size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>Rename</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.action} onPress={() => onDelete(target)}>
|
||||
<Trash2 size={18} color={c.error} />
|
||||
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
@@ -369,6 +796,11 @@ function makeStyles(c: ThemePalette) {
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
headerBtn: {
|
||||
padding: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
@@ -437,6 +869,9 @@ function makeStyles(c: ThemePalette) {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
rowMore: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
gridContent: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
@@ -463,3 +898,100 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makePromptStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
dialog: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
title: {
|
||||
...typography.h3,
|
||||
color: c.text,
|
||||
},
|
||||
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,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
btnGhost: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
btnGhostText: {
|
||||
color: c.text,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
btnPrimary: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
btnPrimaryText: {
|
||||
color: c.primaryForeground,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeSheetStyles(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,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
action: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
actionLabel: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
@@ -30,6 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
|
||||
const canHandoff = Boolean(serverUrl.trim());
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!canSubmit) {
|
||||
@@ -44,6 +46,25 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebmailHandoff = async () => {
|
||||
if (!canHandoff) return;
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
|
||||
// loginViaWebmail swallows the "user cancelled the browser" case
|
||||
// silently — only fire the navigation hook when authentication
|
||||
// actually completed.
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if (after && !before) {
|
||||
onLogin?.();
|
||||
} else if (isAddMode && after) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = React.useCallback(
|
||||
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
|
||||
if (useAuthStore.getState().error) {
|
||||
@@ -149,10 +170,17 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* OAuth buttons */}
|
||||
{/* Webmail-mediated login */}
|
||||
<View style={styles.oauthRow}>
|
||||
<Button variant="outline" size="md" disabled>
|
||||
SSO Provider
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
void handleWebmailHandoff();
|
||||
}}
|
||||
disabled={!canHandoff || isLoading}
|
||||
>
|
||||
Sign in via webmail
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, Clock, X } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
listScheduledEmails,
|
||||
cancelScheduledSend,
|
||||
type ScheduledEmail,
|
||||
} from '../api/email';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
||||
|
||||
function recipientLabel(item: ScheduledEmail): string {
|
||||
const first = item.to?.[0];
|
||||
if (!first) return '(no recipient)';
|
||||
const name = first.name || first.email;
|
||||
const extra = (item.to?.length ?? 0) - 1;
|
||||
return extra > 0 ? `${name} +${extra}` : name;
|
||||
}
|
||||
|
||||
export default function ScheduledScreen({ navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [items, setItems] = React.useState<ScheduledEmail[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setItems(await listScheduledEmails());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load scheduled messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCancel = (item: ScheduledEmail) => {
|
||||
Alert.alert(
|
||||
'Cancel scheduled send?',
|
||||
'The message will not be delivered. A copy stays in your Sent folder.',
|
||||
[
|
||||
{ text: 'Keep scheduled', style: 'cancel' },
|
||||
{
|
||||
text: 'Cancel send',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setCancelling(item.emailSubmissionId);
|
||||
void (async () => {
|
||||
try {
|
||||
await cancelScheduledSend(item.emailSubmissionId);
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => i.emailSubmissionId !== item.emailSubmissionId),
|
||||
);
|
||||
} catch (e) {
|
||||
Alert.alert('Cancel failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCancelling(null);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: ScheduledEmail }) => (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.sendAtRow}>
|
||||
<Clock size={13} color={c.primary} />
|
||||
<Text style={styles.sendAt}>{new Date(item.sendAt).toLocaleString()}</Text>
|
||||
</View>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
<Text style={styles.recipient} numberOfLines={1}>
|
||||
To: {recipientLabel(item)}
|
||||
</Text>
|
||||
{item.preview ? (
|
||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => onCancel(item)}
|
||||
style={styles.cancelBtn}
|
||||
hitSlop={8}
|
||||
disabled={cancelling === item.emailSubmissionId}
|
||||
>
|
||||
{cancelling === item.emailSubmissionId ? (
|
||||
<ActivityIndicator size="small" color={c.error} />
|
||||
) : (
|
||||
<X size={18} color={c.error} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>Scheduled</Text>
|
||||
<View style={styles.headerBtn} />
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.error}>{error}</Text>
|
||||
<Pressable onPress={() => { void load(); }}>
|
||||
<Text style={styles.retry}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : items.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Clock size={40} color={c.textMuted} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>No scheduled messages</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Use the clock icon in the composer to send a message later.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(i) => i.emailSubmissionId}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshing={loading}
|
||||
onRefresh={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
|
||||
error: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
|
||||
emptyText: { ...typography.body, color: c.textSecondary },
|
||||
emptyHint: { ...typography.caption, color: c.textMuted, textAlign: 'center', maxWidth: 280 },
|
||||
listContent: { paddingVertical: spacing.sm },
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowMain: { flex: 1, minWidth: 0, gap: 2 },
|
||||
sendAtRow: { flexDirection: 'row', alignItems: 'center', gap: 5 },
|
||||
sendAt: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
subject: { ...typography.bodyMedium, color: c.text },
|
||||
recipient: { ...typography.caption, color: c.textSecondary },
|
||||
preview: { ...typography.caption, color: c.textMuted },
|
||||
cancelBtn: {
|
||||
width: 36, height: 36, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
||||
Filter, FileText, FolderOpen, Tags, HardDrive,
|
||||
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw,
|
||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug,
|
||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Bug, Download,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
@@ -35,13 +35,15 @@ import { ContentSendersSettings } from '../components/settings/ContentSendersSet
|
||||
import { LanguageSettings } from '../components/settings/LanguageSettings';
|
||||
import { ComposingSettings } from '../components/settings/ComposingSettings';
|
||||
import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
| 'appearance' | 'layout'
|
||||
| 'reading' | 'composing' | 'identities' | 'vacation'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords'
|
||||
| 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads'
|
||||
| 'security' | 'encryption' | 'content_senders'
|
||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||
| 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug';
|
||||
@@ -87,6 +89,7 @@ const TABS: TabDef[] = [
|
||||
{ id: 'templates', label: 'Templates', icon: FileText, group: 'mail', implemented: true },
|
||||
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'mail', implemented: true },
|
||||
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'mail', implemented: true },
|
||||
{ id: 'downloads', label: 'Downloads', icon: Download, group: 'mail', implemented: true },
|
||||
|
||||
// Privacy & Security
|
||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
|
||||
@@ -121,6 +124,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
templates: TemplateSettings,
|
||||
folders: FolderSettings,
|
||||
keywords: KeywordSettings,
|
||||
downloads: DownloadsSettings,
|
||||
security: AccountSecuritySettings,
|
||||
encryption: SmimeSettings,
|
||||
content_senders: ContentSendersSettings,
|
||||
@@ -155,6 +159,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
// Subscribe to locale so labels re-render when the user picks a different language.
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const unavailableTabs = React.useMemo(() => {
|
||||
const set = new Set<Tab>();
|
||||
if (!hasCalendar) set.add('calendar');
|
||||
if (!hasContacts) set.add('contacts');
|
||||
if (!hasFiles) set.add('files');
|
||||
return set;
|
||||
}, [hasCalendar, hasContacts, hasFiles]);
|
||||
const groupedTabs = React.useMemo(() => {
|
||||
void locale; // dependency: re-translate on locale change
|
||||
return groupTabs().map((g) => ({
|
||||
@@ -178,6 +192,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
const handleTabPress = (tab: TabDef) => {
|
||||
if (!tab.implemented) return;
|
||||
if (unavailableTabs.has(tab.id)) return;
|
||||
setSelectedTab(tab.id);
|
||||
onTabSelect?.(tab.id);
|
||||
};
|
||||
@@ -241,7 +256,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const disabled = !tab.implemented;
|
||||
const unavailable = unavailableTabs.has(tab.id);
|
||||
const disabled = !tab.implemented || unavailable;
|
||||
const badgeLabel = !tab.implemented
|
||||
? 'Not implemented'
|
||||
: unavailable
|
||||
? 'Unavailable'
|
||||
: null;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
@@ -269,9 +290,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{disabled ? (
|
||||
{badgeLabel ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>Not implemented</Text>
|
||||
<Text style={styles.notWorkingText}>{badgeLabel}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={c.mutedForeground} />
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, Star, Paperclip, AlertTriangle } from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
|
||||
|
||||
function senderName(email: UnifiedEmail): string {
|
||||
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
|
||||
}
|
||||
|
||||
export default function UnifiedInboxScreen({ navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const dateFormat = useSettingsStore((s) => s.dateFormat);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const includeGroup = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
|
||||
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
|
||||
const accountById = React.useMemo(
|
||||
() => new Map(accounts.map((a) => [a.id, a])),
|
||||
[accounts],
|
||||
);
|
||||
const accountIds = React.useMemo(() => accounts.map((a) => a.id), [accounts]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchUnifiedInbox(accountIds, 25, { includeGroup });
|
||||
setEmails(result.emails);
|
||||
setErrors(result.errors);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accountIds, includeGroup]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onOpen = React.useCallback(
|
||||
(email: UnifiedEmail) => {
|
||||
if (opening) return;
|
||||
setOpening(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.activeAccountId !== email.sourceAccountId) {
|
||||
await switchAccount(email.sourceAccountId);
|
||||
if (useAuthStore.getState().activeAccountId !== email.sourceAccountId) return;
|
||||
}
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
// Group/shared messages live under another JMAP account in the
|
||||
// same session; pass it so the thread opens against the right one.
|
||||
jmapAccountId: email.isShared ? email.jmapAccountId : undefined,
|
||||
});
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[opening, switchAccount, navigation],
|
||||
);
|
||||
|
||||
const errorCount = Object.keys(errors).length;
|
||||
|
||||
const renderItem = ({ item }: { item: UnifiedEmail }) => {
|
||||
const acc = accountById.get(item.sourceAccountId);
|
||||
const unread = !item.keywords?.$seen;
|
||||
const starred = !!item.keywords?.$flagged;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onOpen(item)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={styles.avatarWrap}>
|
||||
<SenderAvatar
|
||||
name={senderName(item)}
|
||||
email={item.from?.[0]?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
/>
|
||||
{acc && <View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />}
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.line}>
|
||||
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
|
||||
{senderName(item)}
|
||||
</Text>
|
||||
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
|
||||
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
|
||||
</View>
|
||||
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
<View style={styles.line}>
|
||||
{acc && (
|
||||
<Text style={styles.account} numberOfLines={1}>
|
||||
{acc.email || acc.username}
|
||||
</Text>
|
||||
)}
|
||||
{item.isShared && (
|
||||
<View style={styles.sharedBadge}>
|
||||
<Text style={styles.sharedBadgeText} numberOfLines={1}>
|
||||
{item.sharedLabel || 'Shared'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{item.preview ? (
|
||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>All inboxes</Text>
|
||||
<View style={styles.headerBtn}>
|
||||
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{errorCount > 0 && (
|
||||
<View style={styles.errorBanner}>
|
||||
<AlertTriangle size={14} color={c.error} />
|
||||
<Text style={styles.errorBannerText} numberOfLines={1}>
|
||||
{errorCount === 1
|
||||
? '1 account could not be loaded'
|
||||
: `${errorCount} accounts could not be loaded`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && emails.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
<Text style={styles.loadingText}>Loading all inboxes…</Text>
|
||||
</View>
|
||||
) : emails.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.loadingText}>No mail across your accounts</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={emails}
|
||||
keyExtractor={(item) => `${item.sourceAccountId}:${item.id}`}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
refreshing={loading}
|
||||
onRefresh={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: {
|
||||
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||
errorBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.errorBg,
|
||||
},
|
||||
errorBannerText: { ...typography.caption, color: c.error, flex: 1 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm },
|
||||
loadingText: { ...typography.body, color: c.textSecondary },
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
avatarWrap: { position: 'relative' },
|
||||
accountDot: {
|
||||
position: 'absolute',
|
||||
right: -2, bottom: -2,
|
||||
width: 12, height: 12,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
borderColor: c.background,
|
||||
},
|
||||
content: { flex: 1, minWidth: 0, gap: 1 },
|
||||
line: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
sender: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
bold: { fontWeight: '700' },
|
||||
time: { ...typography.caption, color: c.textMuted },
|
||||
subject: { ...typography.body, color: c.text },
|
||||
account: { ...typography.caption, color: c.textMuted },
|
||||
sharedBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.muted,
|
||||
maxWidth: 120,
|
||||
},
|
||||
sharedBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
preview: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
|
||||
vi.mock('../../lib/push-notifications', () => ({
|
||||
teardownPushNotifications: vi.fn(async () => undefined),
|
||||
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -271,7 +272,9 @@ describe('calendar-store', () => {
|
||||
{ title: 'New' } as any,
|
||||
);
|
||||
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' });
|
||||
// The third arg is the iMIP scheduling flag — undefined here since the
|
||||
// event has no participants to notify.
|
||||
expect(mockUpdateEvent).toHaveBeenCalledWith('master', { title: 'New' }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,30 +2,117 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../api/email', () => ({
|
||||
getMailboxes: vi.fn(),
|
||||
// New helpers used by the incremental-sync path. Default behavior: behave
|
||||
// like a first-ever load — no prior state, full re-query expected.
|
||||
getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||
getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })),
|
||||
getMailboxChanges: vi.fn(async () => null),
|
||||
queryEmails: vi.fn(),
|
||||
getEmailQueryChanges: vi.fn(async () => null),
|
||||
getEmails: vi.fn(),
|
||||
getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })),
|
||||
getEmailChanges: vi.fn(async () => null),
|
||||
getFullEmail: vi.fn(),
|
||||
setEmailKeywords: vi.fn(),
|
||||
setKeywordsForEmails: vi.fn(),
|
||||
moveEmail: vi.fn(),
|
||||
moveEmails: vi.fn(),
|
||||
archiveEmails: vi.fn(),
|
||||
restoreEmailMailboxes: vi.fn(),
|
||||
setEmailMailboxes: vi.fn(),
|
||||
destroyEmails: vi.fn(),
|
||||
deleteEmail: vi.fn(),
|
||||
deleteEmails: vi.fn(),
|
||||
searchEmails: vi.fn(),
|
||||
}));
|
||||
|
||||
// The mutations now route through the offline outbox. In tests we want the
|
||||
// "online, nothing queued" fast path: run the supplied online runner (or the
|
||||
// op's primitive) immediately so the existing api-call assertions still hold,
|
||||
// without pulling in network-store / NetInfo.
|
||||
vi.mock('../outbox-store', async () => {
|
||||
const api = await import('../../api/email') as Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
const runOp = async (op: { kind: string; emailId: string; keywords?: unknown; mailboxIds?: unknown }) => {
|
||||
if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords);
|
||||
if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds);
|
||||
if (op.kind === 'destroy') return api.destroyEmails([op.emailId]);
|
||||
};
|
||||
const applyOrQueueBatch = async (ops: any[], onlineRun?: () => Promise<void>) => {
|
||||
if (onlineRun) await onlineRun();
|
||||
else await Promise.all(ops.map(runOp));
|
||||
return { queued: false };
|
||||
};
|
||||
return {
|
||||
applyOrQueueBatch,
|
||||
applyOrQueue: async (op: any, onlineRun?: () => Promise<void>) => applyOrQueueBatch([op], onlineRun),
|
||||
useOutboxStore: {
|
||||
getState: () => ({
|
||||
entries: [],
|
||||
count: () => 0,
|
||||
setAccount: vi.fn(async () => undefined),
|
||||
flush: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// settings-store transitively pulls in jmap-client / expo-secure-store, which
|
||||
// trip on react-native's Flow-typed entrypoint under vitest. The store only
|
||||
// reads archiveMode in archiveEmail, so a minimal stub is enough.
|
||||
vi.mock('../settings-store', () => ({
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
|
||||
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
|
||||
}));
|
||||
|
||||
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
|
||||
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
|
||||
// switch). Stub it as an empty cache so tests don't need to set up
|
||||
// AsyncStorage.
|
||||
vi.mock('../offline-cache-store', () => ({
|
||||
useOfflineCacheStore: {
|
||||
getState: () => ({
|
||||
activeAccountId: null,
|
||||
hydrated: true,
|
||||
hydrate: vi.fn(),
|
||||
setAccount: vi.fn(async () => undefined),
|
||||
totalCount: () => 0,
|
||||
getEmailsInMailbox: vi.fn(async () => []),
|
||||
has: () => false,
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined),
|
||||
patch: vi.fn(async () => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// email-store now early-returns from fetch actions unless jmapClient is
|
||||
// connected AND serving the same logical account the store has active
|
||||
// (`generateAccountId(username, serverUrl) === activeAccountId`). The
|
||||
// tests exercise those actions, so present a fully connected stub plus
|
||||
// the matching username/serverUrl pair. beforeEach() syncs the store's
|
||||
// activeAccountId to the id these credentials produce.
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
isConnected: true,
|
||||
accountId: 'acc-1',
|
||||
username: 'test@example.com',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
|
||||
getMaxObjectsInGet: () => 500,
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateAccountId } from '../../lib/account-utils';
|
||||
const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.example.com');
|
||||
|
||||
import * as emailApi from '../../api/email';
|
||||
import { useEmailStore } from '../email-store';
|
||||
|
||||
const mockGetMailboxes = emailApi.getMailboxes as ReturnType<typeof vi.fn>;
|
||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
||||
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
|
||||
const mockSetKeywords = emailApi.setEmailKeywords as ReturnType<typeof vi.fn>;
|
||||
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
|
||||
@@ -35,6 +122,10 @@ const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useEmailStore.getState().reset();
|
||||
// Wire the store's active account to the one the mocked jmapClient is
|
||||
// serving, so the guard inside fetchMailboxes / refreshEmails / etc.
|
||||
// doesn't short-circuit the tests.
|
||||
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
|
||||
});
|
||||
|
||||
describe('email-store', () => {
|
||||
@@ -44,7 +135,7 @@ describe('email-store', () => {
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox' },
|
||||
{ id: 'mb-2', name: 'Sent', role: 'sent' },
|
||||
];
|
||||
mockGetMailboxes.mockResolvedValue(mailboxes);
|
||||
mockGetMailboxesWithState.mockResolvedValue({ list: mailboxes, state: 'mb-state-1' });
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes();
|
||||
|
||||
@@ -52,7 +143,7 @@ describe('email-store', () => {
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
mockGetMailboxes.mockRejectedValue(new Error('Network error'));
|
||||
mockGetMailboxesWithState.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await useEmailStore.getState().fetchMailboxes();
|
||||
|
||||
@@ -62,12 +153,12 @@ describe('email-store', () => {
|
||||
|
||||
describe('selectMailbox', () => {
|
||||
it('should query and fetch emails for mailbox', async () => {
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2 });
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 2, queryState: 'q-1' });
|
||||
const emails = [
|
||||
{ id: 'e1', subject: 'Email 1' },
|
||||
{ id: 'e2', subject: 'Email 2' },
|
||||
];
|
||||
mockGetEmails.mockResolvedValue(emails);
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: emails, state: 'em-state-1' });
|
||||
|
||||
await useEmailStore.getState().selectMailbox('mb-1');
|
||||
|
||||
@@ -79,11 +170,14 @@ describe('email-store', () => {
|
||||
});
|
||||
|
||||
it('should handle empty mailbox', async () => {
|
||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0 });
|
||||
mockQueryEmails.mockResolvedValue({ ids: [], total: 0, queryState: 'q-empty' });
|
||||
|
||||
await useEmailStore.getState().selectMailbox('mb-empty');
|
||||
|
||||
expect(useEmailStore.getState().emails).toEqual([]);
|
||||
// First-time load with no ids hits getEmailsWithState only to prime
|
||||
// emailState (with an empty ids array). It should NOT call legacy
|
||||
// getEmails — that path is reserved for pagination/search.
|
||||
expect(mockGetEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// NetInfo is a native module; stub it so network-store loads under node. The
|
||||
// store defaults to online, which we flip per-test via setState.
|
||||
vi.mock('@react-native-community/netinfo', () => ({
|
||||
default: {
|
||||
addEventListener: () => () => undefined,
|
||||
fetch: async () => ({ isConnected: true, isInternetReachable: true }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { isConnected: true },
|
||||
}));
|
||||
|
||||
const setEmailKeywords = vi.fn(async (..._a: any[]) => undefined);
|
||||
const setEmailMailboxes = vi.fn(async (..._a: any[]) => undefined);
|
||||
const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
|
||||
vi.mock('../../api/email', () => ({
|
||||
setEmailKeywords: (...a: any[]) => setEmailKeywords(...a),
|
||||
setEmailMailboxes: (...a: any[]) => setEmailMailboxes(...a),
|
||||
destroyEmails: (...a: any[]) => destroyEmails(...a),
|
||||
}));
|
||||
|
||||
import { useOutboxStore, applyOrQueue } from '../outbox-store';
|
||||
import { useNetworkStore } from '../network-store';
|
||||
|
||||
const ACCOUNT = 'acc-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
useNetworkStore.setState({ online: true, connected: true });
|
||||
// Detach then attach a clean bucket (AsyncStorage is the in-memory mock).
|
||||
await useOutboxStore.getState().setAccount(null);
|
||||
await useOutboxStore.getState().clear();
|
||||
await useOutboxStore.getState().setAccount(ACCOUNT);
|
||||
await useOutboxStore.getState().clear();
|
||||
});
|
||||
|
||||
describe('outbox enqueue + coalescing', () => {
|
||||
it('coalesces repeated keyword ops for the same email (last-write-wins)', () => {
|
||||
const store = useOutboxStore.getState();
|
||||
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } });
|
||||
|
||||
const entries = useOutboxStore.getState().entries;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].op).toEqual({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true, $flagged: true } });
|
||||
});
|
||||
|
||||
it('keeps keyword and mailbox ops for the same email separate', () => {
|
||||
const store = useOutboxStore.getState();
|
||||
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('destroy supersedes pending edits for that email', () => {
|
||||
const store = useOutboxStore.getState();
|
||||
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
store.enqueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } });
|
||||
store.enqueue({ kind: 'destroy', emailId: 'e1' });
|
||||
|
||||
const entries = useOutboxStore.getState().entries;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].op.kind).toBe('destroy');
|
||||
});
|
||||
|
||||
it('ignores further edits once a destroy is queued', () => {
|
||||
const store = useOutboxStore.getState();
|
||||
store.enqueue({ kind: 'destroy', emailId: 'e1' });
|
||||
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(1);
|
||||
expect(useOutboxStore.getState().entries[0].op.kind).toBe('destroy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOrQueue', () => {
|
||||
it('runs immediately when online with an empty queue', async () => {
|
||||
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
expect(result.queued).toBe(false);
|
||||
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('prefers the supplied online runner over the primitive', async () => {
|
||||
const onlineRun = vi.fn(async () => undefined);
|
||||
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { archive: true } }, onlineRun);
|
||||
expect(onlineRun).toHaveBeenCalledOnce();
|
||||
expect(setEmailMailboxes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues instead of running when offline', async () => {
|
||||
useNetworkStore.setState({ online: false });
|
||||
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
expect(result.queued).toBe(true);
|
||||
expect(setEmailKeywords).not.toHaveBeenCalled();
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('queues a later op for the same email to preserve order', async () => {
|
||||
useNetworkStore.setState({ online: false });
|
||||
await applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } });
|
||||
useNetworkStore.setState({ online: true });
|
||||
// Now online, but an op is already queued for e1 → must queue, not run.
|
||||
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
expect(result.queued).toBe(true);
|
||||
expect(setEmailKeywords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a non-transient (server) error to the caller', async () => {
|
||||
const onlineRun = vi.fn(async () => { throw new Error('JMAP request failed: 403'); });
|
||||
await expect(
|
||||
applyOrQueue({ kind: 'mailboxes', emailId: 'e1', mailboxIds: { a: true } }, onlineRun),
|
||||
).rejects.toThrow('403');
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('queues when the online attempt fails with a connectivity error', async () => {
|
||||
const err = new Error('Network request failed');
|
||||
err.name = 'NetworkError';
|
||||
const onlineRun = vi.fn(async () => { throw err; });
|
||||
const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }, onlineRun);
|
||||
expect(result.queued).toBe(true);
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
it('replays queued ops in order and clears them', async () => {
|
||||
useNetworkStore.setState({ online: false });
|
||||
const store = useOutboxStore.getState();
|
||||
store.enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
store.enqueue({ kind: 'mailboxes', emailId: 'e2', mailboxIds: { trash: true } });
|
||||
store.enqueue({ kind: 'destroy', emailId: 'e3' });
|
||||
|
||||
useNetworkStore.setState({ online: true });
|
||||
await useOutboxStore.getState().flush();
|
||||
|
||||
expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true });
|
||||
expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true });
|
||||
expect(destroyEmails).toHaveBeenCalledWith(['e3']);
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does nothing while offline', async () => {
|
||||
useNetworkStore.setState({ online: false });
|
||||
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
await useOutboxStore.getState().flush();
|
||||
expect(setEmailKeywords).not.toHaveBeenCalled();
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops and retains the op on a transient failure', async () => {
|
||||
useNetworkStore.setState({ online: false });
|
||||
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
useNetworkStore.setState({ online: true });
|
||||
|
||||
const err = new Error('Network request failed');
|
||||
err.name = 'NetworkError';
|
||||
setEmailKeywords.mockRejectedValueOnce(err);
|
||||
|
||||
await useOutboxStore.getState().flush();
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(1);
|
||||
expect(useOutboxStore.getState().entries[0].lastError).toContain('Network');
|
||||
});
|
||||
|
||||
it('drops a poison op after repeated server rejections', async () => {
|
||||
useNetworkStore.setState({ online: false });
|
||||
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } });
|
||||
useNetworkStore.setState({ online: true });
|
||||
|
||||
setEmailKeywords.mockRejectedValue(new Error('JMAP request failed: 400'));
|
||||
|
||||
// MAX_ATTEMPTS is 5; flush bumps one attempt per run.
|
||||
for (let i = 0; i < 5; i++) await useOutboxStore.getState().flush();
|
||||
expect(useOutboxStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+165
-18
@@ -5,8 +5,13 @@ import { useAccountStore } from './account-store';
|
||||
import { useEmailStore } from './email-store';
|
||||
import { useContactsStore } from './contacts-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { useFilterStore } from './filter-store';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { teardownPushNotifications } from '../lib/push-notifications';
|
||||
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
|
||||
import {
|
||||
teardownPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../lib/push-notifications';
|
||||
|
||||
// Persist middleware hydrates asynchronously on cold start. Without this
|
||||
// guard, restoreSession() can read the account-store before AsyncStorage has
|
||||
@@ -35,6 +40,7 @@ export interface AuthState {
|
||||
client: typeof jmapClient | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
@@ -43,10 +49,32 @@ export interface AuthState {
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function resetFeatureStores(): void {
|
||||
useEmailStore.getState().reset();
|
||||
// Wipe ALL cached feature data for ALL accounts. Used for logoutAll where
|
||||
// the user is signing out of everything — we don't want stale snapshots
|
||||
// lingering on disk for accounts that no longer exist.
|
||||
function clearAllFeatureStores(): void {
|
||||
useEmailStore.getState().clearAllAccounts();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
useFilterStore.getState().clearState();
|
||||
}
|
||||
|
||||
// Drop the named account from the email cache, then reset the (per-session,
|
||||
// not yet per-account) contacts and calendar stores. Used by logout when
|
||||
// signing one account out while others remain.
|
||||
function clearAccountFeatureStores(accountId: string | null): void {
|
||||
if (accountId) {
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
} else {
|
||||
useEmailStore.getState().clearAllAccounts();
|
||||
}
|
||||
// Contacts and calendar stores aren't yet keyed by account — the safe
|
||||
// thing on logout is still to wipe them so the next account doesn't see
|
||||
// the previous user's data. Per-account caching for those stores is a
|
||||
// follow-up.
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
useFilterStore.getState().clearState();
|
||||
}
|
||||
|
||||
function refetchFeatureStores(): void {
|
||||
@@ -105,11 +133,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (serverUrl, username, password, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
// Adding an additional account - snapshot/reset feature stores so the
|
||||
// new account starts with a clean slate.
|
||||
// Adding an additional account - snapshot the current account away so
|
||||
// its cache survives, then reset the JMAP client for the new login.
|
||||
// Contacts/calendar are still single-bucket, so wipe those.
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const session = await jmapClient.connect(serverUrl, username, password);
|
||||
@@ -126,6 +156,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
// Swap the email store's active view to the new account so the rest of
|
||||
// this function (and refetchFeatureStores) writes to the right bucket.
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
} catch (err) {
|
||||
@@ -139,13 +172,83 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
loginViaWebmail: async (webmailUrl, opts) => {
|
||||
set({ isLoading: true, error: null });
|
||||
let result;
|
||||
try {
|
||||
result = await runWebmailHandoff(webmailUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof HandoffCancelledError) {
|
||||
// User closed the browser tab — quiet exit, no error banner.
|
||||
set({ isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result.flow === 'password') {
|
||||
// Hand the credentials to the existing password login path so account
|
||||
// registration + feature-store wiring all behave identically to a
|
||||
// manual sign-in.
|
||||
await get().login(result.serverUrl, result.username, result.password, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth — the webmail did the dance against Stalwart and handed us a
|
||||
// token bundle. Bootstrap the JMAP session with Bearer auth and let
|
||||
// ensure/forceRefreshToken keep it alive going forward.
|
||||
try {
|
||||
if (opts?.addAccount && get().isAuthenticated) {
|
||||
jmapClient.reset();
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
}
|
||||
|
||||
const { session, username, accountId } = await jmapClient.connectWithOAuth(
|
||||
result.serverUrl,
|
||||
result.tokens,
|
||||
);
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
accountStore.addAccount({
|
||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
||||
username,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AuthenticationError
|
||||
? 'Authentication rejected by server'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'OAuth sign-in failed';
|
||||
set({ isLoading: false, error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const accountStore = useAccountStore.getState();
|
||||
const currentId = get().activeAccountId;
|
||||
|
||||
// Best-effort: revoke the JMAP PushSubscription and drop the relay
|
||||
// mapping before we lose credentials. Do not abort logout on failure.
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
// Best-effort: revoke this account's JMAP PushSubscription and drop its
|
||||
// relay mapping before we lose credentials. Other logged-in accounts'
|
||||
// push setups remain untouched. Do not abort logout on failure.
|
||||
if (currentId) {
|
||||
await teardownPushNotificationsForAccount(currentId).catch(() => undefined);
|
||||
} else {
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Clear credentials for this account first
|
||||
if (currentId) {
|
||||
@@ -156,7 +259,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
clearAccountFeatureStores(currentId);
|
||||
|
||||
// Switch to next remaining account, if any
|
||||
const remaining = accountStore.accounts;
|
||||
@@ -190,7 +293,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
await teardownPushNotifications().catch(() => undefined);
|
||||
await jmapClient.clearAllCredentials(ids);
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
clearAllFeatureStores();
|
||||
|
||||
for (const id of ids) accountStore.removeAccount(id);
|
||||
|
||||
@@ -217,14 +320,50 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
jmapClient.reset();
|
||||
resetFeatureStores();
|
||||
// Swap the email-store view to the new account *before* the network
|
||||
// round-trip. The previous account's data is tucked into its snapshot;
|
||||
// the new account's data (if previously cached) is restored to the
|
||||
// top-level fields so the EmailListScreen immediately shows the new
|
||||
// account's last-known mail instead of flashing empty. The network
|
||||
// refresh below applies incremental updates on top.
|
||||
useEmailStore.getState().setActiveAccount(accountId);
|
||||
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
// Contacts and calendar stores aren't yet per-account, so they still
|
||||
// need a reset to avoid showing the previous account's data.
|
||||
useContactsStore.getState().reset();
|
||||
useCalendarStore.getState().reset();
|
||||
|
||||
// Load the new account's session. loadAccount overwrites
|
||||
// credentials/session/_accountId itself, so we don't need to reset
|
||||
// jmapClient first. If it fails, restore the previous active account
|
||||
// so we don't leave the user stranded on a half-switched state.
|
||||
const previousActive = get().activeAccountId;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(accountId);
|
||||
if (!ok) {
|
||||
// Credentials missing - evict stale entry and surface error
|
||||
accountStore.removeAccount(accountId);
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
|
||||
accountStore.removeAccount(accountId);
|
||||
useEmailStore.getState().removeAccount(accountId);
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({ isLoading: false, error: 'Session expired for this account' });
|
||||
return;
|
||||
}
|
||||
// NetworkError or anything else - keep the previous active account
|
||||
// intact instead of stranding the user on a half-switched state.
|
||||
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to switch account',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -287,11 +426,18 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Point the email store at the target account before any await — so
|
||||
// the EmailListScreen, which re-renders the moment the persisted state
|
||||
// hydrates, sees the right account's cached emails instead of stale
|
||||
// data from a previous session.
|
||||
useEmailStore.getState().setActiveAccount(target.id);
|
||||
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
// No stored credentials (or corrupt) — genuine logout.
|
||||
accountStore.removeAccount(target.id);
|
||||
useEmailStore.getState().removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
}
|
||||
@@ -325,6 +471,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
// Server reachable but credentials rejected — drop them.
|
||||
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
|
||||
accountStore.removeAccount(target.id);
|
||||
useEmailStore.getState().removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,19 @@ import {
|
||||
createEvent as apiCreateEvent,
|
||||
updateEvent as apiUpdateEvent,
|
||||
deleteEvents as apiDeleteEvents,
|
||||
batchCreateEvents as apiBatchCreateEvents,
|
||||
rsvpEvent as apiRsvpEvent,
|
||||
createCalendar as apiCreateCalendar,
|
||||
} from '../api/calendar';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { expandRecurringEvents } from '../lib/recurrence-expansion';
|
||||
|
||||
// Does the event carry attendees the server should notify over iMIP? Used to
|
||||
// decide whether to set sendSchedulingMessages on create/update/delete.
|
||||
function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boolean {
|
||||
return !!event?.participants && Object.keys(event.participants).length > 0;
|
||||
}
|
||||
|
||||
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
|
||||
|
||||
export interface LoadedRange {
|
||||
@@ -23,6 +32,7 @@ export interface LoadedRange {
|
||||
export interface CalendarState {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
tasks: CalendarEvent[];
|
||||
hiddenCalendarIds: string[];
|
||||
loadedRange: LoadedRange | null;
|
||||
loading: boolean;
|
||||
@@ -38,6 +48,20 @@ export interface CalendarState {
|
||||
createEvent: (event: Partial<CalendarEvent>, calendarId: string) => Promise<CalendarEvent>;
|
||||
updateEvent: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
// Invitations / scheduling
|
||||
rsvpEvent: (
|
||||
eventId: string,
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
) => Promise<void>;
|
||||
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
createCalendar: (name: string, color?: string) => Promise<Calendar>;
|
||||
// Tasks
|
||||
createTask: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void>;
|
||||
updateTask: (id: string, changes: Partial<CalendarEvent>) => Promise<void>;
|
||||
toggleTaskComplete: (id: string) => Promise<void>;
|
||||
deleteTask: (id: string) => Promise<void>;
|
||||
toggleCalendarVisibility: (id: string) => void;
|
||||
setCalendarHidden: (id: string, hidden: boolean) => void;
|
||||
reset: () => void;
|
||||
@@ -66,6 +90,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
(set, get) => ({
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
hiddenCalendarIds: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
@@ -89,6 +114,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
fetchCalendars: async () => {
|
||||
// CalendarScreen fires this on mount; on cold start that happens before
|
||||
// restoreSession has connected jmapClient. Bail rather than surfacing
|
||||
// a "Not authenticated" error - the refetch driven by the auth-store
|
||||
// will run this again once the session is live.
|
||||
if (!jmapClient.isConnected) return;
|
||||
try {
|
||||
const calendars = (await fetchCalendars()) ?? [];
|
||||
set({ calendars });
|
||||
@@ -98,18 +128,21 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
fetchEvents: async (calendarIds, after, before) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
|
||||
const raw = ids.length > 0 ? ((await fetchEvents(ids)) ?? []) : [];
|
||||
// Stalwart returns both Events and Tasks from CalendarEvent/query; tasks
|
||||
// lack `start` and must be filtered out client-side.
|
||||
// Stalwart returns both Events and Tasks from CalendarEvent/query. Events
|
||||
// have a `start`; tasks are surfaced separately so they don't pollute the
|
||||
// grid (they're shown in the task list with their due date instead).
|
||||
const onlyEvents = raw.filter((e) => {
|
||||
const t = (e as { '@type'?: string })['@type'];
|
||||
return (t === 'Event' || t === undefined) && !!e.start;
|
||||
});
|
||||
const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task');
|
||||
const events = expandRecurringEvents(onlyEvents, after, before);
|
||||
set({ events, loadedRange: { after, before }, loading: false });
|
||||
set({ events, tasks, loadedRange: { after, before }, loading: false });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
|
||||
}
|
||||
@@ -142,6 +175,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
@@ -159,7 +193,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
createEvent: async (event, calendarId) => {
|
||||
const created = await apiCreateEvent(event, calendarId);
|
||||
const created = await apiCreateEvent(
|
||||
event,
|
||||
calendarId,
|
||||
hasSchedulingParticipants(event) ? true : undefined,
|
||||
);
|
||||
set({ events: [...get().events, created] });
|
||||
return created;
|
||||
},
|
||||
@@ -168,7 +206,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
// Resolve client-side expanded occurrence IDs back to the master event ID.
|
||||
const storeEvent = get().events.find((e) => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
await apiUpdateEvent(realId, changes);
|
||||
// Notify attendees if either the stored event or the incoming changes
|
||||
// carry participants.
|
||||
const schedule =
|
||||
hasSchedulingParticipants(changes) || hasSchedulingParticipants(storeEvent);
|
||||
await apiUpdateEvent(realId, changes, schedule ? true : undefined);
|
||||
set({
|
||||
events: get().events.map((e) => (e.id === id ? { ...e, ...changes } : e)),
|
||||
});
|
||||
@@ -177,10 +219,89 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
deleteEvent: async (id) => {
|
||||
const storeEvent = get().events.find((e) => e.id === id);
|
||||
const realId = storeEvent?.originalId || id;
|
||||
await apiDeleteEvents([realId]);
|
||||
await apiDeleteEvents(
|
||||
[realId],
|
||||
hasSchedulingParticipants(storeEvent) ? true : undefined,
|
||||
);
|
||||
set({ events: get().events.filter((e) => e.id !== id) });
|
||||
},
|
||||
|
||||
rsvpEvent: async (eventId, participantId, status, replyTo) => {
|
||||
if (!participantId || participantId.includes('..')) {
|
||||
throw new Error('Invalid participant ID');
|
||||
}
|
||||
const storeEvent = get().events.find((e) => e.id === eventId);
|
||||
const realId = storeEvent?.originalId || eventId;
|
||||
await apiRsvpEvent(realId, participantId, status, replyTo);
|
||||
set({
|
||||
events: get().events.map((e) => {
|
||||
if (e.id !== eventId || !e.participants?.[participantId]) return e;
|
||||
return {
|
||||
...e,
|
||||
participants: {
|
||||
...e.participants,
|
||||
[participantId]: { ...e.participants[participantId], participationStatus: status },
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
importEvents: async (events, calendarId) => {
|
||||
if (events.length === 0) return 0;
|
||||
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
|
||||
// already exists; create the rest. (We don't attempt cross-calendar linking
|
||||
// on mobile — a duplicate is simply skipped.)
|
||||
let toCreate = events;
|
||||
try {
|
||||
const existingIds = await queryEvents([], '', '');
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
|
||||
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
|
||||
} catch {
|
||||
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
||||
}
|
||||
if (toCreate.length === 0) return 0;
|
||||
const count = await apiBatchCreateEvents(toCreate, calendarId);
|
||||
await get().refresh();
|
||||
return count;
|
||||
},
|
||||
|
||||
createCalendar: async (name, color) => {
|
||||
const created = await apiCreateCalendar(name, color);
|
||||
set({ calendars: [...get().calendars, created] });
|
||||
return created;
|
||||
},
|
||||
|
||||
createTask: async (task, calendarId) => {
|
||||
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId);
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
updateTask: async (id, changes) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
const realId = task?.originalId || id;
|
||||
await apiUpdateEvent(realId, changes);
|
||||
set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });
|
||||
},
|
||||
|
||||
toggleTaskComplete: async (id) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
const completed = task.progress === 'completed';
|
||||
const next: Partial<CalendarEvent> = completed
|
||||
? { progress: 'in-process', percentComplete: 0 }
|
||||
: { progress: 'completed', percentComplete: 100 };
|
||||
await get().updateTask(id, next);
|
||||
},
|
||||
|
||||
deleteTask: async (id) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
const realId = task?.originalId || id;
|
||||
await apiDeleteEvents([realId]);
|
||||
set({ tasks: get().tasks.filter((t) => t.id !== id) });
|
||||
},
|
||||
|
||||
toggleCalendarVisibility: (id) => {
|
||||
const { hiddenCalendarIds } = get();
|
||||
const next = hiddenCalendarIds.includes(id)
|
||||
@@ -204,6 +325,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
reset: () => set({
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -218,6 +340,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
partialize: (state) => ({
|
||||
calendars: state.calendars,
|
||||
events: state.events,
|
||||
tasks: state.tasks,
|
||||
loadedRange: state.loadedRange,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { CalendarEvent } from '../api/types';
|
||||
import {
|
||||
createCalendar,
|
||||
deleteCalendar,
|
||||
parseCalendarBlob,
|
||||
queryEvents,
|
||||
getEvents,
|
||||
deleteEvents,
|
||||
} from '../api/calendar';
|
||||
import { uploadBytes } from '../api/blob';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
|
||||
export interface CalendarSubscription {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
color?: string;
|
||||
/** The local Stalwart calendar that mirrors this remote feed. */
|
||||
calendarId: string;
|
||||
lastSyncAt: number | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
interface SubscriptionsState {
|
||||
subscriptions: CalendarSubscription[];
|
||||
syncing: Record<string, boolean>;
|
||||
|
||||
addSubscription: (input: { name: string; url: string; color?: string }) => Promise<CalendarSubscription>;
|
||||
removeSubscription: (id: string) => Promise<void>;
|
||||
syncSubscription: (id: string) => Promise<void>;
|
||||
syncAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
// webcal:// is just iCalendar over HTTP(S) — browsers/clients map it to https.
|
||||
function normalizeFeedUrl(url: string): string {
|
||||
return url.trim().replace(/^webcal:\/\//i, 'https://');
|
||||
}
|
||||
|
||||
async function fetchAndParseFeed(url: string): Promise<Partial<CalendarEvent>[]> {
|
||||
const res = await fetch(normalizeFeedUrl(url));
|
||||
if (!res.ok) throw new Error(`Could not fetch feed (HTTP ${res.status})`);
|
||||
const text = await res.text();
|
||||
if (!/BEGIN:VCALENDAR/i.test(text)) {
|
||||
throw new Error('That URL did not return an iCalendar feed');
|
||||
}
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const { blobId } = await uploadBytes(bytes, 'text/calendar');
|
||||
const events = await parseCalendarBlob(blobId);
|
||||
return events.filter((e) => !!e.start);
|
||||
}
|
||||
|
||||
// Reconcile the local calendar with the remote feed: delete events whose UID
|
||||
// disappeared upstream, then import the current set (importEvents dedupes by
|
||||
// UID, so unchanged events are left in place).
|
||||
async function syncFeedIntoCalendar(calendarId: string, url: string): Promise<void> {
|
||||
const parsed = await fetchAndParseFeed(url);
|
||||
const parsedUids = new Set(parsed.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
|
||||
try {
|
||||
const allIds = await queryEvents([], '', '');
|
||||
const all = allIds.length > 0 ? await getEvents(allIds) : [];
|
||||
const stale = all
|
||||
.filter((e) => e.calendarIds?.[calendarId] && e.uid && !parsedUids.has(e.uid))
|
||||
.map((e) => e.id);
|
||||
if (stale.length > 0) await deleteEvents(stale);
|
||||
} catch {
|
||||
// Non-fatal: if we can't enumerate existing events, still import new ones.
|
||||
}
|
||||
|
||||
await useCalendarStore.getState().importEvents(parsed, calendarId);
|
||||
}
|
||||
|
||||
export const useCalendarSubscriptionsStore = create<SubscriptionsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
subscriptions: [],
|
||||
syncing: {},
|
||||
|
||||
addSubscription: async ({ name, url, color }) => {
|
||||
// Create a dedicated local calendar to hold the feed's events.
|
||||
const calendar = await createCalendar(name, color);
|
||||
const sub: CalendarSubscription = {
|
||||
id: `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name,
|
||||
url: url.trim(),
|
||||
color,
|
||||
calendarId: calendar.id,
|
||||
lastSyncAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
set({ subscriptions: [...get().subscriptions, sub] });
|
||||
// Make the new calendar visible immediately.
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
try {
|
||||
set({ syncing: { ...get().syncing, [sub.id]: true } });
|
||||
await syncFeedIntoCalendar(sub.calendarId, sub.url);
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === sub.id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed';
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === sub.id ? { ...s, lastError: message } : s,
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
set({ syncing: { ...get().syncing, [sub.id]: false } });
|
||||
}
|
||||
return sub;
|
||||
},
|
||||
|
||||
removeSubscription: async (id) => {
|
||||
const sub = get().subscriptions.find((s) => s.id === id);
|
||||
set({ subscriptions: get().subscriptions.filter((s) => s.id !== id) });
|
||||
if (sub) {
|
||||
try {
|
||||
await deleteCalendar(sub.calendarId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
await useCalendarStore.getState().refresh();
|
||||
}
|
||||
},
|
||||
|
||||
syncSubscription: async (id) => {
|
||||
const sub = get().subscriptions.find((s) => s.id === id);
|
||||
if (!sub) return;
|
||||
set({ syncing: { ...get().syncing, [id]: true } });
|
||||
try {
|
||||
await syncFeedIntoCalendar(sub.calendarId, sub.url);
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === id ? { ...s, lastSyncAt: Date.now(), lastError: null } : s,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sync failed';
|
||||
set({
|
||||
subscriptions: get().subscriptions.map((s) =>
|
||||
s.id === id ? { ...s, lastError: message } : s,
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
set({ syncing: { ...get().syncing, [id]: false } });
|
||||
}
|
||||
},
|
||||
|
||||
syncAll: async () => {
|
||||
const subs = get().subscriptions;
|
||||
for (const sub of subs) {
|
||||
await get().syncSubscription(sub.id);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'calendar-subscriptions',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({ subscriptions: state.subscriptions }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
createContact as apiCreateContact,
|
||||
updateContact as apiUpdateContact,
|
||||
deleteContacts as apiDeleteContacts,
|
||||
createAddressBook as apiCreateAddressBook,
|
||||
updateAddressBook as apiUpdateAddressBook,
|
||||
deleteAddressBook as apiDeleteAddressBook,
|
||||
getContactsInBook,
|
||||
} from '../api/contacts';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import {
|
||||
@@ -20,6 +24,10 @@ import {
|
||||
|
||||
const SELECTED_CATEGORY_STORAGE_KEY = 'webmail:contacts:category:v1';
|
||||
|
||||
// Dedicated JMAP address book that backs the "trusted senders" allow-list.
|
||||
// Mirrors the webmail so the same book is shared across clients.
|
||||
export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
|
||||
|
||||
export type ContactCategory =
|
||||
| { type: 'all' }
|
||||
| { type: 'addressBook'; addressBookId: string }
|
||||
@@ -35,6 +43,13 @@ export interface ContactsState {
|
||||
error: string | null;
|
||||
hydrated: boolean;
|
||||
|
||||
// Trusted senders are stored as contacts in a dedicated JMAP address book so
|
||||
// the allow-list syncs across devices (matches the webmail behavior).
|
||||
trustedSendersBookId: string | null;
|
||||
trustedSenderEmails: string[];
|
||||
trustedSendersLoaded: boolean;
|
||||
trustedSendersLoading: boolean;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
fetchAddressBooks: () => Promise<void>;
|
||||
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
|
||||
@@ -45,11 +60,25 @@ export interface ContactsState {
|
||||
updateContact: (id: string, changes: Partial<ContactCard>) => Promise<void>;
|
||||
deleteContact: (id: string) => Promise<void>;
|
||||
bulkDelete: (ids: string[]) => Promise<void>;
|
||||
importContacts: (contacts: Partial<ContactCard>[], addressBookId: string) => Promise<number>;
|
||||
|
||||
addContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
|
||||
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
|
||||
|
||||
// Address book management
|
||||
createAddressBook: (name: string) => Promise<AddressBook>;
|
||||
renameAddressBook: (id: string, name: string) => Promise<void>;
|
||||
deleteAddressBook: (id: string) => Promise<void>;
|
||||
|
||||
// Trusted senders address book. Passive loads (createIfMissing=false) only
|
||||
// read an existing book; the book is created lazily on the first add.
|
||||
loadTrustedSendersBook: (createIfMissing?: boolean) => Promise<void>;
|
||||
addToTrustedSendersBook: (email: string) => Promise<void>;
|
||||
removeFromTrustedSendersBook: (email: string) => Promise<void>;
|
||||
isTrustedAddressBookSender: (email: string) => boolean;
|
||||
|
||||
setSelectedCategory: (category: ContactCategory) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -70,6 +99,11 @@ export const useContactsStore = create<ContactsState>()(
|
||||
error: null,
|
||||
hydrated: false,
|
||||
|
||||
trustedSendersBookId: null,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
@@ -87,6 +121,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
fetchAddressBooks: async () => {
|
||||
// No-op until a JMAP session exists. ContactsScreen fires this from a
|
||||
// mount-time useEffect, which on cold start runs before restoreSession
|
||||
// has set up the client.
|
||||
if (!jmapClient.isConnected) return;
|
||||
try {
|
||||
const addressBooks = (await fetchAddressBooks()) ?? [];
|
||||
set({ addressBooks });
|
||||
@@ -96,6 +134,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
fetchContacts: async (filter) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const ids = (await queryContacts(filter)) ?? [];
|
||||
@@ -111,6 +150,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
@@ -154,6 +194,22 @@ export const useContactsStore = create<ContactsState>()(
|
||||
set({ contacts: get().contacts.filter((c) => !idSet.has(c.id)) });
|
||||
},
|
||||
|
||||
importContacts: async (incoming, addressBookId) => {
|
||||
let imported = 0;
|
||||
for (const contact of incoming) {
|
||||
try {
|
||||
// Strip the temporary client-side id; the server assigns a real one.
|
||||
const { id: _id, addressBookIds: _abIds, ...data } = contact;
|
||||
const created = await apiCreateContact(data, addressBookId);
|
||||
set({ contacts: [...get().contacts, created] });
|
||||
imported++;
|
||||
} catch (err) {
|
||||
console.warn('[contacts-store] import failed for one contact', err);
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
},
|
||||
|
||||
addContactKeyword: async (id, keyword) => {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) return;
|
||||
@@ -170,12 +226,115 @@ export const useContactsStore = create<ContactsState>()(
|
||||
await get().updateContact(id, { keywords: rest });
|
||||
},
|
||||
|
||||
addKeywordToContacts: async (ids, keyword) => {
|
||||
const kw = keyword.trim();
|
||||
if (!kw) return;
|
||||
for (const id of ids) {
|
||||
await get().addContactKeyword(id, kw);
|
||||
}
|
||||
},
|
||||
|
||||
moveContactsToAddressBook: async (ids, addressBookId) => {
|
||||
for (const id of ids) {
|
||||
await get().updateContact(id, { addressBookIds: { [addressBookId]: true } });
|
||||
}
|
||||
},
|
||||
|
||||
createAddressBook: async (name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error('Address book name is required');
|
||||
const book = await apiCreateAddressBook(trimmed);
|
||||
set({ addressBooks: [...get().addressBooks, book] });
|
||||
return book;
|
||||
},
|
||||
|
||||
renameAddressBook: async (id, name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
await apiUpdateAddressBook(id, { name: trimmed });
|
||||
set({
|
||||
addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
|
||||
});
|
||||
},
|
||||
|
||||
deleteAddressBook: async (id) => {
|
||||
await apiDeleteAddressBook(id);
|
||||
set({
|
||||
addressBooks: get().addressBooks.filter((b) => b.id !== id),
|
||||
// Drop the deleted book id from any cached contact so the UI doesn't
|
||||
// keep filtering against a book that no longer exists.
|
||||
contacts: get().contacts.map((c) => {
|
||||
if (!c.addressBookIds?.[id]) return c;
|
||||
const { [id]: _gone, ...rest } = c.addressBookIds;
|
||||
return { ...c, addressBookIds: rest };
|
||||
}),
|
||||
});
|
||||
const category = get().selectedCategory;
|
||||
if (category.type === 'addressBook' && category.addressBookId === id) {
|
||||
get().setSelectedCategory({ type: 'all' });
|
||||
}
|
||||
},
|
||||
|
||||
loadTrustedSendersBook: async (createIfMissing = false) => {
|
||||
if (!jmapClient.isConnected || get().trustedSendersLoading) return;
|
||||
set({ trustedSendersLoading: true });
|
||||
try {
|
||||
const books = (await fetchAddressBooks()) ?? [];
|
||||
let book = books.find((b) => b.name === TRUSTED_SENDERS_BOOK_NAME);
|
||||
if (!book) {
|
||||
if (!createIfMissing) {
|
||||
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
return;
|
||||
}
|
||||
book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
|
||||
}
|
||||
const bookId = book.id;
|
||||
const cards = await getContactsInBook(bookId);
|
||||
const emails = cards.flatMap((c) =>
|
||||
c.emails ? Object.values(c.emails).map((e) => e.address.toLowerCase().trim()) : [],
|
||||
).filter(Boolean);
|
||||
set({
|
||||
trustedSendersBookId: bookId,
|
||||
trustedSenderEmails: emails,
|
||||
trustedSendersLoaded: true,
|
||||
trustedSendersLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[contacts-store] load trusted senders failed', err);
|
||||
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addToTrustedSendersBook: async (email) => {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
if (!normalized || get().trustedSenderEmails.includes(normalized)) return;
|
||||
|
||||
let bookId = get().trustedSendersBookId;
|
||||
if (!bookId) {
|
||||
await get().loadTrustedSendersBook(true);
|
||||
bookId = get().trustedSendersBookId;
|
||||
}
|
||||
if (!bookId) throw new Error('Could not find or create the trusted senders address book');
|
||||
|
||||
await apiCreateContact({ emails: { email: { address: normalized } } }, bookId);
|
||||
set({ trustedSenderEmails: [...get().trustedSenderEmails, normalized] });
|
||||
},
|
||||
|
||||
removeFromTrustedSendersBook: async (email) => {
|
||||
const normalized = email.toLowerCase().trim();
|
||||
const bookId = get().trustedSendersBookId;
|
||||
if (!bookId) return;
|
||||
const cards = await getContactsInBook(bookId);
|
||||
const match = cards.find((c) =>
|
||||
c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase().trim() === normalized),
|
||||
);
|
||||
if (match) await apiDeleteContacts([match.id]);
|
||||
set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== normalized) });
|
||||
},
|
||||
|
||||
isTrustedAddressBookSender: (email) =>
|
||||
get().trustedSenderEmails.includes(email.toLowerCase().trim()),
|
||||
|
||||
setSelectedCategory: (category) => {
|
||||
set({ selectedCategory: category });
|
||||
persistCategory(category);
|
||||
@@ -187,6 +346,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
selectedCategory: { type: 'all' },
|
||||
loading: false,
|
||||
error: null,
|
||||
trustedSendersBookId: null,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
@@ -213,9 +376,9 @@ export function selectGroups(state: ContactsState): ContactCard[] {
|
||||
return state.contacts.filter(isGroup);
|
||||
}
|
||||
|
||||
export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: string; count: number }> {
|
||||
export function selectKeywordsUsed(contacts: ContactCard[]): Array<{ keyword: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const contact of state.contacts) {
|
||||
for (const contact of contacts) {
|
||||
for (const kw of getContactKeywords(contact)) {
|
||||
counts.set(kw, (counts.get(kw) || 0) + 1);
|
||||
}
|
||||
@@ -225,10 +388,13 @@ export function selectKeywordsUsed(state: ContactsState): Array<{ keyword: strin
|
||||
.sort((a, b) => a.keyword.localeCompare(b.keyword));
|
||||
}
|
||||
|
||||
export function selectAddressBooksWithCount(state: ContactsState): Array<AddressBook & { count: number }> {
|
||||
return state.addressBooks.map((book) => ({
|
||||
export function selectAddressBooksWithCount(
|
||||
addressBooks: AddressBook[],
|
||||
contacts: ContactCard[],
|
||||
): Array<AddressBook & { count: number }> {
|
||||
return addressBooks.map((book) => ({
|
||||
...book,
|
||||
count: state.contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
|
||||
count: contacts.filter((c) => !isGroup(c) && c.addressBookIds?.[book.id]).length,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+898
-86
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user