Compare commits

..
121 Commits
Author SHA1 Message Date
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
Linus Rath 910e5418e9 chore: update version number to 0.1.49 2026-06-12 17:16:03 +02:00
Linus Rath a019d9e775 chore: update version number to 0.1.48 2026-06-12 17:15:53 +02:00
Linus Rath e6d47b6a16 feat: shared calendars, custom recurrence editor, and file sharing 2026-06-12 17:15:50 +02:00
Linus Rath b10f61a642 chore: update version number to 0.1.47 2026-06-12 15:26:57 +02:00
Linus Rath 752aa719d8 feat: pinch zoom and pan for email body content 2026-06-12 15:26:42 +02:00
Linus Rath 5884a18979 refactor: move date/time/size meta from subject row to sender block 2026-06-11 16:33:59 +02:00
Linus Rath 3ccc83e7ce chore: update version number to 0.1.46 2026-06-11 16:24:55 +02:00
Linus Rath 5dc4ea1a45 refactor: replace three-pane pager with FlatList page-snapping pager 2026-06-11 16:24:43 +02:00
Linus Rath 1bd476e052 chore: update version number to 0.1.45 2026-06-11 15:11:28 +02:00
Linus Rath 41793455f7 fix: seamless email swipe transitions with skeleton loading placeholder 2026-06-11 15:11:09 +02:00
Linus Rath d99cbf1dbb chore: update version number to 0.1.44 2026-06-11 14:39:39 +02:00
Linus Rath 1afd2185d5 feat: three-pane pager for seamless email swipe navigation 2026-06-11 14:39:30 +02:00
Linus Rath 5aa1c88b1e chore: update version number to 0.1.43 2026-06-05 19:25:47 +02:00
Linus Rath ac95a2bc39 feat: QR code sign-in 2026-06-05 19:25:00 +02:00
Linus Rath e75aefeb4f chore: update version number to 0.1.42 2026-06-05 16:38:13 +02:00
Linus Rath 70863e0923 feat: swipe navigation between emails and dark-mode emoji/background re-inversion 2026-06-05 16:35:06 +02:00
Linus Rath b6ecf07369 feat: customizable reader quick actions and shrink-to-fit email scaling 2026-06-05 16:21:00 +02:00
Linus Rath bf5d75d5e9 chore: update version number to 0.1.41 2026-05-30 17:25:51 +02:00
Linus Rath 53520a4236 fix: prevent infinite render loop in contacts selectors 2026-05-30 17:25:47 +02:00
Linus Rath c55f78b5a7 chore: update version number to 0.1.40 2026-05-30 15:33:52 +02:00
Linus Rath 5d47f47c8e feat: add Sieve filters, account security (TOTP), outbox, and EML import 2026-05-30 15:33:41 +02:00
Linus Rath d16f04e818 chore: update version number to 0.1.39 2026-05-29 15:34:50 +02:00
Linus Rath d616943c25 feat: add unified inbox, scheduled send, iCal import/subscriptions, tasks, and contact tagging/import 2026-05-29 15:34:39 +02:00
Linus Rath cf4e3955bf chore: update version number to 0.1.38 2026-05-19 00:45:52 +02:00
Linus Rath bf20c0af58 feat: implement OAuth token handling and webmail-mediated login flow 2026-05-19 00:45:42 +02:00
Linus Rath 67f7f83621 chore: update version number to 0.1.37 2026-05-19 00:08:27 +02:00
Linus Rath 086d398058 feat: implement webmail-mediated login flow and remove OAuth dependencies 2026-05-19 00:05:51 +02:00
Linus Rath 373a1a60d8 chore: update version number to 0.1.36 2026-05-18 23:32:59 +02:00
Linus Rath f71f219187 feat: implement OAuth login flow and enhance authentication handling 2026-05-18 23:32:50 +02:00
Linus Rath 77b7be03d8 chore: update version number to 0.1.35 2026-05-18 21:52:49 +02:00
Linus Rath 9dd9ce8105 feat: enhance email store and offline cache to support account switching and data isolation 2026-05-18 21:52:35 +02:00
Linus Rath dd64931be8 chore: update version number to 0.1.34 2026-05-18 20:11:20 +02:00
Linus Rath ac234f288e Refactor email store to support incremental sync 2026-05-18 20:05:03 +02:00
Linus Rath 81749dd89c chore: update version number to 0.1.33 2026-05-15 17:26:10 +02:00
Linus Rath beef086d4c chore: update version number to 0.1.32 2026-05-15 17:26:00 +02:00
Linus Rath cfd67398de feat: add capability hooks for calendar, contacts, and files 2026-05-15 17:25:55 +02:00
Linus Rath 2b15d2dc50 chore: update version number to 0.1.31 2026-05-15 02:10:55 +02:00
Linus Rath 9bf2ecdf27 fix: resolve "Not authenticated" race on cold start and account switch 2026-05-15 00:31:57 +02:00
Linus Rath 67db8a2ac3 chore: update version number to 0.1.30 2026-05-14 13:51:00 +02:00
Linus Rath 6846d859fd feat: implement multi-account support for push notifications and enhance teardown logic 2026-05-14 13:50:52 +02:00
Linus Rath 3bdca0133e chore: update version number to 0.1.29 2026-05-14 12:57:18 +02:00
Linus Rath 1858d08dd0 feat: enhance notification handling with accountId support and improve file management functions 2026-05-14 12:57:03 +02:00
Linus Rath 427d163dc0 chore: update version number to 0.1.28 2026-05-08 20:04:32 +02:00
Linus Rath 7e34ba42ef feat: implement in-memory upload with uploadBytes function and integrate into createFolder 2026-05-08 20:04:25 +02:00
Linus Rath 886a64171d chore: update version number to 0.1.27 2026-05-08 13:18:24 +02:00
Linus Rath 760bf01f4d fix: defer expo-document-picker require to avoid native-module crash 2026-05-08 13:18:17 +02:00
Linus Rath b6d87093b9 chore: update version number to 0.1.26 2026-05-08 10:24:43 +02:00
Linus Rath 2a3b1f90dc feat: add upload, preview, download, selection, folder creation 2026-05-08 10:24:36 +02:00
Linus Rath 2ed9c29bd5 fix: clear status bar inset on OfflineCacheBanner 2026-05-08 01:36:49 +02:00
Linus Rath 455f26e90a chore: update version number to 0.1.25 2026-05-08 01:03:20 +02:00
Linus Rath e9b93bc6e5 fix: import ByteArrayOutputStream in BulwarkFcmModule 2026-05-08 01:03:11 +02:00
Linus Rath c21661ac7a feat: finish Files tab with folder nav, grid view, and refresh 2026-05-08 00:57:51 +02:00
Linus Rath 00be851611 chore: update version number to 0.1.24 2026-05-08 00:51:23 +02:00
Linus Rath 71ff8930a2 fix: apply unread/starred filters in mailbox queries 2026-05-08 00:50:27 +02:00
Linus Rath 29b06a3a4f security: fix push SSE breakage, harden APK updates and email rendering 2026-05-05 22:06:07 +02:00
Linus Rath 2911b8e7ac chore: update version number to 0.1.23 2026-05-03 23:23:15 +02:00
Linus Rath 3d4779a689 feat: enhance offline email handling with caching and mailbox seeding 2026-05-03 19:05:30 +02:00
Linus Rath 641fde0505 chore: update version number to 0.1.22 2026-05-03 18:40:08 +02:00
Linus Rath 6d5195d9fa feat: implement offline mail caching and sync functionality 2026-05-03 18:40:01 +02:00
Linus Rath 40f9da244f chore: update version number to 0.1.21 2026-05-03 17:38:59 +02:00
Linus Rath c7ea4d757c feat: implement client certificate support with secure fetch and alias management 2026-05-03 17:33:54 +02:00
Linus Rath 1d89cb38cf chore: update version number to 0.1.20 2026-05-03 17:15:31 +02:00
Linus Rath abbbff2afa feat: implement network recovery for authenticated users and enhance error handling in auth store 2026-05-03 17:15:17 +02:00
Linus Rath 0a402225f1 chore: update version number to 0.1.19 2026-05-03 16:58:13 +02:00
Linus Rath 9bfeb027de refactor: simplify sharing logic by removing unnecessary shareableUri function 2026-05-03 16:58:04 +02:00
Linus Rath 9a76b22d86 chore: update version number to 0.1.18 2026-05-03 16:12:22 +02:00
Linus Rath b254573237 feat: implement dynamic email limit based on user settings and add email notifications check 2026-05-03 16:08:11 +02:00
Linus Rath 2409a51952 refactor: streamline authorization header access and improve blob upload handling 2026-05-03 16:04:01 +02:00
Linus Rath 37e1b93c48 chore: update version number to 0.1.17 2026-05-03 15:33:12 +02:00
Linus Rath a656a6b56a feat: enhance email composition and management features 2026-05-03 15:32:59 +02:00
Linus Rath 20cdc282c8 chore: update version number to 0.1.16 2026-05-03 14:41:22 +02:00
Linus Rath cec98d89db refactor: refactor settings components 2026-05-03 13:38:29 +02:00
Linus Rath 8fa8597e9f chore: update version number to 0.1.15 2026-05-02 21:12:12 +02:00
Linus Rath 57f15f6f2e feat: improve keyboard handling in ComposeScreen for better visibility of format bar 2026-05-02 21:12:03 +02:00
Linus Rath cd3fdfa217 chore: update version number to 0.1.14 2026-05-02 17:22:14 +02:00
Linus Rath f8064d4544 feat: add swipe mode settings and enhance swipeable row functionality 2026-05-02 17:22:05 +02:00
Linus Rath a8bb83ffe0 chore: update version number to 0.1.13 2026-05-02 14:44:00 +02:00
Linus Rath c37256c54e feat: enhance attachment handling in ComposeScreen with file picker and updated UI options 2026-05-02 14:43:45 +02:00
Linus Rath e1d318ebc1 feat: add restoreEmailMailboxes mock to email store tests 2026-05-02 14:32:15 +02:00
Linus Rath 2090480bf4 feat: implement undo functionality for email actions with snackbar notification 2026-05-02 14:31:50 +02:00
Linus Rath 329c8a2f0b feat: add swipe actions for pinning and moving emails; enhance archive functionality 2026-05-02 14:27:15 +02:00
Linus Rath 49d1fc62a8 chore: update version number to 0.1.12 2026-04-27 17:14:32 +02:00
Linus Rath d5a336519a feat: add rich text editor component and enhance email thread screen with attachment handling
- Implemented a new RichTextEditor component for composing and editing rich text emails.
- Enhanced EmailThreadScreen to support downloading attachments with loading indicators.
- Added functionality to expand/collapse attachment list in EmailThreadScreen.
- Created utility functions for building initial HTML for email composition and converting HTML to plain text.
- Added tests for HTML composition and conversion utilities.
2026-04-27 17:14:22 +02:00
Linus Rath 9c7654acb2 chore: update version number to 0.1.11 2026-04-27 13:55:25 +02:00
Linus Rath 346a71de84 feat: enhance email composition with markdown support and attachment handling
- Implement markdown formatting in the email composer, allowing users to use bold, italic, lists, and blockquotes.
- Add functionality to upload and manage attachments, including inline images.
- Introduce a new compose-format utility for converting markdown to HTML.
- Update the ComposeScreen to handle attachments and formatting actions.
- Refactor ContactDetailScreen and EmailListScreen for improved code clarity and performance.
- Add tests for markdown formatting and selection wrapping functionalities.
2026-04-27 13:55:16 +02:00
Linus Rath 9a278f1be3 chore: update version number to 0.1.10 2026-04-27 13:30:57 +02:00
Linus Rath cf3f919fa2 feat: enhance SettingsScreen with dynamic localization and theming
- Integrated useColors hook for dynamic theming in SettingsScreen.
- Updated tab labels and titles to use localized strings.
- Refactored styles to utilize theme palette for consistent styling.
- Improved locale subscription to re-render labels on language change.

feat: implement useSheetDrag hook for bottom sheet gesture handling

- Added useSheetDrag hook to manage drag-to-dismiss functionality for bottom sheets.
- Configured pan responder for handling drag gestures and dismissing the sheet based on distance and velocity.

refactor: restructure color and token management

- Introduced useColors and useResolvedTheme hooks for better theme management.
- Separated light and dark color palettes for improved maintainability.
- Updated tokens to reflect new theming structure and ensure consistency across components.
2026-04-27 13:30:45 +02:00
Linus Rath 792b028b7a chore: update version number to 0.1.9 2026-04-27 11:20:53 +02:00
Linus Rath ac84f13b22 feat: add network connectivity management and offline banner component 2026-04-27 11:20:23 +02:00
Linus Rath 571b28a9f2 chore: update version number to 0.1.8 2026-04-27 00:16:35 +02:00
Linus Rath a95783723a feat: implement keyword management and layout settings for email interactions 2026-04-27 00:16:25 +02:00
Linus Rath b36e065ebe feat: add content senders and language settings components
- Implemented ContentSendersSettings component for managing trusted senders.
- Added LanguageSettings component to allow users to select their preferred language.
- Introduced minimal i18n support with locale detection and translation functions.
- Created a locale store using Zustand for managing locale state and persistence with AsyncStorage.
- Added a script to sync locale JSON files from the parent webmail repository.
2026-04-27 00:06:58 +02:00
Linus Rath 0d7bf3204a chore: update version number to 0.1.7 2026-04-26 22:23:26 +02:00
Linus Rath a76e0fe8c0 feat: enhance APK installation process with progress tracking and UI updates 2026-04-26 22:22:52 +02:00
Linus Rath cee4193e58 chore: update version number to 0.1.6 2026-04-26 21:59:14 +02:00
Linus Rath 5ac2a142b5 feat: update Android build configuration to use arm64-v8a architecture and improve caching mechanism 2026-04-26 21:58:08 +02:00
Linus Rath e73dc4e5ba feat: add GitHub release creation after version bump 2026-04-26 21:40:25 +02:00
Linus Rath 892ff33f14 chore: update version number to 0.1.5 2026-04-26 21:38:21 +02:00
Linus Rath 7e42cb85ea feat: add EmailSourceScreen for viewing and sharing email source, update navigation and types 2026-04-26 21:38:07 +02:00
Linus Rath 02c590beb3 chore: update version to 0.1.4 in VERSION, package.json, and package-lock.json 2026-04-26 21:20:18 +02:00
Linus Rath e5bbfeb26e refactor: optimize EmailRow component and improve row press handling in EmailListScreen 2026-04-26 21:19:08 +02:00
Linus Rath 373c66f2ba feat: implement navigation between emails in EmailThreadScreen with previous and next buttons 2026-04-26 20:55:29 +02:00
Linus Rath 07a18595aa Refactor SettingsScreen: Consolidate settings tabs, improve layout, and enhance user experience 2026-04-26 20:49:17 +02:00
Linus Rath 2309845274 feat: add AdvancedSettings and EmailSettings components for enhanced user configuration 2026-04-26 20:49:06 +02:00
Linus Rath 04815c4e60 feat: add back button handling in SettingsScreen to manage tab selection 2026-04-26 20:31:44 +02:00
Linus Rath fbfb887e0a chore: update version number to 0.1.3 2026-04-26 20:28:29 +02:00
Linus Rath 10db4c503a feat: update APK installation process to use expo-intent-launcher and improve versioning 2026-04-26 20:27:30 +02:00
Linus Rath abbda7a74d chore: update version number to 0.1.2 2026-04-26 17:42:49 +02:00
Linus Rath fe7d9bdf09 feat: add more actions and move functionality to EmailThreadScreen 2026-04-26 17:40:53 +02:00
Linus Rath a8a0d475db chore: update readme 2026-04-26 04:57:10 +02:00
Linus Rath dd2f941dbb chore: update version number to 0.1.1 2026-04-26 04:53:20 +02:00
Linus Rath 2fa48e8e05 feat: enhance Android release workflow with caching and tag resolution 2026-04-26 04:53:01 +02:00
Linus Rath b274d4b76b feat: implement update management system with update banner and settings 2026-04-26 04:50:03 +02:00
Linus Rath d5710128fd chore: ensure gradlew is executable before building release APK 2026-04-26 04:19:05 +02:00
Linus Rath 96bddad289 chore: update gradlew file permissions to executable 2026-04-26 04:18:58 +02:00
211 changed files with 76701 additions and 6635 deletions
+56 -3
View File
@@ -28,9 +28,41 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- uses: android-actions/setup-android@v3
- name: Cache Android NDK / CMake
uses: actions/cache@v4
with:
path: |
~/.android/build-cache
/usr/local/lib/android/sdk/ndk
/usr/local/lib/android/sdk/cmake
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
restore-keys: android-sdk-${{ runner.os }}-
- name: Compute native cache key input
run: |
node -e "
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
delete lock.version;
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
"
- name: Cache native CMake build outputs
uses: actions/cache@v4
with:
path: |
android/app/.cxx
android/app/build/intermediates/cxx
android/app/build/intermediates/merged_native_libs
android/app/build/intermediates/stripped_native_libs
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
restore-keys: cxx-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
@@ -54,7 +86,9 @@ jobs:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
run: |
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon
- name: Rename APK with version
id: artifact
@@ -72,9 +106,28 @@ jobs:
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
- name: Resolve target release tag
id: target_tag
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
TAG="${{ github.event.inputs.attach_to_release_tag }}"
else
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then
echo "Will attach APK to release: $TAG"
else
echo "No release tag resolved - APK is in workflow artifacts only."
fi
- name: Attach APK to release
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_to_release_tag != '')
if: steps.target_tag.outputs.tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.attach_to_release_tag }}
tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.path }}
+165 -26
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -14,34 +15,61 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreenNew';
import ContactsScreen from './src/screens/ContactsScreenNew';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsScreen from './src/screens/ContactsScreen';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreenNew2';
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 { colors, spacing, typography } from './src/theme/tokens';
import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
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,
@@ -50,30 +78,37 @@ function navigateToNotificationTap(payload: NotificationTapPayload): void {
}
function LoadingScreen({ message }: { message: string }) {
const c = useColors();
return (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} />
<Text style={styles.loadingText}>{message}</Text>
<View style={[styles.loadingContainer, { backgroundColor: c.background }]}>
<ActivityIndicator color={c.primary} />
<Text style={[styles.loadingText, { color: c.textSecondary }]}>{message}</Text>
</View>
);
}
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
const c = useColors();
const mailboxes = useEmailStore((state) => state.mailboxes);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<OfflineCacheBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
// Matches webmail horizontal NavigationRail: bg-background, border-t border-border,
// active uses foreground (white), inactive uses muted-foreground.
tabBarActiveTintColor: colors.text,
tabBarInactiveTintColor: colors.textSecondary,
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.border,
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
elevation: 0,
shadowOpacity: 0,
@@ -91,8 +126,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.error,
color: colors.primaryForeground,
backgroundColor: c.error,
color: c.primaryForeground,
fontSize: 10,
fontWeight: '700',
minWidth: 16,
@@ -122,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
@@ -129,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
@@ -136,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
@@ -147,6 +203,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
{() => <SettingsScreen onLogout={logout} />}
</Tab.Screen>
</Tab.Navigator>
</View>
);
}
@@ -155,6 +212,15 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession);
// Resolve the user's theme preference to a concrete light/dark style for the
// system status bar. The rest of the app's colors are still hard-coded dark
// until the StyleSheet migration to a theme-aware `useColors` hook lands.
const themePref = useSettingsStore((state) => state.theme);
const systemScheme = useColorScheme();
const resolvedScheme: 'light' | 'dark' =
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
// Persisted active account is the signal that the user was already signed
// in on the previous launch. When present we render the main UI with the
// cached mail list instead of the "Restoring session" spinner; the real
@@ -169,10 +235,67 @@ export default function App() {
React.useEffect(() => {
void useSettingsStore.getState().hydrate();
void useLocaleStore.getState().hydrate();
return useNetworkStore.getState().init();
}, []);
// When the network flips back on while we're authenticated-but-offline
// (no live JMAP session), retry the session so the user lands back on
// live data without needing to relaunch.
React.useEffect(() => {
if (!isAuthenticated) return;
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && !useAuthStore.getState().session) {
void useAuthStore.getState().retrySession();
}
});
}, [isAuthenticated]);
React.useEffect(() => {
let cancelled = false;
void (async () => {
const store = useUpdatesStore.getState();
await store.hydrate();
if (cancelled) return;
if (useUpdatesStore.getState().autoCheck) {
await useUpdatesStore.getState().checkNow();
}
})();
return () => { cancelled = true; };
}, []);
// Offline mail cache: hydrate the cache index on launch, and kick off a
// background sync once we have a live JMAP session and the user has the
// feature enabled. Re-runs whenever the user changes the days window.
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
}, []);
React.useEffect(() => {
if (!offlineCacheEnabled || !haveLiveSession) return;
// Slight delay so cold start doesn't compete with the inbox load.
const t = setTimeout(() => {
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
}, 2000);
return () => clearTimeout(t);
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
// Drain the offline action queue (outbox) as soon as we have a live session,
// and again whenever the network comes back. The flush itself no-ops when
// there's nothing queued or the client isn't ready.
React.useEffect(() => {
if (!haveLiveSession) return;
void useOutboxStore.getState().flush();
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online) void useOutboxStore.getState().flush();
});
}, [haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background — the FirebaseMessagingService also posts the system
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
@@ -192,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 () => {
@@ -206,12 +329,27 @@ export default function App() {
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates.
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down THIS account's subscription so
// notifications stop arriving for it. Other logged-in accounts keep their
// setups intact.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
if (activeAccountId) {
await teardownPushNotificationsForAccount(activeAccountId).catch(
() => undefined,
);
}
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl) return;
try {
@@ -237,7 +375,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
@@ -286,7 +424,7 @@ export default function App() {
if (!hasRestoredSession && !hasPersistedAccount) {
return (
<>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<LoadingScreen message="Loading..." />
</>
);
@@ -295,7 +433,7 @@ export default function App() {
if (hasRestoredSession && !isAuthenticated) {
return (
<>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<LoginScreen />
</>
);
@@ -303,10 +441,11 @@ export default function App() {
return (
<NavigationContainer ref={navigationRef}>
<StatusBar style="light" />
<StatusBar style={statusBarStyle} />
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
@@ -325,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={{
@@ -350,12 +491,10 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
});
-220
View File
@@ -1,220 +0,0 @@
# React Native Calendar — Parity Plan
Bring `repos/react-native/` calendar to parity with the webmail calendar at
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
utility libs; mobile port targets ~2,500 lines with feature parity on the
essentials and explicit deferrals for things that don't fit a phone UX.
## Current state
- `src/screens/CalendarScreenNew.tsx` — month grid + agenda-for-selected-day,
all rendering `MOCK_EVENTS`. Doesn't touch the store.
- `src/stores/calendar-store.ts` — CRUD wiring exists (`fetchCalendars`,
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
`handleStateChange`, no per-calendar visibility, no range tracking.
- `src/api/calendar.ts` — JMAP methods wired (`Calendar/get`,
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
- `src/api/types.ts` — `CalendarEvent` and `Calendar` types match webmail.
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
## In scope
### Phase 1 — Data layer (foundation for everything else)
**`src/stores/calendar-store.ts`** — expand to match webmail's store semantics:
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
`CalendarSidebar` toggle state).
- `loadedRange: { after: string; before: string } | null` — track what's fetched
so view changes don't re-fetch unnecessarily.
- `ensureRange(after, before)` — fetch only if the requested window isn't
covered by `loadedRange`; expand the loaded range in place instead of
replacing events wholesale, so switching Month → Week doesn't drop data.
- `handleStateChange(change)` — subscribe to `Calendar` and `CalendarEvent`
typeState keys; on push, refetch calendars and re-query the current window.
Already referenced from `App.tsx` push handler — this finally implements it.
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
**`src/lib/calendar-utils.ts`** (new) — port from webmail's `lib/calendar-utils.ts`:
- `parseLocalDateTime(iso)` — JSCalendar start strings are floating local time
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
- `parseDuration(iso8601)` — `PT1H30M` → milliseconds.
- `eventTimeRange(event)` — returns `{ start: Date, end: Date, allDay: boolean }`
handling `showWithoutTime`.
- `eventsOnDay(events, day)` — day overlap check (not just start-matches-day).
- `getCalendarColor(cal)` — fallback to a deterministic palette when `color`
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
**`src/lib/recurrence-expansion.ts`** (new) — minimal port of webmail's RRULE
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
Mobile port: same surface, same tests. This is the hard part — allocate ~400
lines. Drives correct rendering of recurring events across the visible window.
### Phase 2 — Views
**`src/screens/CalendarScreenNew.tsx`** — replace mocks, gain view switching:
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
- Header shows month/week label; prev/next shifts by the current view's step.
- "Today" button resets both `currentDate` and `selectedDate`.
- FAB opens create modal with `start = selectedDate at next half hour`.
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
~180 lines) — real events, up to 3 colored dots per day, "+N" indicator when
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
modal with that date.
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) — port of
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
- All-day strip at top.
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
overlapping events (port webmail's stacking algorithm).
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
- Horizontal swipe → prev/next week (react-native-gesture-handler already
available via react-navigation).
- *Deferred*: drag-to-reschedule, drag-to-resize. Phone targets are too small
for it to be better than tap-edit.
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) — port of
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
days forward. Taps open detail modal.
### Phase 3 — Event interaction
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) — bottom
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
on web — phone version drops the hover card chrome and the inline-edit fields):
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
- Sections: location, description (linkified), participants with RSVP status,
recurrence summary (e.g. "Weekly on Mon, Wed — until 2026-06-01"), reminders.
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
only" / "This and following" / "All events").
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) — full-screen
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
- Title, calendar picker (color swatches), all-day toggle.
- Start/end date+time pickers (`@react-native-community/datetimepicker` — add dep).
- Location, description (multiline).
- Participant input with autocomplete from contacts store (port of
`participant-input.tsx`, ~180 lines).
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
are deferred — covers the 95% case.
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
`Alert.trigger.offset`.
- Timezone: default to device TZ; optional override picker hidden behind
"Advanced" disclosure.
- Save path handles "this instance only" override vs master update for
recurring events (write to `recurrenceOverrides[recurrenceId]`).
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) —
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
edit/delete of a recurring instance.
### Phase 4 — Multi-calendar
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) —
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
(reuses `SidebarDrawer.tsx`):
- List of calendars with color swatch + visibility toggle + name.
- "My calendars" vs "Subscribed" sections if `myRights` differs.
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
- Footer: "Add calendar" (deferred to Phase 6).
**`src/theme/tokens.ts`** — audit `colors.calendar.*` palette against webmail's
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
add any missing shades.
### Phase 5 — Polish
- Pull-to-refresh on each view → forces re-fetch of current window.
- Empty states per view (no calendars yet / no events this month).
- Loading skeleton for month grid and week grid.
- Error banner when fetch fails (pattern from `EmailListScreen`).
- Respect `email-store` push via `handleStateChange` so events update live
when another client creates one.
## Explicitly deferred (out of scope)
These are in the webmail but don't make sense for a first mobile pass:
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
`ical-subscription-modal.tsx` 220 lines) — file picker + URL subscription
with auth flows. Better as a dedicated settings flow later.
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) —
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
screen, not bolted onto calendar.
- **Drag to reschedule / resize** — mobile tap targets are too small for the
fine-grained control the web drag gives; tap-edit is the mobile idiom.
- **Quick event input** (`quick-event-input.tsx`) — NLP parser ("Lunch tomorrow
at noon"). Neat on desktop, marginal on phone where typing is already slow.
- **Event context menu** (`event-context-menu.tsx`) — right-click is desktop-
only. Actions move into the detail sheet's action row.
- **Mini-calendar** (`mini-calendar.tsx`) — web uses it as a sidebar date
picker. Mobile already has the main month view; redundant.
- **Birthday calendar auto-derived events** — lower priority; leave the
contacts-store integration for a follow-up.
## Dependencies to add
- `@react-native-community/datetimepicker` — native date/time pickers.
- `react-native-reanimated` is already in (via react-navigation); reuse for
bottom-sheet animations on `EventDetailSheet`.
- No new calendar library — reimplement RRULE expansion in ~400 lines rather
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
## File map (new / modified)
```
repos/react-native/src/
├── screens/
│ └── CalendarScreenNew.tsx [rewrite]
├── components/
│ └── calendar/ [new directory]
│ ├── MonthView.tsx [new]
│ ├── WeekView.tsx [new]
│ ├── AgendaView.tsx [new]
│ ├── EventCard.tsx [new]
│ ├── EventDetailSheet.tsx [new]
│ ├── EventModal.tsx [new]
│ ├── ParticipantInput.tsx [new]
│ ├── RecurrenceScopeDialog.tsx [new]
│ ├── CalendarSidebarDrawer.tsx [new]
│ └── index.ts [new]
├── stores/
│ └── calendar-store.ts [extend]
├── lib/
│ ├── calendar-utils.ts [new]
│ └── recurrence-expansion.ts [new]
└── theme/
└── tokens.ts [audit calendar palette]
```
## Testing approach
- `vitest.config.ts` already exists. Unit tests for:
- `recurrence-expansion` (daily/weekly/monthly, count+until, byDay).
- `calendar-utils` (duration parse, all-day overlap, DST boundary).
- Store: `ensureRange` dedup, `handleStateChange` refetch trigger.
- Manual emulator test matrix:
- Create event (timed, all-day, recurring), verify server round-trip.
- Edit recurring event via scope dialog.
- Delete recurring instance vs series.
- Toggle calendar visibility, confirm events disappear.
- Push update from webmail, confirm mobile refreshes.
## Build order (suggested)
1. Phase 1 in full — nothing else works without real data.
2. MonthView + EventDetailSheet (read path) — first thing user sees.
3. EventModal create path — unblocks manual testing of write path.
4. AgendaView + CalendarSidebarDrawer — pulls the rest of month together.
5. WeekView — biggest UI, easiest to defer if time runs short.
6. EventModal edit + recurrence scope — finishes the edit surface.
7. Phase 5 polish.
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
changes. Each phase is ship-safe on its own — the app stays functional even if
we stop at Phase 2.
+47
View File
@@ -0,0 +1,47 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
# Bulwark Mobile
> **Beta - work in progress.** Many features are unfinished or rough. Expect bugs, missing functionality, and breaking changes between releases. Do not rely on this for primary email yet.
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
## What works today
- Sign in to any JMAP server (e.g. Stalwart)
- Multiple accounts
- Email list, threads, compose
- Calendar (basic)
- Contacts (basic)
- Push notifications via FCM relay
- In-app sideload updates from GitHub Releases
## What's missing or rough
- iOS build is untested
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
## Run locally
```bash
npm install
npx expo start
```
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
+1 -1
View File
@@ -1 +1 @@
0.1.0
0.1.52
+1 -1
View File
@@ -166,7 +166,7 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
// Firebase Cloud Messaging — delivers pushes via Google Play services.
// Firebase Cloud Messaging - delivers pushes via Google Play services.
// Requires android/app/google-services.json at build time.
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
+10 -4
View File
@@ -1,11 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@@ -22,6 +22,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmobile"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
@@ -0,0 +1,286 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import android.security.KeyChainAliasCallback
import android.util.Base64
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
import kotlin.concurrent.thread
/**
* Bridges Android's KeyChain client-certificate machinery into JS so the
* mobile app can authenticate to a reverse proxy that asks for mTLS.
*
* Two surfaces:
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
* pick a cert installed in Android's system credential store. The
* selected alias is persisted in SharedPreferences so the choice
* survives app restarts.
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
* injects the picked cert into the TLS handshake. JS-side `fetch`
* can't present a client cert because RN's bundled OkHttp client
* doesn't expose its KeyManager hook to userland; this method is
* the workaround.
*/
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
private val prefs: SharedPreferences =
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
@Volatile private var cachedFactory: SSLSocketFactory? = null
@Volatile private var cachedFactoryForAlias: String? = null
override fun getName(): String = MODULE_NAME
// ── alias management ────────────────────────────────────────
@ReactMethod
fun getAlias(promise: Promise) {
promise.resolve(cachedAlias)
}
@ReactMethod
fun clearAlias(promise: Promise) {
prefs.edit().remove(PREF_ALIAS).apply()
cachedAlias = null
invalidateFactory()
promise.resolve(null)
}
/**
* Show the system "Choose a certificate" picker. Resolves with the
* selected alias, or `null` when the user dismisses the picker.
*/
@ReactMethod
fun pickAlias(host: String?, promise: Promise) {
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
// reference). The explicit Java-style getter on the application
// context is the supported call site.
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
if (activity == null) {
promise.reject("no_activity", "No current activity to host the cert picker")
return
}
val callback = KeyChainAliasCallback { alias ->
// Callback runs off the main thread; persist + resolve directly.
if (alias.isNullOrBlank()) {
promise.resolve(null)
return@KeyChainAliasCallback
}
prefs.edit().putString(PREF_ALIAS, alias).apply()
cachedAlias = alias
invalidateFactory()
promise.resolve(alias)
}
try {
KeyChain.choosePrivateKeyAlias(
activity,
callback,
/* keyTypes */ arrayOf("RSA", "EC"),
/* issuers */ null,
/* host */ host,
/* port */ -1,
/* alias */ cachedAlias,
)
} catch (e: Exception) {
promise.reject("pick_failed", e.message, e)
}
}
// ── secure fetch ────────────────────────────────────────────
/**
* Perform an HTTP request that presents the persisted client cert (if
* any) during the TLS handshake.
*
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
*
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
* reconstruct a standard `Response`-like object.
*/
@ReactMethod
fun fetchSecure(request: ReadableMap, promise: Promise) {
val url = request.getString("url")
if (url.isNullOrBlank()) {
promise.reject("bad_args", "url is required")
return
}
val method = (request.getString("method") ?: "GET").uppercase()
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
val headersMap = request.getMap("headers")
val bodyBase64 = request.getString("bodyBase64")
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
thread(name = "BulwarkClientCert.fetch") {
try {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = method
instanceFollowRedirects = true
if (bodyBytes != null) {
doOutput = true
setFixedLengthStreamingMode(bodyBytes.size)
}
}
if (conn is HttpsURLConnection && cachedAlias != null) {
conn.sslSocketFactory = buildOrReuseSocketFactory()
}
headersMap?.let { h ->
val it = h.keySetIterator()
while (it.hasNextKey()) {
val k = it.nextKey()
val v = h.getString(k)
if (v != null) conn.setRequestProperty(k, v)
}
}
conn.connect()
if (bodyBytes != null) {
conn.outputStream.use { out -> out.write(bodyBytes) }
}
val status = conn.responseCode
val statusText = conn.responseMessage ?: ""
val responseStream = try {
conn.inputStream
} catch (_: Exception) {
conn.errorStream
}
val responseBytes = if (responseStream != null) {
val buf = ByteArrayOutputStream()
responseStream.copyTo(buf)
responseStream.close()
buf.toByteArray()
} else {
ByteArray(0)
}
val out = Arguments.createMap()
out.putInt("status", status)
out.putString("statusText", statusText)
val headers = Arguments.createMap()
// headerFields is a `Map<String?, List<String>>`; the null
// key is the HTTP status line, which we don't need to copy.
for ((name, values) in conn.headerFields) {
if (name == null || values == null) continue
headers.putString(name, values.joinToString(", "))
}
out.putMap("headers", headers)
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
conn.disconnect()
promise.resolve(out)
} catch (e: Exception) {
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
}
}
}
// ── internals ───────────────────────────────────────────────
private fun invalidateFactory() {
cachedFactory = null
cachedFactoryForAlias = null
}
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
val existing = cachedFactory
if (existing != null && cachedFactoryForAlias == alias) return existing
val ctx = reactApplicationContext
val km = KeyChainKeyManager(ctx, alias)
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as java.security.KeyStore?)
val trustManagers = tmf.trustManagers
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
?: throw IllegalStateException("No X509TrustManager available")
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
val factory = sslContext.socketFactory
cachedFactory = factory
cachedFactoryForAlias = alias
return factory
}
/**
* X509ExtendedKeyManager that always returns the same KeyChain alias.
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
* them lazily during the TLS handshake, which is fine since the
* fetch already runs off-main-thread.
*/
private class KeyChainKeyManager(
private val context: Context,
private val alias: String,
) : X509ExtendedKeyManager() {
override fun chooseClientAlias(
keyTypes: Array<out String>?,
issuers: Array<out Principal>?,
socket: Socket?,
): String = alias
override fun chooseEngineClientAlias(
keyType: Array<out String>?,
issuers: Array<out Principal>?,
engine: SSLEngine?,
): String = alias
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
return try {
KeyChain.getCertificateChain(context, this.alias)
} catch (_: Exception) {
null
}
}
override fun getPrivateKey(alias: String?): PrivateKey? {
return try {
KeyChain.getPrivateKey(context, this.alias)
} catch (_: Exception) {
null
}
}
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
arrayOf(alias)
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
emptyArray()
override fun chooseServerAlias(
keyType: String?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = null
}
companion object {
const val MODULE_NAME = "BulwarkClientCert"
private const val PREFS_NAME = "bulwark_client_cert"
private const val PREF_ALIAS = "alias"
}
}
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.google.firebase.messaging.FirebaseMessaging
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
@@ -64,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -72,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
emailId, threadId, subject, accountId,
)
promise.resolve(null)
}
@@ -93,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
emailId: String?,
threadId: String?,
subject: String?,
accountId: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -100,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
}
val pending = PendingIntent.getActivity(
ctx,
@@ -148,7 +152,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
conn.readTimeout = 3000
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", "bulwark-mobile")
conn.inputStream.use { BitmapFactory.decodeStream(it) }
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
// server can't OOM the headless task with a multi-megabyte image.
// We also sample down to ~256x256 since it'll only render at 192px.
val stream = conn.inputStream
val bytes = stream.use { input ->
val limit = MAX_FAVICON_BYTES
val buf = ByteArrayOutputStream()
val chunk = ByteArray(8 * 1024)
var total = 0
while (true) {
val n = input.read(chunk)
if (n <= 0) break
total += n
if (total > limit) return@use null
buf.write(chunk, 0, n)
}
buf.toByteArray()
} ?: return null
val opts = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
var sample = 1
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
sample *= 2
}
val decodeOpts = BitmapFactory.Options().apply {
inSampleSize = sample
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
} catch (_: Exception) {
null
}
@@ -167,6 +200,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
fun removeListeners(count: Int) {}
companion object {
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
@Volatile private var currentInstance: BulwarkFcmModule? = null
fun emit(eventName: String, params: WritableMap?) {
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(BulwarkFcmModule(reactContext))
listOf(
BulwarkFcmModule(reactContext),
BulwarkClientCertModule(reactContext),
)
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
@@ -26,7 +26,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
val data = message.data
// Hand off to JS via a headless task only when the app isn't already
// running in the foreground — HeadlessJsTaskContext throws if started
// running in the foreground - HeadlessJsTaskContext throws if started
// while foreground. When the app is open, the main JS instance already
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
@@ -17,17 +17,36 @@ object NotificationTapStore {
return value
}
// The activity is exported (required by the LAUNCHER intent filter), so
// any third-party app on the device can craft a starting intent that
// includes these extras. Reject anything that doesn't look like the IDs
// our notification builder writes — preventing other apps from
// navigating us to a forged email/thread.
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
// accountId is `username@host` (see generateAccountId), so it needs `.`,
// `@`, `+` etc. — cap length and restrict charset to what that helper can
// emit. accountId is optional: notifications created before this field
// existed still navigate (using the active account as a fallback).
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
fun captureFromIntent(intent: Intent?): TapPayload? {
val extras = intent?.extras ?: return null
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
val subject = extras.getString(EXTRA_SUBJECT)
val payload = TapPayload(emailId, threadId, subject)
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
?: return null
// Subject is human-readable text and may legitimately contain anything;
// cap its length so a hostile launcher can't ship a 1MB string into
// the navigation payload.
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
val payload = TapPayload(emailId, threadId, subject, accountId)
pending = payload
// Clear so a subsequent activity lifecycle event doesn't replay this.
extras.remove(EXTRA_EMAIL_ID)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
return payload
}
@@ -35,15 +54,18 @@ object NotificationTapStore {
val emailId: String,
val threadId: String,
val subject: String?,
val accountId: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
}
+1 -1
View File
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
reactNativeArchitectures=arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
Vendored Regular → Executable
View File
+13 -1
View File
@@ -20,6 +20,7 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
@@ -45,7 +46,18 @@ module.exports = {
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-secure-store', '@react-native-community/datetimepicker'],
plugins: [
'expo-secure-store',
'@react-native-community/datetimepicker',
'expo-localization',
[
'expo-camera',
{
cameraPermission:
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
},
],
],
extra: {
commit: COMMIT,
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+185 -2
View File
@@ -1,25 +1,33 @@
{
"name": "bulwark-mobile",
"version": "1.0.0",
"version": "0.1.52",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "1.0.0",
"version": "0.1.52",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.0.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",
@@ -2790,6 +2798,15 @@
}
}
},
"node_modules/@react-native-community/netinfo": {
"version": "11.4.1",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz",
"integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.59"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -4742,6 +4759,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5189,6 +5212,26 @@
}
}
},
"node_modules/expo-camera": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -5203,6 +5246,15 @@
"react-native": "*"
}
},
"node_modules/expo-document-picker": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -5234,6 +5286,28 @@
"expo": "*"
}
},
"node_modules/expo-intent-launcher": {
"version": "13.0.8",
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz",
"integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-localization": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
"integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==",
"license": "MIT",
"dependencies": {
"rtl-detect": "^1.0.2"
},
"peerDependencies": {
"expo": "*",
"react": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
@@ -5373,6 +5447,16 @@
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -6146,6 +6230,12 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -6262,6 +6352,12 @@
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6763,6 +6859,18 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -6790,6 +6898,15 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
@@ -7950,6 +8067,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-png": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz",
@@ -8161,6 +8284,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -8529,6 +8658,27 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -8778,6 +8928,12 @@
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
}
},
"node_modules/rtl-detect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -8927,6 +9083,12 @@
"node": ">= 0.8"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9164,6 +9326,21 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -9700,6 +9877,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+12 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.0",
"version": "0.1.52",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -9,21 +9,31 @@
"ios": "expo run:ios",
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"bump": "node scripts/bump-version.mjs",
"sync-locales": "node scripts/sync-locales.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
"@react-navigation/native-stack": "^7.14.10",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-intent-launcher": "~13.0.8",
"expo-localization": "~17.0.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",
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const versionFile = path.join(root, 'VERSION');
const pkgFile = path.join(root, 'package.json');
const lockFile = path.join(root, 'package-lock.json');
const git = (...args) =>
execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
const gitInherit = (...args) =>
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
const dirty = git('status', '--porcelain');
if (dirty) {
console.error('Working tree is not clean. Commit or stash changes before bumping.');
console.error(dirty);
process.exit(1);
}
const current = fs.readFileSync(versionFile, 'utf8').trim();
const parts = current.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
console.error(`Invalid version in VERSION: "${current}" (expected MAJOR.MINOR.PATCH)`);
process.exit(1);
}
parts[2] += 1;
const next = parts.join('.');
const existingTags = new Set(git('tag', '--list').split(/\r?\n/).filter(Boolean));
if (existingTags.has(next)) {
console.error(`Tag ${next} already exists.`);
process.exit(1);
}
fs.writeFileSync(versionFile, `${next}\n`);
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
pkg.version = next;
fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
lock.version = next;
if (lock.packages?.['']) lock.packages[''].version = next;
fs.writeFileSync(lockFile, `${JSON.stringify(lock, null, 2)}\n`);
console.log(`${current} -> ${next}`);
gitInherit('add', 'VERSION', 'package.json', 'package-lock.json');
gitInherit('commit', '-m', `chore: update version number to ${next}`);
gitInherit('tag', '-a', next, '-m', next);
gitInherit('push', '--follow-tags');
execFileSync(
'gh',
['release', 'create', next, '--title', next, '--generate-notes'],
{ cwd: root, stdio: 'inherit' },
);
console.log(`Released ${next}`);
+43
View File
@@ -0,0 +1,43 @@
// Copy locale JSON files from the parent webmail repo into ./locales/.
// Run from the RN repo root: `node scripts/sync-locales.mjs`.
// In environments where the parent repo isn't checked out (e.g. CI clone of
// the standalone RN repo), this is a no-op — vendored files stay as-is.
import { readdir, mkdir, copyFile, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const RN_LOCALES = join(__dirname, '..', 'locales');
const WEBMAIL_LOCALES = join(__dirname, '..', '..', '..', 'locales');
if (!existsSync(WEBMAIL_LOCALES)) {
console.log(`No webmail locales at ${WEBMAIL_LOCALES} — skipping (vendored files will be used).`);
process.exit(0);
}
async function copyDir(src, dest) {
await mkdir(dest, { recursive: true });
for (const entry of await readdir(src, { withFileTypes: true })) {
const s = join(src, entry.name);
const d = join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(s, d);
} else if (entry.isFile() && entry.name.endsWith('.json')) {
await copyFile(s, d);
}
}
}
const langs = (await readdir(WEBMAIL_LOCALES, { withFileTypes: true }))
.filter((e) => e.isDirectory())
.map((e) => e.name);
let copied = 0;
for (const lang of langs) {
const src = join(WEBMAIL_LOCALES, lang);
const dest = join(RN_LOCALES, lang);
await copyDir(src, dest);
copied++;
}
console.log(`Synced ${copied} locale(s) from ${WEBMAIL_LOCALES} → ${RN_LOCALES}`);
+7
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('expo-file-system', () => ({
File: class {
constructor(public uri: string) {}
async bytes() { return new Uint8Array(); }
},
}));
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
+33 -6
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
});
describe('queryEvents', () => {
it('should query events within date range', async () => {
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter (and
// after/before); calendars are restricted via singular `inCalendar`
// conditions and date filtering stays client-side.
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
});
@@ -50,12 +54,35 @@ describe('calendar operations', () => {
expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
inCalendars: ['cal-1'],
after: '2026-03-01T00:00:00Z',
before: '2026-03-31T23:59:59Z',
types: ['Event'],
expect(call[0]).toBe('CalendarEvent/query');
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
expect(call[1].accountId).toBe('acc-1');
});
it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'OR',
conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }],
});
expect(call[1].accountId).toBe('acc-shared');
});
it('should omit the filter when no calendars are given', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
});
await queryEvents([], '', '');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toBeUndefined();
});
});
+60 -2
View File
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: () => 500,
},
}));
@@ -15,6 +16,9 @@ import {
createContact,
updateContact,
deleteContacts,
createAddressBook,
updateAddressBook,
deleteAddressBook,
} from '../contacts';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -50,14 +54,14 @@ describe('contacts operations', () => {
expect(result).toEqual(['c1', 'c2']);
});
it('should use default empty filter', async () => {
it('should omit filter when none provided', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
});
await queryContacts();
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({});
expect(call[1].filter).toBeUndefined();
});
});
@@ -116,4 +120,58 @@ describe('contacts operations', () => {
expect(call[1].destroy).toEqual(['c1', 'c2']);
});
});
describe('createAddressBook', () => {
it('creates a book and returns the created entity', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { created: { 'new-book': { id: 'ab-9' } } }, '0']],
});
const book = await createAddressBook('Work');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].create).toEqual({ 'new-book': { name: 'Work' } });
expect(book).toEqual({ id: 'ab-9', name: 'Work' });
});
it('throws when the server refuses to create', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notCreated: { 'new-book': { description: 'nope' } } }, '0']],
});
await expect(createAddressBook('X')).rejects.toThrow('nope');
});
});
describe('updateAddressBook', () => {
it('forwards only settable properties', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
});
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
});
});
describe('deleteAddressBook', () => {
it('destroys the book by id', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
});
await deleteAddressBook('ab-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].destroy).toEqual(['ab-1']);
});
it('throws when destroy fails', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['AddressBook/set', { notDestroyed: { 'ab-1': { description: 'in use' } } }, '0']],
});
await expect(deleteAddressBook('ab-1')).rejects.toThrow('in use');
});
});
});
+20 -2
View File
@@ -90,6 +90,24 @@ describe('email operations', () => {
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
});
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
});
const userFilter = {
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
};
await queryEmails('mb-1', { filter: userFilter });
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'mb-1' }, userFilter],
});
});
});
describe('getEmails', () => {
@@ -197,7 +215,7 @@ describe('email operations', () => {
expect(result).toEqual(['e1']);
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'test query' });
expect(call[1].filter).toEqual({ text: 'test* query*' });
});
it('should include mailbox filter if specified', async () => {
@@ -208,7 +226,7 @@ describe('email operations', () => {
await searchEmails('query', 'mb-1');
const call = mockRequest.mock.calls[0][0][0];
expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' });
expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' });
});
});
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
hasCapability: vi.fn(),
currentSession: null as unknown,
},
}));
vi.mock('../blob', () => ({
getDownloadUrl: vi.fn(
(blobId: string, _name?: string, _type?: string, accountId?: string) =>
`https://dl/${accountId ?? 'own'}/${blobId}`,
),
uploadBlob: vi.fn(),
uploadBytes: vi.fn(),
}));
import { jmapClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
createFolder,
deleteFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
getPrincipals,
isCrossAccountId,
isFolder,
setFileNodeShare,
} from '../files';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
function setSession(session: unknown) {
(jmapClient as { currentSession: unknown }).currentSession = session;
}
beforeEach(() => {
vi.clearAllMocks();
mockHasCapability.mockImplementation(
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
);
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
},
});
});
describe('isFolder', () => {
it('treats blob-less nodes as folders and anything with a blob as a file', () => {
// #379: a blob-marked node — even the legacy type:'d' dir markers — is a
// 0-byte file that nothing can be parented under.
expect(isFolder({ blobId: null })).toBe(true);
expect(isFolder({ blobId: undefined })).toBe(true);
expect(isFolder({ blobId: 'blob-1' })).toBe(false);
});
});
describe('createFolder', () => {
it('creates a real container node: no blobId/type/size, parentId set', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', 'parent-1');
const [method, args] = mockRequest.mock.calls[0][0][0];
expect(method).toBe('FileNode/set');
expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' });
});
it('omits parentId at the root', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
});
await createFolder('Docs', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.create['new-dir']).toEqual({ name: 'Docs' });
});
});
describe('deleteFileNodes', () => {
it('lets the server cascade into folder children', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']],
});
await deleteFileNodes(['f1']);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.destroy).toEqual(['f1']);
expect(args.onDestroyRemoveChildren).toBe(true);
});
it('throws when the server refuses a destroy', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'],
],
});
await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden');
});
});
describe('setFileNodeShare', () => {
it('patches shareWith/{principalId} and requests the principals:owner capability', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
const rights = {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
};
await setFileNodeShare('node-1', 'principal-2', rights);
const [calls, using] = mockRequest.mock.calls[0];
expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights });
expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER);
});
it('sends null rights to revoke access', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
});
await setFileNodeShare('node-1', 'principal-2', null);
const args = mockRequest.mock.calls[0][0][0][1];
expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null });
});
it('throws when the server rejects or does not confirm the update', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'],
],
});
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right');
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] });
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow(
'Server did not confirm the share update',
);
});
});
describe('getAllFileNodesAcrossAccounts', () => {
it('namespaces ids of nodes from other principals and tags them isShared', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-2': { name: 'Team', isPersonal: false },
},
});
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'],
],
})
.mockResolvedValueOnce({
methodResponses: [
['FileNode/get', {
list: [
{ id: 's1', name: 'Shared Folder', parentId: null, blobId: null },
{ id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' },
],
}, '0'],
],
});
const nodes = await getAllFileNodesAcrossAccounts();
expect(nodes).toHaveLength(3);
const own = nodes.find((n) => n.id === 'own-1')!;
expect(own.isShared).toBe(false);
expect(isCrossAccountId(own.id)).toBe(false);
const folder = nodes.find((n) => n.name === 'Shared Folder')!;
expect(folder.id).toBe('acc-2:s1');
expect(folder.isShared).toBe(true);
expect(folder.accountId).toBe('acc-2');
expect(folder.accountName).toBe('Team');
// parentId is namespaced the same way so child lookups keep working.
const child = nodes.find((n) => n.name === 'inside.txt')!;
expect(child.parentId).toBe('acc-2:s1');
});
it('skips personal accounts without the filenode capability', async () => {
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
accounts: {
'acc-1': { name: 'me@example.com', isPersonal: true },
'acc-3': { name: 'other personal', isPersonal: true },
},
});
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', { list: [] }, '0']],
});
await getAllFileNodesAcrossAccounts();
expect(mockRequest).toHaveBeenCalledTimes(1);
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1');
});
});
describe('getFileNodeDownloadUrl', () => {
it('routes shared blobs to the owning account', () => {
const url = getFileNodeDownloadUrl({
id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain',
blobId: 'b2', accountId: 'acc-2',
});
expect(url).toBe('https://dl/acc-2/b2');
});
});
describe('getPrincipals', () => {
it('queries then gets via back-reference', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Principal/query', { ids: ['p1'] }, '0'],
['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'],
],
});
const principals = await getPrincipals();
expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]);
const calls = mockRequest.mock.calls[0][0];
expect(calls[0][0]).toBe('Principal/query');
expect(calls[1][0]).toBe('Principal/get');
expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' });
});
it('returns empty when the server lacks the principals capability', async () => {
mockHasCapability.mockReturnValue(false);
expect(await getPrincipals()).toEqual([]);
expect(mockRequest).not.toHaveBeenCalled();
});
});
+277
View File
@@ -0,0 +1,277 @@
// Stalwart account-security management. Mirrors the webmail's
// stores/account-security-store.ts, talking to Stalwart's JMAP extension
// methods (x:AccountPassword, x:AppPassword, x:ApiKey, x:AccountSettings,
// x:Account). The webmail proxies these through a server-side passthrough so
// the basic-auth header stays in an httpOnly cookie; the mobile client already
// holds its own credentials, so it calls the JMAP endpoint directly with the
// `urn:stalwart:jmap` capability in `using`.
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
const STALWART_USING = [CAPABILITIES.CORE, STALWART_CAPABILITY];
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
export interface AppCredentialInfo {
id: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
allowedIps: string[];
}
export type AppPasswordInfo = AppCredentialInfo;
export type ApiKeyInfo = AppCredentialInfo;
export interface AppCredentialInput {
description: string;
expiresAt?: string | null;
allowedIps?: string[];
}
export interface AuthInfo {
otpEnabled: boolean;
appPasswords: AppPasswordInfo[];
apiKeys: ApiKeyInfo[];
}
export interface PrincipalInfo {
displayName: string;
emails: string[];
quota: number;
roles: string[];
}
type MethodResponse = [string, Record<string, any>, string];
// True once a live session advertises Stalwart's JMAP extension. Without it
// none of the x:* methods exist, so the screen shows a "not available" notice.
export function isStalwartSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
return STALWART_CAPABILITY in (session.capabilities ?? {});
}
// Pull a single method response out by its call id and throw on a JMAP method
// error (the `['error', {type, description}, id]` envelope).
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
const match = responses.find((r) => r[2] === callId);
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
if (match[0] === 'error') {
const err = match[1] as { type?: string; description?: string };
throw new Error(err.description || err.type || 'JMAP error');
}
return match[1] as T;
}
async function send(methodCalls: [string, Record<string, unknown>, string][]): Promise<MethodResponse[]> {
const res = await jmapClient.request(methodCalls, STALWART_USING);
return res.methodResponses as MethodResponse[];
}
function credentialFromResult(raw: Record<string, unknown>): AppCredentialInfo {
const allowedIps =
raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record<string, unknown>)
: [];
return {
id: String(raw.id ?? ''),
description: typeof raw.description === 'string' ? raw.description : '',
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
allowedIps,
};
}
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
if (!ips || ips.length === 0) return undefined;
return Object.fromEntries(ips.map((ip) => [ip, true]));
}
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
const body: Record<string, unknown> = { description: input.description };
if (input.expiresAt) body.expiresAt = input.expiresAt;
const allowed = ipsToMap(input.allowedIps);
if (allowed) body.allowedIps = allowed;
return body;
}
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
if (type === 'Aes128' || type === 'Aes256') return type;
return 'Disabled';
}
// ── Reads ─────────────────────────────────────────────────
export async function fetchAuthInfo(): Promise<AuthInfo> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'p'],
['x:AppPassword/query', { accountId }, 'aq'],
['x:ApiKey/query', { accountId }, 'kq'],
]);
const passwordResult = resultFor<{ list?: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(responses, 'p');
const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
const otpAuth = passwordResult.list?.[0]?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
const followUps: [string, Record<string, unknown>, string][] = [];
if (appPwQuery.ids?.length) {
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
}
if (apiKeyQuery.ids?.length) {
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
}
let appPasswords: AppPasswordInfo[] = [];
let apiKeys: ApiKeyInfo[] = [];
if (followUps.length) {
const followUpResponses = await send(followUps);
if (appPwQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'app');
appPasswords = (r.list ?? []).map(credentialFromResult);
}
if (apiKeyQuery.ids?.length) {
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'key');
apiKeys = (r.list ?? []).map(credentialFromResult);
}
}
return { otpEnabled, appPasswords, apiKeys };
}
export async function fetchEncryptionType(): Promise<EncryptionType> {
const accountId = jmapClient.accountId;
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0');
return extractEncryptionType(result.list?.[0]?.encryptionAtRest);
}
export async function fetchPrincipal(): Promise<PrincipalInfo> {
const accountId = jmapClient.accountId;
const responses = await send([['x:Account/get', { accountId, ids: [accountId] }, '0']]);
const result = resultFor<{
list?: Array<{
description?: string | null;
aliases?: Record<string, { name?: string; enabled?: boolean }>;
quotas?: { maxDiskQuota?: number };
roles?: { ['@type']?: string };
name?: string;
}>;
}>(responses, '0');
const acc = result.list?.[0];
const aliasAddresses = acc?.aliases
? Object.values(acc.aliases).flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
: [];
const primaryEmail = acc?.name ? [acc.name] : [];
return {
displayName: acc?.description ?? '',
emails: [...primaryEmail, ...aliasAddresses],
quota: acc?.quotas?.maxDiskQuota ?? 0,
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
};
}
// ── Mutations ─────────────────────────────────────────────
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
}
export async function updateDisplayName(displayName: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to update display name');
}
export async function enableTotp(currentPassword: string, otpUrl: string, otpCode: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to enable two-factor authentication');
}
export async function disableTotp(currentPassword: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([
[
'x:AccountPassword/set',
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl: null } } } },
'0',
],
]);
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notUpdated?.singleton;
if (failure) throw new Error(failure.description || failure.type || 'Failed to disable two-factor authentication');
}
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
async function createCredential(method: SetMethod, input: AppCredentialInput): Promise<{ id: string; secret: string }> {
const accountId = jmapClient.accountId;
const tmpId = 'new';
const responses = await send([[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0']]);
const result = resultFor<{
created?: Record<string, { id: string; secret: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
}>(responses, '0');
const notCreated = result.notCreated?.[tmpId];
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to create credential');
const created = result.created?.[tmpId];
if (!created?.id || !created.secret) throw new Error('Server did not return the created credential');
return { id: created.id, secret: created.secret };
}
async function removeCredential(method: SetMethod, id: string): Promise<void> {
const accountId = jmapClient.accountId;
const responses = await send([[method, { accountId, destroy: [id] }, '0']]);
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
const failure = result.notDestroyed?.[id];
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove credential');
}
export function createAppPassword(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:AppPassword/set', input);
}
export function removeAppPassword(id: string): Promise<void> {
return removeCredential('x:AppPassword/set', id);
}
export function createApiKey(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
return createCredential('x:ApiKey/set', input);
}
export function removeApiKey(id: string): Promise<void> {
return removeCredential('x:ApiKey/set', id);
}
+98 -12
View File
@@ -1,4 +1,6 @@
import { File } from 'expo-file-system';
import { jmapClient } from './jmap-client';
import { secureFetch } from '../lib/client-cert';
export async function uploadBlob(
uri: string,
@@ -7,39 +9,123 @@ export async function uploadBlob(
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId);
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
// Fetch local file as blob
const fileResponse = await fetch(uri);
const blob = await fileResponse.blob();
// Read via the file-system API so this works for both `file://` (image
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
// the underlying ArrayBuffer rather than the Uint8Array view because some
// RN transports stringify typed-array bodies, which silently produces an
// empty/garbage upload.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: blob,
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json();
// JMAP servers return either the direct shape `{blobId, type, size}` or
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
// Stalwart has shipped both depending on version; mirror the webmail's
// tolerant parsing so uploads don't silently lose the blobId.
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
// `new File(uri).bytes()`, which is overkill when we already have the bytes
// (e.g. the empty-blob trick used to create directories on Stalwart).
export async function uploadBytes(
bytes: Uint8Array,
type: string,
): Promise<{ blobId: string; size: number; type: string }> {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
export function getDownloadUrl(
blobId: string,
name?: string,
type?: string,
// Blobs of nodes shared by another principal live in the owner's account;
// downloading them with our own accountId 404s.
accountId?: string,
): string {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', jmapClient.accountId)
.replace('{blobId}', blobId)
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
}
+255 -21
View File
@@ -4,12 +4,48 @@ import type { Calendar, CalendarEvent } from './types';
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
/**
* IANA time zone of the device, sent as the `timeZone` argument on
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
* filter values and computes utcStart/utcEnd for floating events in this zone,
* defaulting to UTC when absent — which shifts range boundaries and
* floating-event times for any user not in UTC. Stalwart ignores unparseable
* values, so sending it is always safe.
*/
function getUserTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
return undefined;
}
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
* calendar id per condition), not the draft's plural `inCalendars` array —
* sending the plural form fails the whole query with `unsupportedFilter`.
* Multiple calendars are expressed as an OR of singular conditions.
*/
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
if (calendarIds.length === 1) {
return { inCalendar: calendarIds[0] };
}
return {
operator: 'OR',
conditions: calendarIds.map((id) => ({ inCalendar: id })),
};
}
const CALENDAR_EVENT_PROPERTIES = [
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
'start', 'duration', 'timeZone', 'showWithoutTime',
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
'participants', 'alerts', 'recurrenceRules',
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
'recurrenceOverrides', 'excludedRecurrenceRules',
'replyTo', 'organizerCalendarAddress', 'sequence',
'locations', 'virtualLocations',
'progress', 'due', 'priority', 'percentComplete',
'links', 'created', 'updated',
];
@@ -23,43 +59,92 @@ function methodResult<T = any>(res: any, index = 0): T {
return entry[1] as T;
}
// Other session accounts that expose the calendars capability hold calendars
// shared with the user (JMAP surfaces shared data under the sharer's account).
function sharedCalendarAccountIds(): string[] {
const session = jmapClient.currentSession;
const primary = jmapClient.accountId;
return Object.entries(session?.accounts ?? {})
.filter(([id, info]) =>
id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS])
.map(([id]) => id);
}
export async function getCalendars(): Promise<Calendar[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/get', { accountId }, '0']],
USING,
);
return methodResult<{ list: Calendar[] }>(res).list ?? [];
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
// Calendars shared with the user live in other session accounts. Failures
// there (revoked share, transient error) must not hide the user's own
// calendars, so each shared account is fetched best-effort.
const shared = await Promise.all(
sharedCalendarAccountIds().map(async (sharedAccountId) => {
try {
const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']],
USING,
);
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
// JMAP calendar ids are only unique within an account, so a shared
// "default" calendar collides with the user's own "default". Namespace
// the id (keeping the real id in originalId) so visibility toggles,
// event-query routing, and create/default mutations don't cross wires.
// Mirrors webmail's getAllCalendars.
return list.map((cal) => ({
...cal,
id: `${sharedAccountId}:${cal.id}`,
originalId: cal.id,
accountId: sharedAccountId,
isShared: true,
}));
} catch {
return [];
}
}),
);
return [...own, ...shared.flat()];
}
export async function queryEvents(
_calendarIds: string[],
calendarIds: string[],
_after: string,
_before: string,
accountId?: string,
): Promise<string[]> {
// Stalwart rejects inCalendars/after/before filters on CalendarEvent/query.
// Fetch all events in the account; calendar visibility + date filtering is
// done client-side.
const accountId = jmapClient.accountId;
// Stalwart rejects after/before filters on CalendarEvent/query; date
// filtering is done client-side. Calendars are restricted via the singular
// `inCalendar` condition (the plural `inCalendars` fails the whole query
// with unsupportedFilter on Stalwart).
const account = accountId || jmapClient.accountId;
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone;
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
const res = await jmapClient.request(
[['CalendarEvent/query', { accountId, limit: 1000 }, '0']],
[['CalendarEvent/query', args, '0']],
USING,
);
return methodResult<{ ids: string[] }>(res).ids ?? [];
}
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const account = accountId || jmapClient.accountId;
const timeZone = getUserTimeZone();
const batchSize = jmapClient.getMaxObjectsInGet();
const all: CalendarEvent[] = [];
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
const res = await jmapClient.request(
[['CalendarEvent/get', {
accountId,
accountId: account,
ids: batch,
properties: CALENDAR_EVENT_PROPERTIES,
...(timeZone ? { timeZone } : {}),
}, '0']],
USING,
);
@@ -69,38 +154,187 @@ export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
return all;
}
// `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation
// / reply / cancellation emails to participants. We pass it whenever an event
// has participants so creating/updating/deleting a meeting notifies attendees.
function setArgs(
accountId: string,
payload: Record<string, unknown>,
sendSchedulingMessages?: boolean,
): Record<string, unknown> {
const args: Record<string, unknown> = { accountId, ...payload };
if (sendSchedulingMessages !== undefined) {
args.sendSchedulingMessages = sendSchedulingMessages;
}
return args;
}
export async function createEvent(
event: Partial<CalendarEvent>,
calendarId: string,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<CalendarEvent> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', {
accountId,
[['CalendarEvent/set', setArgs(accountId, {
create: {
'new-event': { ...event, calendarIds: { [calendarId]: true } },
},
}, '0']],
}, sendSchedulingMessages), '0']],
USING,
);
return res.methodResponses[0][1].created['new-event'];
const result = methodResult<{
created?: Record<string, CalendarEvent>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-event'];
if (!created) {
const err = result.notCreated?.['new-event'];
throw new Error(err?.description || err?.type || 'Failed to create event');
}
return created;
}
// Batch-create many events in one CalendarEvent/set. Returns the number created.
export async function batchCreateEvents(
events: Partial<CalendarEvent>[],
calendarId: string,
targetAccountId?: string,
): Promise<number> {
if (events.length === 0) return 0;
const accountId = targetAccountId || jmapClient.accountId;
const create: Record<string, Partial<CalendarEvent>> = {};
events.forEach((e, i) => {
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
});
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, create }, '0']],
USING,
);
const result = methodResult<{ created?: Record<string, unknown> }>(res);
return result.created ? Object.keys(result.created).length : 0;
}
export async function updateEvent(
id: string,
changes: Partial<CalendarEvent>,
changes: Partial<CalendarEvent> | Record<string, unknown>,
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update event');
}
export async function deleteEvents(
ids: string[],
sendSchedulingMessages?: boolean,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [id]: changes } }, '0']],
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
USING,
);
}
export async function deleteEvents(ids: string[]): Promise<void> {
// RSVP to an invitation: patch the participant's participationStatus via a JSON
// Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages).
export async function rsvpEvent(
eventId: string,
participantId: string,
status: 'accepted' | 'declined' | 'tentative',
replyTo?: Record<string, string> | null,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
// Escape per RFC 6901: ~ → ~0, / → ~1.
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
const patch: Record<string, unknown> = {
[`participants/${escaped}/participationStatus`]: status,
};
if (replyTo) patch.replyTo = replyTo;
const res = await jmapClient.request(
[['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[eventId];
if (err) throw new Error(err.description || err.type || 'Failed to send RSVP');
}
// Parse an uploaded .ics blob into one or more JSCalendar events (server-side).
export async function parseCalendarBlob(blobId: string): Promise<Partial<CalendarEvent>[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']],
USING,
);
const result = methodResult<{
parsed?: Record<string, CalendarEvent | CalendarEvent[]>;
notParsable?: string[];
notFound?: string[];
}>(res);
if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format');
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
const parsed = result.parsed?.[blobId];
if (!parsed) return [];
return Array.isArray(parsed) ? parsed : [parsed];
}
export async function createCalendar(
name: string,
color?: string,
): Promise<Calendar> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', {
accountId,
create: { 'new-cal': { name, color, isVisible: true, isSubscribed: true } },
}, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, Calendar>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-cal'];
if (!created) {
const err = result.notCreated?.['new-cal'];
throw new Error(err?.description || err?.type || 'Failed to create calendar');
}
return created;
}
/**
* Mark a calendar as the account default. `isDefault` is read-only in
* Stalwart's Calendar/set — the default is changed via the
* `onSuccessSetIsDefault` request argument instead.
*/
export async function setDefaultCalendar(
calendarId: string,
targetAccountId?: string,
): Promise<void> {
const accountId = targetAccountId || jmapClient.accountId;
const res = await jmapClient.request(
[['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']],
USING,
);
methodResult(res);
}
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
// Stalwart otherwise refuses to delete a non-empty calendar.
export async function deleteCalendar(id: string): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['CalendarEvent/set', { accountId, destroy: ids }, '0']],
[['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']],
USING,
);
}
+55
View File
@@ -91,3 +91,58 @@ export async function deleteContacts(ids: string[]): Promise<void> {
USING,
);
}
export async function createAddressBook(name: string): Promise<AddressBook> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
USING,
);
const result = methodResult<{
created?: Record<string, AddressBook>;
notCreated?: Record<string, { description?: string; type?: string }>;
}>(res);
const created = result.created?.['new-book'];
if (created) return { ...created, name };
const err = result.notCreated?.['new-book'];
throw new Error(err?.description || err?.type || 'Failed to create address book');
}
export async function updateAddressBook(
id: string,
updates: Partial<AddressBook>,
): Promise<void> {
const accountId = jmapClient.accountId;
// Only forward server-settable properties.
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if (name !== undefined) patch.name = name;
if (description !== undefined) patch.description = description;
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
if (isDefault !== undefined) patch.isDefault = isDefault;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
USING,
);
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notUpdated?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
}
export async function deleteAddressBook(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
USING,
);
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
const err = result.notDestroyed?.[id];
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
}
/** Fetch all contact cards that live in a specific address book. */
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
const ids = await queryContacts({ inAddressBook: addressBookId });
return getContacts(ids);
}
+681 -21
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
import { toWildcardQuery } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
@@ -22,6 +22,132 @@ export async function getMailboxes(): Promise<Mailbox[]> {
return res.methodResponses[0][1].list;
}
// Variant that also returns the JMAP `state` token so callers can later issue
// Mailbox/changes(sinceState=…) to fetch only what changed.
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
if (ids.length === 0) return { list: [], state: '' };
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/get', { accountId, ids }, '0']],
);
const body = res.methodResponses[0][1];
return { list: body.list as Mailbox[], state: body.state as string };
}
export interface MailboxChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server can't compute the diff (typically
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['Mailbox/changes', { accountId, sinceState }, '0']],
);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function createMailbox(
data: { name: string; parentId?: string | null },
): Promise<string> {
const accountId = jmapClient.accountId;
const cid = 'new-mailbox';
const res = await jmapClient.request([
['Mailbox/set', {
accountId,
create: {
[cid]: {
name: data.name,
parentId: data.parentId ?? null,
},
},
}, '0'],
]);
const result = res.methodResponses[0][1];
if (result.created?.[cid]?.id) return result.created[cid].id as string;
const failure = result.notCreated?.[cid] as
| { type?: string; description?: string; properties?: string[] }
| undefined;
throw new Error(
failure
? `${failure.type ?? 'create failed'}${failure.description ? `: ${failure.description}` : ''}`
: 'Mailbox create returned no id',
);
}
export async function updateMailbox(
id: string,
changes: { name?: string; parentId?: string | null },
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
]);
const failure = res.methodResponses[0][1].notUpdated?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'update failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
export async function deleteMailbox(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, destroy: [id] }, '0'],
]);
const failure = res.methodResponses[0][1].notDestroyed?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'delete failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
function buildMailboxQueryFilter(
mailboxId: string,
userFilter: Record<string, unknown> | undefined,
): Record<string, unknown> {
const inMailbox = { inMailbox: mailboxId };
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
// produces a hybrid object that servers reduce to the FilterCondition,
// silently dropping the operator's conditions (e.g. the "unread" toggle).
if (!userFilter || Object.keys(userFilter).length === 0) return inMailbox;
if ('operator' in userFilter) {
return { operator: 'AND', conditions: [inMailbox, userFilter] };
}
return { ...inMailbox, ...userFilter };
}
export async function queryEmails(
mailboxId: string,
options?: {
@@ -30,21 +156,69 @@ export async function queryEmails(
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
},
): Promise<{ ids: string[]; total: number }> {
): Promise<{ ids: string[]; total: number; queryState?: string }> {
const accountId = jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const res = await jmapClient.request([
['Email/query', {
accountId,
filter: { inMailbox: mailboxId, ...options?.filter },
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
position: options?.position ?? 0,
limit: options?.limit ?? 50,
calculateTotal: true,
}, '0'],
]);
const body = res.methodResponses[0][1];
return {
ids: res.methodResponses[0][1].ids,
total: res.methodResponses[0][1].total,
ids: body.ids,
total: body.total,
queryState: body.queryState as string | undefined,
};
}
export interface EmailQueryChangesResult {
oldQueryState: string;
newQueryState: string;
total: number;
removed: string[];
added: Array<{ id: string; index: number }>;
}
// Run Email/queryChanges for the standard "by receivedAt desc, in this mailbox"
// query. Returns null when the server replies with `cannotCalculateChanges`
// (or any other error) — caller should fall back to a fresh Email/query.
export async function getEmailQueryChanges(
mailboxId: string,
sinceQueryState: string,
options?: {
sort?: Array<{ property: string; isAscending: boolean }>;
filter?: Record<string, unknown>;
upToId?: string;
maxChanges?: number;
},
): Promise<EmailQueryChangesResult | null> {
const accountId = jmapClient.accountId;
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
const args: Record<string, unknown> = {
accountId,
filter,
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
sinceQueryState,
calculateTotal: true,
};
if (options?.upToId) args.upToId = options.upToId;
if (options?.maxChanges) args.maxChanges = options.maxChanges;
const res = await jmapClient.request([['Email/queryChanges', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldQueryState: body.oldQueryState as string,
newQueryState: body.newQueryState as string,
total: (body.total as number) ?? 0,
removed: (body.removed as string[]) ?? [],
added: (body.added as Array<{ id: string; index: number }>) ?? [],
};
}
@@ -56,8 +230,63 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
return res.methodResponses[0][1].list;
}
export async function getFullEmail(id: string): Promise<Email> {
// Returns the Email/get response with the JMAP `state` token. Used by the
// store so we can later issue Email/changes(sinceState=…) for incremental
// updates instead of re-fetching the full list.
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
if (ids.length === 0) {
// Email/get with an empty id list still returns a state token; useful for
// priming the store after an empty mailbox query.
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: [], state: body.state as string };
}
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
]);
const body = res.methodResponses[0][1];
return { list: body.list as Email[], state: body.state as string };
}
export interface EmailChangesResult {
oldState: string;
newState: string;
hasMoreChanges: boolean;
created: string[];
updated: string[];
destroyed: string[];
}
// Returns null when the server replies with `cannotCalculateChanges` or any
// other error response; caller should treat that as "rebuild from scratch".
export async function getEmailChanges(
sinceState: string,
maxChanges?: number,
): Promise<EmailChangesResult | null> {
const accountId = jmapClient.accountId;
const args: Record<string, unknown> = { accountId, sinceState };
if (maxChanges) args.maxChanges = maxChanges;
const res = await jmapClient.request([['Email/changes', args, '0']]);
const [name, body] = res.methodResponses[0];
if (name === 'error') return null;
return {
oldState: body.oldState as string,
newState: body.newState as string,
hasMoreChanges: Boolean(body.hasMoreChanges),
created: (body.created as string[]) ?? [],
updated: (body.updated as string[]) ?? [],
destroyed: (body.destroyed as string[]) ?? [],
};
}
export async function getFullEmail(id: string, accountIdOverride?: string): Promise<Email> {
// `accountIdOverride` lets the unified inbox open a message that lives under
// a group/shared account in the same session instead of the user's own.
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
@@ -74,6 +303,55 @@ export async function getFullEmail(id: string): Promise<Email> {
return email;
}
// Batch variant for offline sync. JMAP servers cap how many objects can be
// returned in a single Email/get; the caller should chunk to that ceiling
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
export async function getFullEmails(ids: string[]): Promise<Email[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
ids,
properties: EMAIL_FULL_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 512000,
}, '0'],
]);
return res.methodResponses[0][1].list as Email[];
}
/**
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
* `importRawEmail` import step.
*/
export async function importEmailBlob(
blobId: string,
mailboxId: string,
keywords: Record<string, boolean> = { $seen: true },
): Promise<string> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/import', {
accountId,
emails: {
'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords },
},
}, '0'],
]);
const result = res.methodResponses[0][1];
const notCreated = result?.notCreated?.['import-0'];
if (notCreated) {
throw new Error(notCreated.description || notCreated.type || 'Failed to import email');
}
const id = result?.created?.['import-0']?.id;
if (!id) throw new Error('Email import succeeded but no id was returned');
return id;
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
@@ -85,8 +363,9 @@ export async function getThread(threadId: string): Promise<Thread> {
export async function setEmailKeywords(
emailId: string,
keywords: Record<string, boolean>,
accountIdOverride?: string,
): Promise<void> {
const accountId = jmapClient.accountId;
const accountId = accountIdOverride ?? jmapClient.accountId;
await jmapClient.request([
['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'],
]);
@@ -111,6 +390,207 @@ export async function moveEmail(
]);
}
// Move several emails from one mailbox to another in a single Email/set.
export async function moveEmails(
ids: string[],
fromMailboxId: string,
toMailboxId: string,
): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, Record<string, unknown>> = {};
for (const id of ids) {
update[id] = {
[`mailboxIds/${fromMailboxId}`]: null,
[`mailboxIds/${toMailboxId}`]: true,
};
}
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
}
// Batch delete: destroy outright when already in trash, otherwise move to trash.
export async function deleteEmails(
ids: string[],
trashMailboxId: string,
currentMailboxId: string,
): Promise<void> {
if (ids.length === 0) return;
if (currentMailboxId === trashMailboxId) {
const accountId = jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
} else {
await moveEmails(ids, currentMailboxId, trashMailboxId);
}
}
// Apply keyword maps to several emails in one round-trip.
export async function setKeywordsForEmails(
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
): Promise<void> {
if (updates.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { keywords: Record<string, boolean> }> = {};
for (const u of updates) update[u.id] = { keywords: u.keywords };
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
): Promise<void> {
if (items.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
for (const [id, present] of Object.entries(item.mailboxIds)) {
if (present) onlyTrue[id] = true;
}
update[item.id] = { mailboxIds: onlyTrue };
}
await jmapClient.request([
['Email/set', { accountId, update }, '0'],
]);
}
// Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole
// set, so this is idempotent — replaying it produces the same result no matter
// the current server state. The offline outbox relies on that to coalesce and
// safely retry move/archive/trash operations.
export async function setEmailMailboxes(
emailId: string,
mailboxIds: Record<string, boolean>,
): Promise<void> {
await restoreEmailMailboxes([{ id: emailId, mailboxIds }]);
}
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
// already-gone id is a no-op on replay.
export async function destroyEmails(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
export async function archiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
): Promise<void> {
if (emails.length === 0) return;
const accountId = jmapClient.accountId;
if (mode === 'single') {
const updates = Object.fromEntries(
emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]),
);
await jmapClient.request([
['Email/set', { accountId, update: updates }, '0'],
]);
return;
}
type Dest = { year: string; month?: string };
const destFor = new Map<string, Dest>();
for (const e of emails) {
const d = new Date(e.receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
}
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
const yearIdFor = new Map<string, string>();
const monthIdFor = new Map<string, string>();
const createEntries: Record<string, Record<string, unknown>> = {};
const findExisting = (name: string, parentId: string) =>
existingMailboxes.find(
(m) => m.name === name && m.parentId === parentId,
);
for (const dest of destFor.values()) {
if (!yearIdFor.has(dest.year)) {
const existing = findExisting(dest.year, archiveMailboxId);
if (existing) {
yearIdFor.set(dest.year, existing.id);
} else {
const cid = `year-${dest.year}`;
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
yearIdFor.set(dest.year, `#${cid}`);
}
}
if (mode === 'month' && dest.month) {
const monthKey = `${dest.year}/${dest.month}`;
if (!monthIdFor.has(monthKey)) {
const yearRef = yearIdFor.get(dest.year)!;
const existingMonth = yearRef.startsWith('#')
? undefined
: findExisting(dest.month, yearRef);
if (existingMonth) {
monthIdFor.set(monthKey, existingMonth.id);
} else {
const cid = `month-${dest.year}-${dest.month}`;
createEntries[cid] = { name: dest.month, parentId: yearRef };
monthIdFor.set(monthKey, `#${cid}`);
}
}
}
}
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const [emailId, dest] of destFor.entries()) {
const destId = mode === 'month' && dest.month
? monthIdFor.get(`${dest.year}/${dest.month}`)!
: yearIdFor.get(dest.year)!;
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]);
const response = await jmapClient.request(methodCalls);
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as
| Record<string, { type?: string; properties?: string[]; description?: string }>
| undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
}
}
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as
| Record<string, { type?: string; description?: string }>
| undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(
`Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
);
}
}
export async function deleteEmail(
emailId: string,
trashMailboxId: string,
@@ -164,6 +644,24 @@ export async function queryEmailsByFilter(
return res.methodResponses[0][1].ids;
}
export interface OutgoingAttachment {
blobId: string;
type: string;
name: string;
size?: number;
disposition?: 'attachment' | 'inline';
cid?: string;
}
export interface SendEmailResult {
/** True when the message was deferred (HOLDFOR / FUTURERELEASE). */
scheduled: boolean;
/** ISO timestamp the server resolved for a deferred send, when known. */
sendAt?: string;
emailId?: string;
emailSubmissionId?: string;
}
export async function sendEmail(
email: {
from: EmailAddress[];
@@ -173,13 +671,17 @@ export async function sendEmail(
subject: string;
htmlBody?: string;
textBody?: string;
attachments?: Array<{ blobId: string; type: string; name: string }>;
attachments?: OutgoingAttachment[];
inReplyTo?: string;
references?: string;
},
identityId: string,
sentMailboxId: string,
): Promise<void> {
// When > 0 the message is held for this many seconds before delivery via the
// SMTP HOLDFOR parameter (FUTURERELEASE). Used for both explicit "send later"
// scheduling and the global send-delay (undo-send) window.
holdForSeconds?: number,
): Promise<SendEmailResult> {
const accountId = jmapClient.accountId;
const emailCreate: Record<string, unknown> = {
from: email.from,
@@ -191,21 +693,29 @@ export async function sendEmail(
keywords: { $seen: true },
};
const bodyValues: Record<string, { value: string }> = {};
if (email.htmlBody) {
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
emailCreate.bodyValues = { html: { value: email.htmlBody } };
} else {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
bodyValues.html = { value: email.htmlBody };
}
if (email.textBody || !email.htmlBody) {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
bodyValues.text = { value: email.textBody ?? '' };
}
emailCreate.bodyValues = bodyValues;
if (email.attachments?.length) {
emailCreate.attachments = email.attachments.map((a) => ({
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: 'attachment',
}));
emailCreate.attachments = email.attachments.map((a) => {
const part: Record<string, unknown> = {
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: a.disposition ?? 'attachment',
};
if (a.size != null) part.size = a.size;
if (a.cid) part.cid = a.cid;
return part;
});
}
if (email.inReplyTo) {
@@ -213,14 +723,164 @@ export async function sendEmail(
emailCreate['header:References:asText'] = email.references ?? email.inReplyTo;
}
await jmapClient.request(
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter).
if (holdForSeconds && holdForSeconds > 0) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim())
.filter(Boolean)
.map((address) => ({ email: address }));
submissionCreate.envelope = {
mailFrom: {
email: email.from[0]?.email,
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
},
rcptTo,
};
}
const res = await jmapClient.request(
[
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
['EmailSubmission/set', {
accountId,
create: { 'sub-1': { emailId: '#draft', identityId } },
create: { 'sub-1': submissionCreate },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let sendAt: string | undefined;
for (const [methodName, result] of res.methodResponses) {
if (methodName.endsWith('/error')) {
throw new Error((result as { description?: string }).description ?? 'Send failed');
}
if (methodName === 'Email/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.draft;
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to create message');
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
}
if (methodName === 'EmailSubmission/set') {
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string }> }).notCreated?.['sub-1'];
if (notCreated) throw new Error(notCreated.description ?? notCreated.type ?? 'Failed to submit message');
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['sub-1'];
emailSubmissionId = created?.id;
sendAt = created?.sendAt;
}
}
return {
scheduled: !!(holdForSeconds && holdForSeconds > 0),
sendAt,
emailId,
emailSubmissionId,
};
}
export interface ScheduledEmail {
emailSubmissionId: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt: string;
undoStatus?: string;
subject?: string;
to?: EmailAddress[];
from?: EmailAddress[];
preview?: string;
}
// List pending (not-yet-delivered, not cancelled) scheduled submissions whose
// send time is still in the future, joined with a light Email/get so the UI
// can show subject/recipients. Empty when the server lacks FUTURERELEASE.
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
if (!jmapClient.hasDelayedSend()) return [];
const accountId = jmapClient.accountId;
const now = Date.now();
const queryRes = await jmapClient.request(
[['EmailSubmission/query', { accountId, limit: 200 }, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const [queryName, queryBody] = queryRes.methodResponses[0];
if (queryName.endsWith('/error')) return [];
const ids = (queryBody.ids as string[]) ?? [];
if (ids.length === 0) return [];
const subRes = await jmapClient.request(
[['EmailSubmission/get', {
accountId,
ids,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus'],
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const submissions = ((subRes.methodResponses[0][1].list as Array<{
id: string;
emailId: string;
identityId: string;
threadId?: string;
sendAt?: string;
undoStatus?: string;
}>) ?? []).filter((s) => {
if (s.undoStatus !== 'pending' || !s.sendAt) return false;
const t = new Date(s.sendAt).getTime();
return Number.isFinite(t) && t > now;
});
if (submissions.length === 0) return [];
const emailIds = Array.from(new Set(submissions.map((s) => s.emailId)));
const emailRes = await jmapClient.request([
['Email/get', {
accountId,
ids: emailIds,
properties: ['id', 'subject', 'to', 'from', 'preview', 'threadId'],
}, '0'],
]);
const emailById = new Map(
((emailRes.methodResponses[0][1].list as Email[]) ?? []).map((e) => [e.id, e]),
);
return submissions
.map((s): ScheduledEmail | null => {
const e = emailById.get(s.emailId);
if (!s.sendAt) return null;
return {
emailSubmissionId: s.id,
emailId: s.emailId,
identityId: s.identityId,
threadId: s.threadId ?? e?.threadId,
sendAt: s.sendAt,
undoStatus: s.undoStatus,
subject: e?.subject,
to: e?.to,
from: e?.from,
preview: e?.preview,
};
})
.filter((s): s is ScheduledEmail => s !== null)
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
}
// Cancel a pending scheduled send. The held message copy stays in Sent; only
// delivery is stopped (matches the webmail behaviour).
export async function cancelScheduledSend(emailSubmissionId: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['EmailSubmission/set', {
accountId,
update: { [emailSubmissionId]: { undoStatus: 'canceled' } },
}, '0']],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
const failure = res.methodResponses[0][1].notUpdated?.[emailSubmissionId] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
}
}
+266 -28
View File
@@ -1,39 +1,277 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { FileNode } from './types';
import type { FileNode, FileNodeRights, Principal } from './types';
import { getDownloadUrl, uploadBlob } from './blob';
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = {};
if (parentId !== undefined) filter.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/get', { accountId, filter }, '0']],
USING,
);
return res.methodResponses[0][1].list;
// A FileNode is a folder (container) only when it has no blob content — the
// server stores it with `file == null`. Sending a blobId/type/size on create
// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that
// nothing can ever be parented under (#379). The webmail migrates those
// legacy flat-named nodes into the real parentId hierarchy on load; this app
// reads and writes only the real hierarchy.
export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
return node.blobId == null;
}
export async function createFileNode(
fileNode: Partial<FileNode>,
): Promise<FileNode> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
create: { 'new-file': fileNode },
}, '0']],
USING,
// Stalwart omits shareWith/myRights from FileNode/get unless they are
// requested explicitly, so the share sheet and indicators must name them here.
const FILE_NODE_PROPERTIES = [
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated',
'shareWith', 'myRights',
];
export function supportsSharing(): boolean {
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
}
function filesAccountId(): string {
return (
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
jmapClient.accountId
);
return res.methodResponses[0][1].created['new-file'];
}
function fileUsing(): string[] {
const using: string[] = [CAPABILITIES.CORE];
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
using.push(CAPABILITIES.FILES);
}
// Required for shareWith/myRights on FileNode and for cross-account
// (shared-with-me) FileNode/get.
if (supportsSharing()) {
using.push(CAPABILITIES.PRINCIPALS_OWNER);
}
return using;
}
// Nodes fetched from another principal's account are namespaced
// "accountId:nodeId" so they can't collide with the primary account's ids.
// JMAP ids never contain ':', so the separator unambiguously marks a shared
// (cross-account) node.
export function isCrossAccountId(id: string | null | undefined): boolean {
return id != null && id.includes(':');
}
// Fetch every FileNode in the account, files AND folders, to build the
// hierarchy client-side from parentId links.
//
// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT
// FileNode/query — Stalwart's query only returns leaf files and omits folder
// nodes entirely, which would make every folder invisible.
export async function getAllFileNodes(): Promise<FileNode[]> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') {
throw new Error(result?.[1]?.description || 'FileNode list failed');
}
return (result[1].list ?? []) as FileNode[];
}
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
// account that advertises the filenode capability or is non-personal, since
// Stalwart doesn't always advertise capabilities on shared accounts.
function filesCapableAccountIds(): string[] {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const rest = Object.entries(accounts)
.filter(([id, info]) =>
id !== primaryId &&
(info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal))
.map(([id]) => id);
return [primaryId, ...rest];
}
// Fetch every FileNode the logged-in user can see across all accessible
// accounts. Nodes owned by another principal (shared with the user) are
// tagged `isShared: true` with the owning accountId/accountName, and their
// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's
// listAllFileNodesAcrossAccounts.
export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
const primaryId = filesAccountId();
const accounts = jmapClient.currentSession?.accounts ?? {};
const all: FileNode[] = [];
for (const accountId of filesCapableAccountIds()) {
const isPrimary = accountId === primaryId;
try {
const res = await jmapClient.request(
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
fileUsing(),
);
const result = res.methodResponses[0];
if (!result || result[0] === 'error') continue;
for (const node of (result[1].list ?? []) as FileNode[]) {
all.push({
...node,
id: isPrimary ? node.id : `${accountId}:${node.id}`,
parentId: node.parentId == null
? null
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
accountId,
accountName: accounts[accountId]?.name || accountId,
isShared: !isPrimary,
});
}
} catch {
// A single unreachable shared account shouldn't hide the user's own
// files; skip it and keep aggregating.
}
}
return all;
}
export async function createFolder(
name: string,
parentId: string | null,
): Promise<FileNode> {
const accountId = filesAccountId();
const props: Record<string, unknown> = { name };
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-dir'];
if (!created) {
const err = result.notCreated?.['new-dir'];
throw new Error(err?.description || 'Create folder failed');
}
return { ...props, ...created } as FileNode;
}
export async function updateFileNode(
id: string,
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
): Promise<void> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
fileUsing(),
);
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
}
export async function renameFileNode(id: string, newName: string): Promise<void> {
await updateFileNode(id, { name: newName });
}
export async function deleteFileNodes(ids: string[]): Promise<void> {
const accountId = jmapClient.accountId;
await jmapClient.request(
[['FileNode/set', { accountId, destroy: ids }, '0']],
USING,
if (ids.length === 0) return;
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
destroy: ids,
// The server removes descendant nodes of destroyed folders.
onDestroyRemoveChildren: true,
}, '0']],
fileUsing(),
);
const notDestroyed = res.methodResponses[0][1].notDestroyed as
| Record<string, { description?: string }>
| undefined;
const failedIds = Object.keys(notDestroyed ?? {});
if (failedIds.length > 0) {
throw new Error(
notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`,
);
}
}
export function getFileNodeDownloadUrl(node: FileNode): string {
if (!node.blobId) throw new Error('Folders cannot be downloaded');
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
}
export async function uploadFileNode(
uri: string,
name: string,
mimeType: string,
parentId: string | null,
): Promise<FileNode> {
const accountId = filesAccountId();
const blob = await uploadBlob(uri, mimeType);
const type = blob.type || mimeType || 'application/octet-stream';
const props: Record<string, unknown> = {
name,
// Stalwart caps the stored MIME type; very long types fail the create.
type: type.length > 30 ? 'application/octet-stream' : type,
blobId: blob.blobId,
size: blob.size,
};
if (parentId !== null) props.parentId = parentId;
const res = await jmapClient.request(
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
const created = result.created?.['new-file'];
if (!created) {
const err = result.notCreated?.['new-file'];
throw new Error(err?.description || 'Upload failed');
}
return { ...props, ...created } as FileNode;
}
// ── Sharing (RFC 9670) ────────────────────────────────────
// Add, update, or remove a principal's rights on an owned FileNode (file or
// folder). Pass rights: null to revoke. Stalwart applies it via a
// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares
// its whole subtree — which is why this app must store real parentId
// hierarchy rather than the legacy flat-name encoding.
export async function setFileNodeShare(
fileNodeId: string,
principalId: string,
rights: FileNodeRights | null,
): Promise<void> {
const accountId = filesAccountId();
const res = await jmapClient.request(
[['FileNode/set', {
accountId,
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
fileUsing(),
);
const result = res.methodResponses[0][1];
if (result.notUpdated?.[fileNodeId]) {
throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share');
}
if (!result.updated || !(fileNodeId in result.updated)) {
throw new Error('Server did not confirm the share update');
}
}
// List all principals visible to the user. Stalwart returns the full
// directory regardless of `filter`, so callers filter client-side.
export async function getPrincipals(): Promise<Principal[]> {
if (!supportsSharing()) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[
['Principal/query', { accountId }, '0'],
['Principal/get', {
accountId,
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
}, '1'],
],
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
);
const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get');
return (getResp?.[1].list ?? []) as Principal[];
}
// The principal id that represents the logged-in user (excluded from the
// share picker — sharing with yourself is a no-op the server may reject).
export function ownPrincipalId(): string {
return filesAccountId();
}
+270 -36
View File
@@ -7,11 +7,17 @@ import type {
} from './types';
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert';
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
// Refresh OAuth access tokens this many ms before they actually expire so
// in-flight requests don't race the expiry window.
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
// SecureStore keys: letters, digits, ".", "-", "_" only — no "@" or "/".
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
@@ -21,6 +27,13 @@ export interface StoredCredentials {
username: string;
password: string;
accessToken?: string;
// OAuth-only — present when credentials came in via the webmail handoff
// OAuth path rather than password auth. The token endpoint and client id
// are needed for refresh; without them the access token would just expire.
refreshToken?: string;
expiresAt?: number;
tokenEndpoint?: string;
clientId?: string;
}
export class JMAPClient {
@@ -30,7 +43,7 @@ export class JMAPClient {
get accountId(): string {
if (!this._accountId) {
throw new Error('Not authenticated — call connect() first');
throw new Error('Not authenticated - call connect() first');
}
return this._accountId;
}
@@ -47,13 +60,23 @@ export class JMAPClient {
return this.credentials?.serverUrl ?? null;
}
// True when the session authenticates with a Bearer token (OAuth handoff or
// token login) rather than a username/password. Used by the security screen
// to hide password/TOTP management, which only applies to password accounts.
get usesBearerAuth(): boolean {
return !!this.credentials?.accessToken;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
// ── Authentication ────────────────────────────────────
private get authHeader(): string {
// Public so blob upload/download helpers (which can't go through the JMAP
// request body) can fetch the same Authorization header the rest of the
// client uses. The value is recomputed each access and never cached.
get authHeader(): string {
if (!this.credentials) throw new Error('No credentials');
if (this.credentials.accessToken) {
return `Bearer ${this.credentials.accessToken}`;
@@ -89,7 +112,114 @@ export class JMAPClient {
return this.session;
}
// Legacy single-slot restore — kept for backward-compat tests. New code
// 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> {
const stored = await SecureStore.getItemAsync(LEGACY_CREDENTIALS_KEY);
@@ -114,18 +244,38 @@ export class JMAPClient {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false;
let creds: StoredCredentials;
try {
creds = JSON.parse(stored);
} catch {
// Corrupt entry — credentials can't be used. Caller should evict.
return false;
}
// Errors past this point propagate so callers can distinguish
// unrecoverable (AuthenticationError) from transient (NetworkError) and
// avoid clearing credentials when the server is just unreachable.
this.credentials = creds;
try {
const creds: StoredCredentials = JSON.parse(stored);
this.credentials = creds;
this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl),
creds.serverUrl,
);
this._accountId = this.resolveAccountId(this.session);
return true;
} catch {
return false;
} catch (err) {
// Don't leave a half-populated client behind; the caller needs to know
// the session is unavailable. Credentials stay in memory so a retry
// after the network comes back doesn't need a re-login.
this.session = null;
this._accountId = null;
if (err instanceof AuthenticationError) throw err;
// Anything else is treated as transport-level. Wrapping rather than
// rethrowing the raw fetch error gives callers a single check.
throw new NetworkError(
err instanceof Error ? err.message : 'Server unreachable',
);
}
this._accountId = this.resolveAccountId(this.session);
return true;
}
async clearAccountCredentials(accountId: string): Promise<void> {
@@ -161,17 +311,18 @@ export class JMAPClient {
// JMAP servers often self-report localhost/container-internal hostnames in
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
// the Android emulator can't resolve the host's "localhost").
//
// Splits via plain string indexing rather than `new URL()` because RN's
// URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = this.extractOrigin(serverUrl);
const rewrite = (url: string | undefined): string | undefined => {
if (!url) return url;
try {
const parsed = new URL(url);
const server = new URL(serverUrl);
if (parsed.origin === server.origin) return url;
return server.origin + parsed.pathname + parsed.search + parsed.hash;
} catch {
return url;
}
const origin = this.extractOrigin(url);
if (!origin || !serverOrigin || origin === serverOrigin) return url;
return serverOrigin + url.slice(origin.length);
};
return {
...session,
@@ -182,6 +333,11 @@ export class JMAPClient {
};
}
private extractOrigin(url: string): string | null {
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
return m ? m[1] : null;
}
// Reset in-memory state without touching persisted credentials (used on
// account switch so the active account's stored creds remain available).
reset(): void {
@@ -205,12 +361,18 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await fetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Invalid credentials');
@@ -250,14 +412,21 @@ export class JMAPClient {
methodCalls,
};
const response = await fetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
await this.ensureFreshToken();
const doFetch = () =>
secureFetch(this.session!.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: this.authHeader,
},
body: JSON.stringify(body),
});
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) {
throw new AuthenticationError('Session expired');
@@ -289,12 +458,65 @@ 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.
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.session?.downloadUrl) {
throw new Error('Download URL not available — not connected');
throw new Error('Download URL not available - not connected');
}
return this.session.downloadUrl
.replace('{accountId}', encodeURIComponent(this.accountId))
@@ -305,9 +527,12 @@ export class JMAPClient {
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await fetch(url, {
headers: { Authorization: this.authHeader },
});
await this.ensureFreshToken();
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
let response = await doFetch();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
}
if (response.status === 401) throw new AuthenticationError('Session expired');
if (!response.ok) throw new Error(`Failed to fetch blob: ${response.status}`);
return response.arrayBuffer();
@@ -323,6 +548,15 @@ export class AuthenticationError extends Error {
}
}
// Server unreachable / transient transport failure. Callers should keep
// stored credentials and surface an offline state rather than logging out.
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
+67 -19
View File
@@ -24,38 +24,64 @@ export async function createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
// want and accept whatever Stalwart returns.
expires?: string;
}): Promise<string> {
const created: Record<string, unknown> = {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
};
if (params.expires) created.expires = params.expires;
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{
create: {
new: {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
},
},
},
{ create: { new: created } },
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
const created = body?.created?.new as { id?: string } | undefined;
if (!created?.id) {
const result = body?.created?.new as { id?: string } | undefined;
if (!result?.id) {
const notCreated = body?.notCreated?.new;
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
);
}
return created.id;
return result.id;
}
/**
* Send the verification code back to the server — the call that flips the
* Push the subscription's expiry forward (RFC 8620 §7.2.1). Returns false if
* the server rejected the update (e.g. the subscription no longer exists),
* which the caller treats as a signal to recreate.
*/
export async function updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[] },
): Promise<boolean> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{ update: { [id]: patch } },
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
if (body?.notUpdated?.[id]) return false;
return body?.updated?.[id] !== undefined;
}
/**
* Send the verification code back to the server - the call that flips the
* subscription from pending to active (RFC 8620 §7.2.2).
*/
export async function verifyPushSubscription(
@@ -108,7 +134,7 @@ export function connectEventSource(
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: (jmapClient as any).authHeader },
headers: { Authorization: jmapClient.authHeader },
});
const handler = (event: any) => {
@@ -175,13 +201,35 @@ export function startPolling(
return () => clearInterval(timer);
}
export interface StartPushOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
}
/**
* Start real-time updates, preferring SSE with polling fallback.
* Start real-time updates, preferring SSE with polling fallback. The `_client`
* argument is unused today (the underlying event source uses the singleton
* `jmapClient`) but is part of the call signature so the host App can pass
* the active client reference and trigger React re-runs on account switch.
*/
export function startPushUpdates(
onStateChange: StateChangeHandler,
_client: unknown,
opts: StartPushOptions,
): () => void {
const cleanup = connectEventSource(onStateChange);
if (cleanup) return cleanup;
return startPolling(onStateChange);
const onError = opts.onError;
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
}
};
try {
const cleanup = connectEventSource(safeOnStateChange);
if (cleanup) return cleanup;
return startPolling(safeOnStateChange);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
return () => undefined;
}
}
+194
View File
@@ -0,0 +1,194 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { secureFetch } from '../lib/client-cert';
import type { SieveScript, SieveCapabilities } from '../lib/sieve/types';
// JMAP Sieve (RFC 9661) bindings. Mirrors the webmail JMAPClient Sieve methods
// but follows the mobile convention of a functional api/* module driving the
// shared `jmapClient` singleton (see api/vacation.ts, api/blob.ts).
const SIEVE_USING = [CAPABILITIES.CORE, CAPABILITIES.SIEVE];
function requireSession() {
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
return session;
}
// Sieve lives on its own JMAP account (RFC 9661). Fall back to the mail account
// when the server does not advertise a dedicated one (Stalwart uses the same id).
export function getSieveAccountId(): string {
const session = jmapClient.currentSession;
return session?.primaryAccounts?.[CAPABILITIES.SIEVE] ?? jmapClient.accountId;
}
export function isSieveSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
return CAPABILITIES.SIEVE in (session.capabilities ?? {});
}
export function getSieveCapabilities(): SieveCapabilities | null {
const session = jmapClient.currentSession;
if (!session) return null;
const info = session.accounts?.[getSieveAccountId()];
const caps = info?.accountCapabilities?.[CAPABILITIES.SIEVE];
return (caps as SieveCapabilities) ?? null;
}
export async function getSieveScripts(): Promise<SieveScript[]> {
const res = await jmapClient.request(
[['SieveScript/get', { accountId: getSieveAccountId() }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/get') {
return ((resp[1] as { list?: SieveScript[] }).list ?? []) as SieveScript[];
}
throw new Error('Failed to fetch Sieve scripts');
}
export async function getSieveScriptContent(blobId: string): Promise<string> {
const session = requireSession();
const url = session.downloadUrl
.replace('{accountId}', encodeURIComponent(getSieveAccountId()))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent('script.sieve'))
.replace('{type}', encodeURIComponent('application/sieve'));
const response = await secureFetch(url, {
headers: { Authorization: jmapClient.authHeader },
});
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
return response.text();
}
async function uploadSieveBlob(content: string): Promise<string> {
const session = requireSession();
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(getSieveAccountId()),
);
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/sieve',
Authorization: jmapClient.authHeader,
},
// A plain string body is encoded as UTF-8 by RN's fetch. (api/blob.ts uses
// an ArrayBuffer only because typed-array bodies get stringified there.)
body: content,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(`Failed to upload Sieve script: ${response.status}${detail ? ` ${detail.substring(0, 200)}` : ''}`);
}
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string };
if (typeof direct.blobId === 'string') return direct.blobId;
const nested = raw[getSieveAccountId()] as { blobId?: string } | undefined;
if (nested?.blobId) return nested.blobId;
throw new Error('Upload succeeded but response did not include a blobId');
}
export async function createSieveScript(
name: string,
content: string,
activate = true,
): Promise<SieveScript> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const setArgs: Record<string, unknown> = {
accountId,
create: { 'new-script': { name, blobId } },
};
if (activate) setArgs.onSuccessActivateScript = '#new-script';
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as {
notCreated?: Record<string, { description?: string }>;
created?: Record<string, { id?: string }>;
};
if (result.notCreated?.['new-script']) {
throw new Error(result.notCreated['new-script'].description ?? 'Failed to create Sieve script');
}
const createdId = result.created?.['new-script']?.id;
if (createdId) {
const scripts = await getSieveScripts();
const script = scripts.find((s) => s.id === createdId);
if (script) return script;
}
}
throw new Error('Failed to create Sieve script');
}
export async function updateSieveScript(
scriptId: string,
content: string,
activate = true,
): Promise<void> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const setArgs: Record<string, unknown> = {
accountId,
update: { [scriptId]: { blobId } },
};
if (activate) setArgs.onSuccessActivateScript = scriptId;
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
if (result.notUpdated?.[scriptId]) {
throw new Error(result.notUpdated[scriptId].description ?? 'Failed to update Sieve script');
}
return;
}
throw new Error('Failed to update Sieve script');
}
export async function deleteSieveScript(scriptId: string): Promise<void> {
const res = await jmapClient.request(
[['SieveScript/set', { accountId: getSieveAccountId(), destroy: [scriptId] }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/set') {
const result = resp[1] as { notDestroyed?: Record<string, { description?: string }> };
if (result.notDestroyed?.[scriptId]) {
throw new Error(result.notDestroyed[scriptId].description ?? 'Failed to delete Sieve script');
}
return;
}
throw new Error('Failed to delete Sieve script');
}
export async function validateSieveScript(
content: string,
): Promise<{ isValid: boolean; errors?: string[] }> {
const blobId = await uploadSieveBlob(content);
const res = await jmapClient.request(
[['SieveScript/validate', { accountId: getSieveAccountId(), blobId }, '0']],
SIEVE_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'SieveScript/validate') {
const result = resp[1] as { error?: { description?: string } };
if (result.error) {
return { isValid: false, errors: [result.error.description ?? 'Validation failed'] };
}
return { isValid: true };
}
if (resp && typeof resp[0] === 'string' && resp[0].endsWith('error')) {
const error = resp[1] as { description?: string };
return { isValid: false, errors: [error.description ?? 'Validation failed'] };
}
return { isValid: false, errors: ['Unexpected validation response'] };
}
+164 -4
View File
@@ -9,12 +9,19 @@ export interface JMAPSession {
accounts: Record<string, JMAPAccountInfo>;
capabilities: Record<string, unknown>;
state: string;
// RFC 8620 §2 — authenticated user identifier. Optional because not all
// servers populate it; OAuth login uses it as the account label when
// there's no password username to fall back on.
username?: string;
}
export interface JMAPAccountInfo {
name: string;
isPersonal: boolean;
isReadOnly: boolean;
// Per-account capability objects (RFC 8620 §2). Optional because not every
// server populates it; the Sieve panel reads its limits/extensions from here.
accountCapabilities?: Record<string, unknown>;
}
export type JMAPMethodCall = [string, Record<string, unknown>, string];
@@ -191,6 +198,8 @@ export interface ContactAddress {
country?: string;
countryCode?: string;
fullAddress?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
label?: string;
pref?: number;
@@ -276,6 +285,30 @@ export interface ContactLink {
pref?: number;
}
export interface ContactCryptoKey {
uri: string;
mediaType?: string;
contexts?: Record<string, boolean>;
}
export interface ContactDirectory {
uri: string;
kind?: 'directory' | 'entry' | string;
mediaType?: string;
pref?: number;
}
export interface ContactPronouns {
pronouns: string;
pref?: number;
contexts?: Record<string, boolean>;
}
export interface ContactSpeakToAs {
grammaticalGender?: string;
pronouns?: Record<string, ContactPronouns>;
}
export interface ContactCard {
id: string;
originalId?: string;
@@ -298,8 +331,16 @@ export interface ContactCard {
media?: Record<string, ContactMedia>;
relatedTo?: Record<string, ContactRelation>;
links?: Record<string, ContactLink>;
cryptoKeys?: Record<string, ContactCryptoKey>;
directories?: Record<string, ContactDirectory>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
speakToAs?: ContactSpeakToAs;
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
source?: string;
prodId?: string;
created?: string;
updated?: string;
}
@@ -328,16 +369,43 @@ export interface AddressBookWithCount extends AddressBook {
// ─── Calendar (RFC 8984 JSCalendar) ─────────────────────
export interface Participant {
'@type'?: 'Participant';
name?: string;
email?: string;
sendTo?: Record<string, string>;
// Stalwart uses calendarAddress (a `mailto:` URI) instead of email/sendTo.
calendarAddress?: string;
kind?: string;
roles?: Record<string, boolean>;
participationStatus?: string;
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated' | string;
participationComment?: string;
scheduleStatus?: string[];
expectReply?: boolean;
description?: string;
}
// JSCalendar Location (RFC 8984 §4.2.5) — a physical place.
export interface EventLocation {
'@type'?: 'Location';
name?: string;
description?: string;
locationTypes?: Record<string, boolean>;
relativeTo?: string;
timeZone?: string;
coordinates?: string;
}
// JSCalendar VirtualLocation (RFC 8984 §4.2.6) — a video/online place.
export interface VirtualLocation {
'@type'?: 'VirtualLocation';
name?: string;
description?: string;
uri: string;
features?: Record<string, boolean>;
}
export interface RecurrenceRule {
'@type'?: 'RecurrenceRule';
frequency: string;
interval?: number;
until?: string;
@@ -352,6 +420,8 @@ export interface RecurrenceRule {
bySecond?: number[];
bySetPosition?: number[];
firstDayOfWeek?: string;
rscale?: string;
skip?: string;
}
export interface Alert {
@@ -375,27 +445,80 @@ export interface CalendarEvent {
status?: string;
freeBusyStatus?: string;
participants?: Record<string, Participant>;
// Scheduling (iTIP/iMIP). `replyTo` tells the server where RSVP replies go;
// Stalwart additionally exposes the organizer as `organizerCalendarAddress`.
replyTo?: Record<string, string>;
organizerCalendarAddress?: string;
sequence?: number;
recurrenceRules?: RecurrenceRule[];
recurrenceOverrides?: Record<string, Partial<CalendarEvent>>;
excludedRecurrenceRules?: RecurrenceRule[];
recurrenceId?: string;
originalId?: string;
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
// when `calendarIds` is remapped to namespaced store ids for shared events.
originalCalendarIds?: Record<string, boolean>;
useDefaultAlerts?: boolean;
alerts?: Record<string, Alert>;
locations?: Record<string, EventLocation>;
virtualLocations?: Record<string, VirtualLocation>;
links?: Record<string, { href: string; rel?: string }>;
created?: string;
updated?: string;
progress?: string;
// Task-only (RFC 8984 JSTask) fields.
progress?: 'needs-action' | 'in-process' | 'completed' | 'failed' | 'cancelled' | string;
due?: string;
priority?: number;
percentComplete?: number;
color?: string;
// Client-only: account the event/occurrence belongs to (for shared/virtual).
localAccountId?: string;
// Client-only: JMAP account the event was fetched from. Absent for the
// primary account; set for events on calendars shared with the user so
// mutations can be routed to the owning account.
accountId?: string;
// Client-only: true when the event lives on a calendar shared with the user.
isShared?: boolean;
}
export interface CalendarRights {
mayReadFreeBusy?: boolean;
mayReadItems?: boolean;
mayWriteAll?: boolean;
mayWriteOwn?: boolean;
mayUpdatePrivate?: boolean;
mayRSVP?: boolean;
mayShare?: boolean;
mayDelete?: boolean;
// Legacy short flag still read by the sidebar drawer.
mayWrite?: boolean;
}
export interface Calendar {
id: string;
name: string;
description?: string | null;
color?: string;
isVisible?: boolean;
isSubscribed?: boolean;
isDefault?: boolean;
sortOrder?: number;
myRights?: Record<string, boolean>;
myRights?: CalendarRights;
// Client-only: JMAP account the calendar was fetched from. Absent for the
// primary account; set for calendars shared with the user.
accountId?: string;
// Client-only: the real JMAP id of a shared calendar, before its `id` was
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
// and event queries must use this raw id against the owning account.
originalId?: string;
// Client-only: true when the calendar lives in another account (shared
// with the user by its owner).
isShared?: boolean;
// Client-only: set when `color` has been replaced by the viewer's local
// override for a shared calendar (see lib/calendar-utils). When true, the
// override wins over per-event colors so the whole shared calendar paints
// uniformly.
colorIsLocalOverride?: boolean;
}
// ─── Files (FileNode) ───────────────────────────────────
@@ -405,10 +528,44 @@ export interface FileNode {
name: string;
parentId?: string | null;
type: string;
blobId?: string;
blobId?: string | null;
size?: number;
created?: string;
updated?: string;
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
// principals capability and the properties are requested explicitly. A node
// is shared-out when `shareWith` has entries; `myRights` describes what the
// viewer may do (always full rights on owned nodes).
myRights?: FileNodeRights;
shareWith?: Record<string, FileNodeRights> | null;
// True when this node was fetched from another principal's account that was
// shared with the logged-in user ("Shared with me").
isShared?: boolean;
// Owning account's JMAP id and display name, set when aggregating nodes
// across accounts so blob downloads route to the right account.
accountId?: string;
accountName?: string;
}
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
export interface FileNodeRights {
mayRead: boolean;
mayAddChildren: boolean;
mayRename: boolean;
mayDelete: boolean;
mayModifyContent: boolean;
mayShare: boolean;
}
// RFC 9670 principal — an entity (user, group, resource) that files and
// calendars can be shared with. On Stalwart the principal id doubles as the
// account id of that user/group.
export interface Principal {
id: string;
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
name: string;
description?: string | null;
email?: string | null;
}
// ─── Push / SSE ─────────────────────────────────────────
@@ -441,4 +598,7 @@ export const CAPABILITIES = {
CALENDARS: 'urn:ietf:params:jmap:calendars',
SIEVE: 'urn:ietf:params:jmap:sieve',
QUOTA: 'urn:ietf:params:jmap:quota',
FILES: 'urn:ietf:params:jmap:filenode',
PRINCIPALS: 'urn:ietf:params:jmap:principals',
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
} as const;
+254
View File
@@ -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 };
}
+91
View File
@@ -0,0 +1,91 @@
export const UPDATE_REPO = 'bulwarkmail/native';
export interface ReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
export interface LatestRelease {
tag: string;
name: string;
htmlUrl: string;
publishedAt: string;
body: string;
apkAsset: ReleaseAsset | null;
// Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion
// asset or from the release body. Null when the release didn't publish one;
// the installer falls back to relying solely on Android's package signing
// check in that case (still strong, but less defense-in-depth).
apkSha256: string | null;
}
const SHA256_RE = /\b([a-f0-9]{64})\b/i;
function extractSha256(body: string, apkName: string | undefined): string | null {
if (!body) return null;
// Prefer a line that explicitly references the APK so we don't pick up the
// hash of an unrelated asset.
const lines = body.split(/\r?\n/);
if (apkName) {
for (const line of lines) {
if (line.includes(apkName)) {
const m = SHA256_RE.exec(line);
if (m) return m[1].toLowerCase();
}
}
}
// Fallback: a single SHA256 anywhere in the body — common when the release
// notes just list one checksum.
const m = SHA256_RE.exec(body);
return m ? m[1].toLowerCase() : null;
}
async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise<string | null> {
if (!asset) return null;
try {
const res = await fetch(asset.browser_download_url);
if (!res.ok) return null;
const text = await res.text();
const m = SHA256_RE.exec(text);
return m ? m[1].toLowerCase() : null;
} catch {
return null;
}
}
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`GitHub API ${res.status}`);
}
const json = (await res.json()) as {
tag_name: string;
name: string;
html_url: string;
published_at: string;
body: string;
assets: ReleaseAsset[];
};
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
const sha256Asset = apkAsset
? json.assets.find(
(a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`,
)
: undefined;
const apkSha256 =
(await fetchCompanionSha256(sha256Asset)) ??
extractSha256(json.body || '', apkAsset?.name);
return {
tag: json.tag_name,
name: json.name || json.tag_name,
htmlUrl: json.html_url,
publishedAt: json.published_at,
body: json.body || '',
apkAsset,
apkSha256,
};
}
+28 -45
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
children: string;
@@ -8,57 +9,39 @@ interface BadgeProps {
style?: ViewStyle;
}
/**
* Matches webmail badge patterns:
* - rounded-full px-2 py-0.5 text-xs font-medium
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, variantStyles[variant], style]}>
<Text style={[styles.text, textVariantStyles[variant]]}>
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
{children}
</Text>
</View>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: radius.full,
paddingHorizontal: 8, // px-2
paddingVertical: 2, // py-0.5
alignSelf: 'flex-start',
},
text: {
...typography.caption,
fontWeight: '500',
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
paddingHorizontal: 8,
paddingVertical: 2,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
const variantStyles: Record<string, ViewStyle> = {
default: {
backgroundColor: colors.surface,
},
primary: {
backgroundColor: colors.primaryBg,
},
destructive: {
backgroundColor: colors.errorBg,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.border,
},
muted: {
backgroundColor: colors.muted,
},
};
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
const textVariantStyles: Record<string, { color: string }> = {
default: { color: colors.text },
primary: { color: colors.primary },
destructive: { color: colors.error },
outline: { color: colors.text },
muted: { color: colors.mutedForeground },
};
+38 -106
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator } from 'react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
@@ -8,7 +9,7 @@ type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
loading?: boolean;
@@ -26,15 +27,17 @@ export default function Button({
icon,
style,
}: ButtonProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
return (
<Pressable
style={({ pressed }) => [
styles.base,
sizeStyles[size],
variantStyles[variant],
pressed && !isDisabled && pressedStyles[variant],
styles[`size_${size}` as const],
styles[`bg_${variant}` as const],
pressed && !isDisabled && styles[`pressed_${variant}` as const],
isDisabled && styles.disabled,
style,
]}
@@ -44,13 +47,13 @@ export default function Button({
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? colors.primaryForeground : colors.primary}
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, textVariantStyles[variant], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
{children}
</Text>
) : (
@@ -62,105 +65,34 @@ export default function Button({
);
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md, // rounded-md (web)
gap: spacing.sm,
},
text: {
...typography.bodyMedium, // text-sm font-medium (web)
},
disabled: {
opacity: 0.5,
},
textDisabled: {
opacity: 0.5,
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
// Matches web: h-9 (sm), h-10 (md), h-11 (lg), h-10 w-10 (icon)
const sizeStyles: Record<ButtonSize, ViewStyle> = {
sm: {
height: componentSizes.buttonSm,
paddingHorizontal: spacing.md, // px-3
},
md: {
height: componentSizes.buttonMd,
paddingHorizontal: spacing.lg, // px-4
paddingVertical: spacing.sm, // py-2
},
lg: {
height: componentSizes.buttonLg,
paddingHorizontal: spacing.xxxl, // px-8
},
icon: {
height: componentSizes.buttonMd,
width: componentSizes.buttonMd,
paddingHorizontal: 0,
},
};
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
// Matches web button variant classes exactly
const variantStyles: Record<ButtonVariant, ViewStyle> = {
default: {
backgroundColor: colors.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
elevation: 1,
},
ghost: {
backgroundColor: 'transparent',
},
outline: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
destructive: {
backgroundColor: colors.error,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
elevation: 1,
},
};
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
const pressedStyles: Record<ButtonVariant, ViewStyle> = {
default: {
backgroundColor: colors.primaryDark,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
ghost: {
backgroundColor: colors.accent,
},
outline: {
backgroundColor: colors.accent,
},
destructive: {
opacity: 0.9,
},
};
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
const textVariantStyles: Record<ButtonVariant, TextStyle> = {
default: {
color: colors.primaryForeground,
},
ghost: {
color: colors.text,
},
outline: {
color: colors.text,
},
destructive: {
color: colors.errorForeground,
},
};
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
});
}
+19 -10
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface CardProps {
children: React.ReactNode;
@@ -13,6 +14,8 @@ interface CardProps {
* - card-foreground for text
*/
export function Card({ children, style }: CardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return <View style={[styles.card, style]}>{children}</View>;
}
@@ -27,6 +30,8 @@ interface SectionHeaderProps {
* - text-sm text-muted-foreground for description
*/
export function SectionHeader({ title, description }: SectionHeaderProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{title}</Text>
@@ -51,6 +56,8 @@ interface SettingItemRowProps {
* - description: text-xs text-muted-foreground mt-1
*/
export function SettingItemRow({ label, description, right, icon, iconBg, bottomBorder = true }: SettingItemRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.settingRow, bottomBorder && styles.settingRowBorder]}>
{icon && (
@@ -67,12 +74,13 @@ export function SettingItemRow({ label, description, right, icon, iconBg, bottom
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
card: {
backgroundColor: colors.card,
backgroundColor: c.card,
borderRadius: radius.lg, // rounded-lg
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
},
sectionHeader: {
@@ -81,11 +89,11 @@ const styles = StyleSheet.create({
},
sectionTitle: {
...typography.h3, // text-lg font-medium
color: colors.text,
color: c.text,
},
sectionDescription: {
...typography.body,
color: colors.mutedForeground, // text-muted-foreground
color: c.mutedForeground, // text-muted-foreground
marginTop: 4,
},
settingRow: {
@@ -97,7 +105,7 @@ const styles = StyleSheet.create({
},
settingRowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.border, // border-b border-border
borderBottomColor: c.border, // border-b border-border
},
settingIcon: {
width: 32,
@@ -112,14 +120,15 @@ const styles = StyleSheet.create({
},
settingLabel: {
...typography.bodyMedium, // text-sm font-medium text-foreground
color: colors.text,
color: c.text,
},
settingDescription: {
...typography.caption, // text-xs text-muted-foreground
color: colors.mutedForeground,
color: c.mutedForeground,
marginTop: 2,
},
settingRight: {
flexShrink: 0,
},
});
});
}
+14 -9
View File
@@ -2,7 +2,8 @@ import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal } from 'react-native';
import { AlertTriangle } from 'lucide-react-native';
import Button from './Button';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface DialogProps {
visible: boolean;
@@ -34,6 +35,8 @@ export default function Dialog({
onConfirm,
onCancel,
}: DialogProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
@@ -41,7 +44,7 @@ export default function Dialog({
<View style={styles.content}>
{variant === 'destructive' && (
<View style={styles.iconBadge}>
<AlertTriangle size={20} color={colors.error} />
<AlertTriangle size={20} color={c.error} />
</View>
)}
<Text style={styles.title}>{title}</Text>
@@ -61,7 +64,8 @@ export default function Dialog({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -72,9 +76,9 @@ const styles = StyleSheet.create({
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.lg,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
@@ -89,18 +93,18 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
borderRadius: radius.full,
backgroundColor: colors.errorBg,
backgroundColor: c.errorBg,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
title: {
...typography.h3, // text-lg font-semibold
color: colors.text,
color: c.text,
},
message: {
...typography.body, // text-sm
color: colors.mutedForeground, // text-muted-foreground
color: c.mutedForeground, // text-muted-foreground
marginTop: spacing.sm,
},
footer: {
@@ -110,4 +114,5 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.xxl, // px-6
paddingBottom: spacing.xxl, // pb-6
},
});
});
}
+436 -20
View File
@@ -3,10 +3,12 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { colors, spacing, typography } from '../theme/tokens';
import { useContactsStore } from '../stores/contacts-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
// skipped so we don't freeze the JS thread base64-encoding megabytes.
@@ -25,6 +27,15 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
interface EmailBodyViewProps {
email: Email;
senderEmail?: string;
// The body is a native WebView, which on Android swallows horizontal touches
// before the surrounding pager can see them. We detect a clear horizontal
// swipe inside the page and report it here so the pager can change messages.
onSwipe?: (direction: 'prev' | 'next') => void;
// Pinch-zoom state from inside the page: `pinching` while two fingers are
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
// scroll the pane away or switch mails.
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
function extractHtmlBody(email: Email): string | null {
@@ -43,29 +54,188 @@ function extractTextBody(email: Email): string | null {
return null;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children.
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
el.style.filter = 'invert(1) hue-rotate(180deg)';
}
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
const HEIGHT_REPORTER = `
(function () {
// One-time: wrap body contents in a block-level div we can measure reliably.
// body.scrollHeight on Android WebView is pinned to the container viewport
// height, so we can't trust it. A dedicated wrapper gives a deterministic
// bounding box.
var wrapperEl = null;
function ensureWrapper() {
var b = document.body;
if (!b) return null;
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
var wrap = document.getElementById('__rn_email_body_wrap__');
if (!wrap) {
wrap = document.createElement('div');
wrap.id = '__rn_email_body_wrap__';
wrap.style.display = 'block';
wrap.style.width = '100%';
wrap.style.transformOrigin = 'top left';
while (b.firstChild) wrap.appendChild(b.firstChild);
b.appendChild(wrap);
}
wrapperEl = wrap;
return wrap;
}
// The width available for the email content (body content-box).
function availWidth() {
var b = document.body;
var cs = window.getComputedStyle(b);
var padL = parseFloat(cs.paddingLeft) || 0;
var padR = parseFloat(cs.paddingRight) || 0;
return b.clientWidth - padL - padR;
}
// Shared zoom state, mutated by the pinch-zoom handler injected after this
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
// on top of it, "tx" pans the zoomed overflow into view horizontally.
// Vertical panning needs no state here: the height reports below carry the
// scaled height, so the surrounding native ScrollView grows and scrolls.
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
// Apply the combined fit × pinch transform using the cached layout numbers
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
function applyTransform(w) {
var s = Z.fit * Z.scale;
if (Z.scale > 1.001 || Z.fit < 0.999) {
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
if (Z.tx < minTx) Z.tx = minTx;
if (Z.tx > 0) Z.tx = 0;
w.style.width = Z.layoutW + 'px';
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
} else {
Z.tx = 0;
w.style.width = '100%';
w.style.transform = 'none';
}
}
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
// a phone viewport and force horizontal scrolling. Lay the content out at its
// natural width, then scale the whole block down so its widest element fits
// the screen — the way native mail clients do. No sideways scrolling.
function fitToWidth(w) {
if (!w) return;
var avail = availWidth();
if (avail <= 0) return;
// Reset to full width so wrapping content reports its true overflow extent
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
var content = w.scrollWidth;
Z.avail = avail;
Z.layoutW = content > avail + 1 ? content : avail;
Z.fit = avail / Z.layoutW;
applyTransform(w);
}
function measure() {
var w = ensureWrapper();
if (!w) return 0;
// While a pinch is in flight only re-apply the transform — a full re-fit
// resets the wrapper width and would thrash layout on every frame.
if (Z.pinching) applyTransform(w);
else fitToWidth(w);
// getBoundingClientRect reflects the applied transform, so r.height is the
// already-scaled visual height of the content.
var r = w.getBoundingClientRect();
var b = document.body;
var cs = window.getComputedStyle(b);
@@ -115,7 +285,16 @@ const HEIGHT_REPORTER = `
if (document.body) ro.observe(document.body);
} catch (e) {}
try {
var mo = new MutationObserver(report);
var mo = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
// otherwise scaling would re-trigger a measure and loop indefinitely.
var rec = records[i];
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
report();
return;
}
});
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
} catch (e) {}
try {
@@ -124,14 +303,199 @@ const HEIGHT_REPORTER = `
img.addEventListener('error', report);
});
} catch (e) {}
// Hooks for the pinch-zoom handler script.
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
window.__rnReport = report;
true;
})();
`;
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
// Detect a clear horizontal flick inside the WebView and report it. Uses
// capture-phase, passive listeners so it never blocks vertical scrolling, text
// selection, or link taps — it only reacts to a quick, horizontal-dominant
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
const SWIPE_DETECTOR = `
(function () {
if (window.__rnSwipeInit) return;
window.__rnSwipeInit = true;
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
document.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
var t = e.touches[0];
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
}, { passive: true, capture: true });
document.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
// content — never switch mails.
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
var t = e.changedTouches[0];
if (!t) return;
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
}
}
}, { passive: true, capture: true });
true;
})();
`;
// Pinch zoom + pan for the email content, driven entirely in the page: native
// WebView zoom can't work here because the WebView is auto-height inside a
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
// scales the measured wrapper (on top of the shrink-to-fit scale the height
// reporter maintains) anchored at the fingers' horizontal midpoint; while
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
// still scroll the surrounding ScrollView, which sees the scaled height
// through the regular height reports. Pinch moves call preventDefault so the
// engine consumes the gesture and Android parents don't intercept it; `zoom`
// messages let the RN side freeze the pane scroll / pager as a second guard.
const PINCH_ZOOM = `
(function () {
if (window.__rnPinchInit) return;
window.__rnPinchInit = true;
var Z = window.__rnZoom = window.__rnZoom || {
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
};
var MAX_ZOOM = 4;
var pinch = null; // { d0, s0, cx0, tx0 }
var pan = null; // { x0, tx0 }
var rafPending = false;
function post(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function apply() {
if (window.__rnApplyZoom) window.__rnApplyZoom();
}
// Height reports are rAF-throttled during the gesture so the RN container
// keeps up with the growing content without re-measuring on every move.
function scheduleReport() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(function () {
rafPending = false;
if (window.__rnReport) window.__rnReport();
});
}
function dist(t0, t1) {
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
document.addEventListener('touchstart', function (e) {
if (e.touches.length === 2) {
var t0 = e.touches[0], t1 = e.touches[1];
pinch = {
d0: dist(t0, t1) || 1,
s0: Z.scale,
cx0: (t0.clientX + t1.clientX) / 2,
tx0: Z.tx,
};
pan = null;
if (!Z.pinching) {
Z.pinching = true;
post({ type: 'zoom', pinching: true, scale: Z.scale });
}
e.preventDefault();
} else if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
} else {
pan = null;
}
}, { passive: false, capture: true });
document.addEventListener('touchmove', function (e) {
if (pinch && e.touches.length === 2) {
e.preventDefault();
var t0 = e.touches[0], t1 = e.touches[1];
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
if (z < 1) z = 1;
if (z > MAX_ZOOM) z = MAX_ZOOM;
// Keep the content under the fingers' midpoint stationary horizontally:
// the wrapper-local point that started under cx0 must map back to the
// current midpoint at the new scale. (applyTransform clamps the result.)
var cx = (t0.clientX + t1.clientX) / 2;
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
Z.scale = z;
Z.tx = cx - local * (Z.fit * z);
apply();
scheduleReport();
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
// No preventDefault: vertical-dominant drags must keep scrolling the
// native ScrollView around the WebView. Horizontal-dominant drags stay
// inside the WebView and pan here.
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
apply();
}
}, { passive: false, capture: true });
function onTouchEnd(e) {
if (pinch && e.touches.length < 2) {
pinch = null;
// Snap back to the fitted layout when the pinch ends near scale 1.
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
Z.pinching = false;
apply();
if (window.__rnReport) window.__rnReport();
post({ type: 'zoom', pinching: false, scale: Z.scale });
// A finger left on screen continues as a pan.
if (e.touches.length === 1 && Z.scale > 1.001) {
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
}
}
if (e.touches.length === 0) pan = null;
}
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
true;
})();
`;
export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const contacts = useContactsStore((s) => s.contacts);
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
const addToTrustedSendersBook = useContactsStore((s) => s.addToTrustedSendersBook);
const resolvedTheme = useResolvedTheme();
// Passively load the dedicated "Trusted Senders" address book once so its
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (!trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersLoaded, loadTrustedSendersBook]);
// Index the address book once per render so the trusted-sender check below
// is O(1) per email. Only used when the matching setting is on.
const addressBookEmails = React.useMemo(() => {
if (!trustedSendersAddressBook) return null;
const out = new Set<string>();
for (const c of contacts) {
if (!c.emails) continue;
for (const e of Object.values(c.emails)) {
if (e?.address) out.add(e.address.toLowerCase());
}
}
return out;
}, [contacts, trustedSendersAddressBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark.
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
const html = React.useMemo(() => extractHtmlBody(email), [email]);
const text = React.useMemo(() => extractTextBody(email), [email]);
@@ -142,7 +506,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
[html, text],
);
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
const trusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const trusted = senderEmail
? isSenderTrusted(senderEmail)
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
: false;
// One-time override: user tapped "Load images" for this email only.
const [allowOnce, setAllowOnce] = React.useState(false);
@@ -215,20 +583,53 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
html: wrapEmailHtml(rawHtml, {
blockRemoteImages: shouldBlock,
cidMap,
isDark: renderAsDark,
}),
};
}
const fallbackText = text ?? email.preview ?? '';
if (!fallbackText) {
return {
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>'),
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
isDark: renderAsDark,
}),
};
}
return { html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText)) };
}, [rawHtml, text, email.preview, shouldBlock, cidMap]);
return {
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }),
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
// Inversion is applied for HTML bodies in dark mode unless the email ships its
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
// stylesheet backgrounds) so it runs before the height reporter measures.
const applyInversion =
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
const injectedJs = React.useMemo(
() =>
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
+ SWIPE_DETECTOR + PINCH_ZOOM,
[applyInversion],
);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
const data = e.nativeEvent.data;
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) {
try {
const msg = JSON.parse(data);
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
onSwipe?.(msg.dir);
} else if (msg.type === 'zoom') {
onZoomChange?.({
pinching: !!msg.pinching,
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
});
}
} catch { /* ignore malformed */ }
return;
}
const parsed = parseInt(data, 10);
if (Number.isNaN(parsed) || parsed <= 0) return;
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
};
@@ -236,7 +637,11 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect, and persist to the
// dedicated "Trusted Senders" address book so the trust syncs across
// devices (matches the webmail behavior).
addTrustedSender(senderEmail);
void addToTrustedSendersBook(senderEmail).catch(() => {});
setAllowOnce(true);
}
};
@@ -247,13 +652,13 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
<View style={styles.banner}>
{externalContentPolicy === 'ask' && (
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
<ImageIcon size={14} color={colors.textSecondary} />
<ImageIcon size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>Load external content</Text>
</Pressable>
)}
{senderEmail ? (
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
<ShieldCheck size={14} color={colors.textSecondary} />
<ShieldCheck size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>Trust sender</Text>
</Pressable>
) : null}
@@ -263,19 +668,28 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
<View style={[styles.webContainer, { height }]}>
<WebView
ref={webviewRef}
originWhitelist={['about:*']}
originWhitelist={['about:blank']}
source={source}
javaScriptEnabled
injectedJavaScript={HEIGHT_REPORTER}
injectedJavaScript={injectedJs}
onMessage={onMessage}
onLoadEnd={() => {
// A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom).
onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout.
// `injectedJavaScript` runs too early to see the final layout. The
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
// again here is a no-op once the body has been processed.
setTimeout(() => {
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
webviewRef.current?.injectJavaScript(injectedJs);
}, 50);
}}
scrollEnabled={false}
// Native Android zoom would scale the viewport inside the fixed-size
// container; pinch zoom is implemented in the page instead.
setBuiltInZoomControls={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
setSupportMultipleWindows={false}
@@ -308,7 +722,8 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrapper: { width: '100%' },
banner: {
flexDirection: 'row',
@@ -317,9 +732,9 @@ const styles = StyleSheet.create({
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.surfaceHover,
backgroundColor: c.surfaceHover,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
bannerButton: {
flexDirection: 'row',
@@ -329,7 +744,7 @@ const styles = StyleSheet.create({
},
bannerButtonText: {
...typography.caption,
color: colors.textSecondary,
color: c.textSecondary,
},
webContainer: {
width: '100%',
@@ -343,4 +758,5 @@ const styles = StyleSheet.create({
webviewContainer: {
backgroundColor: 'transparent',
},
});
});
}
+16 -11
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface InputProps extends TextInputProps {
label?: string;
@@ -28,6 +29,8 @@ export default function Input({
style,
...props
}: InputProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [focused, setFocused] = React.useState(false);
return (
@@ -43,7 +46,7 @@ export default function Input({
<RNTextInput
{...props}
style={[styles.input, style]}
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -54,10 +57,11 @@ export default function Input({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: colors.textSecondary,
color: c.textSecondary,
marginBottom: spacing.sm,
},
inputWrapper: {
@@ -65,17 +69,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
height: componentSizes.inputHeight, // h-10 = 40px
borderWidth: 1,
borderColor: colors.border, // border-input
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
backgroundColor: colors.background, // bg-background
backgroundColor: c.background, // bg-background
paddingHorizontal: spacing.md, // px-3 = 12px
},
inputFocused: {
borderColor: colors.borderFocus, // border-ring (blue-500)
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
},
inputError: {
borderColor: colors.error,
borderColor: c.error,
},
inputDisabled: {
opacity: 0.5,
@@ -83,7 +87,7 @@ const styles = StyleSheet.create({
input: {
flex: 1,
...typography.body, // text-sm = 14px
color: colors.text, // text-foreground
color: c.text, // text-foreground
paddingVertical: 0,
},
iconLeft: {
@@ -94,7 +98,8 @@ const styles = StyleSheet.create({
},
errorText: {
...typography.caption,
color: colors.error,
color: c.error,
marginTop: spacing.xs,
},
});
});
}
+199
View File
@@ -0,0 +1,199 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Mailbox } from '../api/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
interface MoveSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
/** Folder the email is currently in - shown with a check, not selectable. */
currentMailboxId: string | null;
onPick: (id: string) => void;
}
export function MoveSheet({
visible, onClose, mailboxes, currentMailboxId, onPick,
}: MoveSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: c.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: c.surfaceHover },
moveRowLabel: {
...typography.body,
color: c.text,
flex: 1,
},
moveRowLabelDisabled: {
color: c.textMuted,
},
});
}
+51
View File
@@ -0,0 +1,51 @@
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';
interface OfflineBannerProps {
/** Optional extra notice (e.g. "Showing cached mail"). */
hint?: string;
}
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{suffix ? ` — ${suffix}` : ''}
</Text>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
bar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: '#a16207',
},
text: {
...typography.caption,
color: c.primaryForeground,
flex: 1,
},
});
}
+143
View File
@@ -0,0 +1,143 @@
// Live banner that mirrors the UpdateBanner's footprint, surfaced while the
// offline mail sync is running. Hidden once the sync settles to idle/done.
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { CloudDownload, X } from 'lucide-react-native';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { useUpdatesStore } from '../stores/updates-store';
import { formatBytes } from '../lib/offline-sync';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
export function OfflineCacheBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const sync = useOfflineCacheStore((s) => s.sync);
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
const resetSync = useOfflineCacheStore((s) => s.resetSync);
// When UpdateBanner is stacked above us it already absorbs the status-bar
// inset, so we only add it when we're the topmost banner.
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
const updateBannerVisible =
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
const topInset = updateBannerVisible ? 0 : insets.top;
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
// permanent fixture; the Settings screen still shows the cache stats.
React.useEffect(() => {
if (sync.phase === 'done' || sync.phase === 'cancelled') {
const t = setTimeout(() => resetSync(), 4000);
setHideTimer(t);
return () => clearTimeout(t);
}
if (hideTimer) clearTimeout(hideTimer);
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sync.phase, resetSync]);
if (sync.phase === 'idle') return null;
const pct =
sync.total > 0
? Math.min(100, Math.round((sync.completed / sync.total) * 100))
: sync.phase === 'done' ? 100 : 0;
let title = 'Offline sync';
let subtitle = '';
switch (sync.phase) {
case 'scanning':
title = 'Syncing offline mail';
subtitle = 'Scanning recent messages…';
break;
case 'fetching':
title = 'Syncing offline mail';
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
break;
case 'done':
title = 'Offline mail ready';
subtitle = sync.fetched > 0
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}`
: 'Already up to date';
break;
case 'cancelled':
title = 'Sync cancelled';
subtitle = `${sync.completed}/${sync.total} processed`;
break;
case 'error':
title = 'Offline sync failed';
subtitle = sync.message ?? 'Unable to download';
break;
default:
return null;
}
const showCancel = sync.phase === 'scanning' || sync.phase === 'fetching';
const showDismiss = sync.phase === 'done' || sync.phase === 'cancelled' || sync.phase === 'error';
const isError = sync.phase === 'error';
return (
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
<CloudDownload size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>{title}</Text>
{!!subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
{(sync.phase === 'fetching' || sync.phase === 'scanning') && (
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${pct}%` }]} />
</View>
)}
</View>
{showCancel && (
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
)}
{showDismiss && (
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}>
<X size={14} color={c.primaryForeground} />
</Pressable>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.primary,
},
bannerError: { backgroundColor: c.error },
title: { ...typography.bodyMedium, color: c.primaryForeground },
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85, marginTop: 2 },
progressTrack: {
marginTop: 6,
height: 3,
borderRadius: radius.full,
backgroundColor: 'rgba(255,255,255,0.25)',
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: c.primaryForeground,
},
cancelButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
cancelText: { ...typography.captionMedium, color: c.primaryForeground },
dismiss: { padding: 4 },
});
}
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import Button from './Button';
interface QrScanModalProps {
visible: boolean;
onClose: () => void;
// Fires once with the raw decoded QR string. The parent is responsible for
// closing the modal and interpreting the payload.
onScanned: (data: string) => void;
}
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [permission, requestPermission] = useCameraPermissions();
// Guards against the camera firing onBarcodeScanned dozens of times for the
// same code before the modal tears down.
const handled = React.useRef(false);
React.useEffect(() => {
if (visible) handled.current = false;
}, [visible]);
const handleBarcode = React.useCallback(
(result: BarcodeScanningResult) => {
if (handled.current) return;
handled.current = true;
onScanned(result.data);
},
[onScanned],
);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}>
{permission?.granted ? (
<CameraView
style={StyleSheet.absoluteFill}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcode}
/>
) : null}
<SafeAreaView style={styles.overlay}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
<X size={24} color="#ffffff" />
</Pressable>
<Text style={styles.title}>Scan QR code</Text>
<View style={styles.closeButton} />
</View>
{permission?.granted ? (
<View style={styles.frameWrap}>
<View style={styles.frame} />
<Text style={styles.hint}>
Point your camera at the QR code shown on the webmail settings screen.
</Text>
</View>
) : (
<View style={styles.permissionWrap}>
<Text style={styles.permissionText}>
{permission && !permission.canAskAgain
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
</Text>
<Button variant="default" size="md" onPress={() => void requestPermission()}>
Allow camera access
</Button>
</View>
)}
</SafeAreaView>
</View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: '#000000' },
overlay: { flex: 1, justifyContent: 'flex-start' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
paddingBottom: spacing.md,
},
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: '#ffffff' },
frameWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.lg },
frame: {
width: 240,
height: 240,
borderWidth: 3,
borderColor: '#ffffff',
borderRadius: radius.lg,
backgroundColor: 'transparent',
},
hint: {
...typography.caption,
color: '#ffffff',
textAlign: 'center',
paddingHorizontal: spacing.xxl,
},
permissionWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.lg,
paddingHorizontal: spacing.xxl,
backgroundColor: c.background,
},
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
+12 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface RadioOption {
label: string;
@@ -22,6 +23,8 @@ interface RadioGroupProps {
* - unselected: bg-muted text-foreground
*/
export default function RadioGroup({ options, value, onChange, style }: RadioGroupProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.container, style]}>
{options.map((opt) => {
@@ -42,7 +45,8 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
flexDirection: 'row',
gap: 6, // gap-1.5
@@ -53,19 +57,20 @@ const styles = StyleSheet.create({
borderRadius: radius.sm, // rounded-md
},
optionSelected: {
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
optionUnselected: {
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
optionText: {
...typography.caption, // text-xs
},
textSelected: {
color: colors.primaryForeground,
color: c.primaryForeground,
fontWeight: '500',
},
textUnselected: {
color: colors.text,
color: c.text,
},
});
});
}
+473
View File
@@ -0,0 +1,473 @@
import React from 'react';
import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
const MIN_HEIGHT = 220;
export type RichTextCommand =
| 'bold'
| 'italic'
| 'underline'
| 'strikeThrough'
| 'insertUnorderedList'
| 'insertOrderedList'
| 'formatBlock:H1'
| 'formatBlock:H2'
| 'formatBlock:BLOCKQUOTE'
| 'formatBlock:P'
| 'justifyLeft'
| 'justifyCenter'
| 'justifyRight'
| 'removeFormat'
| 'undo'
| 'redo';
export interface RichTextSelectionState {
bold: boolean;
italic: boolean;
underline: boolean;
strikeThrough: boolean;
ul: boolean;
ol: boolean;
blockquote: boolean;
h1: boolean;
h2: boolean;
alignLeft: boolean;
alignCenter: boolean;
alignRight: boolean;
link: boolean;
}
const EMPTY_STATE: RichTextSelectionState = {
bold: false, italic: false, underline: false, strikeThrough: false,
ul: false, ol: false, blockquote: false, h1: false, h2: false,
alignLeft: false, alignCenter: false, alignRight: false, link: false,
};
export interface RichTextEditorHandle {
exec(command: RichTextCommand): void;
insertLink(url: string, label?: string): void;
unsetLink(): void;
insertImage(src: string, cid?: string, alt?: string): void;
setHtml(html: string): void;
focus(): void;
}
interface Props {
initialHtml?: string;
placeholder?: string;
onChange?: (html: string) => void;
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
}
function buildEditorHtml(opts: {
initialHtml: string;
placeholder: string;
c: ThemePalette;
}): string {
const { initialHtml, placeholder, c } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
const placeholderJson = JSON.stringify(placeholder);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
}
function post(type, payload) {
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
}
}
function reportHeight() {
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
function reportChange() {
refreshEmpty();
var html = editor.innerHTML;
if (html === lastHtml) return;
lastHtml = html;
post('change', html);
}
function reportSelection() {
function active(name) {
try { return document.queryCommandState(name); } catch (e) { return false; }
}
function inBlock(tag) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return false;
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === tag) return true;
node = node.parentNode;
}
return false;
}
var state = {
bold: active('bold'),
italic: active('italic'),
underline: active('underline'),
strikeThrough: active('strikeThrough'),
ul: active('insertUnorderedList'),
ol: active('insertOrderedList'),
blockquote: inBlock('BLOCKQUOTE'),
h1: inBlock('H1'),
h2: inBlock('H2'),
alignLeft: active('justifyLeft'),
alignCenter: active('justifyCenter'),
alignRight: active('justifyRight'),
link: inBlock('A'),
};
post('selection', state);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
editor.addEventListener('input', function () {
reportChange();
reportHeight();
});
editor.addEventListener('blur', function () { post('blur', null); });
editor.addEventListener('focus', function () { post('focus', null); });
document.addEventListener('selectionchange', function () {
if (document.activeElement === editor) reportSelection();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var node = sel.anchorNode;
while (node && node !== editor) {
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
document.execCommand('formatBlock', false, '<p>');
reportChange();
reportHeight();
reportSelection();
return;
}
node = node.parentNode;
}
}
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
} else {
document.execCommand(command, false, value);
}
} catch (e) {}
reportChange();
reportHeight();
reportSelection();
},
setHtml: function (html) {
editor.innerHTML = html || '';
lastHtml = editor.innerHTML;
refreshEmpty();
reportChange();
reportHeight();
},
insertHtml: function (html) {
editor.focus();
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
insertLink: function (url, label) {
editor.focus();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
var trimmed = (url || '').trim();
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
if (a.getAttribute('href') === trimmed) {
a.setAttribute('rel', 'noopener noreferrer nofollow');
}
});
} else {
var text = label && label.trim() ? label : trimmed;
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
ref,
) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const webViewRef = React.useRef<WebView>(null);
const [height, setHeight] = React.useState(MIN_HEIGHT);
const onChangeRef = React.useRef(onChange);
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
onBlurRef.current = onBlur;
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c }),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const call = React.useCallback((script: string) => {
webViewRef.current?.injectJavaScript(`${script}; true;`);
}, []);
React.useImperativeHandle(ref, () => ({
exec: (command) => {
const [name, value] = command.split(':');
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
},
insertLink: (url, label) => {
call(`window.__rne && window.__rne.insertLink(${JSON.stringify(url)}, ${JSON.stringify(label ?? '')})`);
},
unsetLink: () => {
call(`window.__rne && window.__rne.unsetLink()`);
},
insertImage: (src, cid, alt) => {
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
},
setHtml: (next) => {
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
},
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
let data: { type: string; payload: unknown };
try {
data = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
switch (data.type) {
case 'change':
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
break;
case 'height': {
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
break;
}
case 'selection':
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
break;
case 'focus':
onFocusRef.current?.();
break;
case 'blur':
onBlurRef.current?.();
break;
}
};
return (
<View style={[styles.container, { height }]}>
<WebView
ref={webViewRef}
originWhitelist={['about:blank']}
source={{ html }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled={false}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
keyboardDisplayRequiresUserAction={false}
hideKeyboardAccessoryView
setSupportMultipleWindows={false}
thirdPartyCookiesEnabled={false}
sharedCookiesEnabled={false}
allowFileAccess={false}
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
allowsBackForwardNavigationGestures={false}
incognito
cacheEnabled={false}
mixedContentMode="never"
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
style={styles.webview}
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
// Allow only the initial about:blank doc + data URIs we emit
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
// scheme so a paste-injected anchor can't navigate the editor.
const url = request.url;
return !url || url === 'about:blank' || url.startsWith('data:');
}}
/>
</View>
);
});
export default RichTextEditor;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
width: '100%',
backgroundColor: c.background,
},
webview: {
backgroundColor: 'transparent',
flex: 1,
},
webviewContainer: {
backgroundColor: 'transparent',
},
});
}
+101 -57
View File
@@ -8,9 +8,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
import { useEmailStore } from '../stores/email-store';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
@@ -48,21 +51,23 @@ function iconFor(
return Folder;
}
const ROLE_COLOR: Record<string, string> = {
const ROLE_COLOR_FIXED: Record<string, string> = {
inbox: '#60a5fa',
sent: '#4ade80',
drafts: '#a78bfa',
trash: colors.textMuted,
junk: '#f87171',
archive: '#fbbf24',
};
function iconColor(role: string | null | undefined, isSelected: boolean): string {
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
return isSelected ? colors.text : colors.textSecondary;
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
if (role === 'trash') return c.textMuted;
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
return isSelected ? c.text : c.textSecondary;
}
function RowCounts({ unread, total }: { unread: number; total: number }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
if (unread === 0 && total === 0) return null;
return (
<View style={styles.counts}>
@@ -90,6 +95,8 @@ function SidebarRow({
icon, label, depth, isSelected, unread, total,
hasChildren, isExpanded, onPress, onToggleExpand,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
return (
<Pressable
@@ -108,9 +115,9 @@ function SidebarRow({
style={styles.chevron}
>
{isExpanded ? (
<ChevronDown size={12} color={colors.textMuted} />
<ChevronDown size={12} color={c.textMuted} />
) : (
<ChevronRight size={12} color={colors.textMuted} />
<ChevronRight size={12} color={c.textMuted} />
)}
</Pressable>
) : (
@@ -135,6 +142,8 @@ interface SidebarDrawerProps {
}
export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const selectMailbox = useEmailStore((s) => s.selectMailbox);
@@ -216,20 +225,22 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }),
]).start();
}
}, [visible, slideX, overlayOpacity]);
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
const accountEmail = username || '';
const initials = React.useMemo(
@@ -237,7 +248,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
[accountEmail],
);
const avatarBg = React.useMemo(
() => (accountEmail ? generateAvatarColor(accountEmail) : colors.primary),
() => (accountEmail ? generateAvatarColor(accountEmail) : c.primary),
[accountEmail],
);
const hostname = React.useMemo(() => {
@@ -267,7 +278,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
{/* Header: close + account switcher */}
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
@@ -284,7 +295,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</View>
<ChevronDown
size={16}
color={colors.textMuted}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
</Pressable>
@@ -323,7 +334,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={colors.primaryForeground} strokeWidth={3} />
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
@@ -364,7 +375,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={colors.textSecondary} />
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>Add account</Text>
</Pressable>
</>
@@ -382,7 +393,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={colors.textSecondary} />
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>Set as default</Text>
</Pressable>
);
@@ -394,7 +405,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
>
<LogOut size={16} color={colors.textSecondary} />
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
Sign out of {accountEmailFull || 'account'}
</Text>
@@ -407,8 +418,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
>
<LogOut size={16} color={colors.error} />
<Text style={[styles.accountMenuActionText, { color: colors.error }]}>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
Sign out all
</Text>
</Pressable>
@@ -417,17 +428,35 @@ 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 ? (
<ChevronDown size={14} color={colors.textMuted} />
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>Folders</Text>
<View style={{ flex: 1 }} />
<View style={styles.sectionSettings}>
<Settings size={14} color={colors.textMuted} />
<Settings size={14} color={c.textMuted} />
</View>
</Pressable>
@@ -443,7 +472,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
return (
<SidebarRow
key={node.id}
icon={<Icon size={16} color={iconColor(node.role, isSelected)} />}
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
label={node.name}
depth={node.depth}
isSelected={isSelected}
@@ -465,7 +494,8 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -476,9 +506,11 @@ const styles = StyleSheet.create({
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
// dark-mode value, which left light mode unreadable (dark bg + dark text).
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -490,7 +522,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36, height: 36,
@@ -506,7 +538,7 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: radius.sm,
},
accountPressed: { backgroundColor: colors.surfaceHover },
accountPressed: { backgroundColor: c.surfaceHover },
accountChevronOpen: { transform: [{ rotate: '180deg' }] },
accountAvatar: {
width: 36, height: 36,
@@ -515,21 +547,21 @@ const styles = StyleSheet.create({
},
accountAvatarText: {
...typography.bodySemibold,
color: colors.primaryForeground,
color: c.primaryForeground,
},
accountInfo: { flex: 1, minWidth: 0 },
accountName: { ...typography.bodyMedium, color: colors.text },
accountEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
accountName: { ...typography.bodyMedium, color: c.text },
accountEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
// Account menu — floating popover card under the header
// Account menu - floating popover card under the header
accountMenu: {
marginHorizontal: spacing.sm,
marginTop: spacing.xs,
marginBottom: spacing.sm,
backgroundColor: '#1f1f1f',
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
// Elevation / shadow
shadowColor: '#000',
@@ -566,14 +598,14 @@ const styles = StyleSheet.create({
accountMenuAvatarText: {
...typography.caption,
fontWeight: '600',
color: colors.primaryForeground,
color: c.primaryForeground,
},
accountMenuCheckBadge: {
position: 'absolute',
right: -3, bottom: -3,
width: 16, height: 16,
borderRadius: 8,
backgroundColor: colors.primary,
backgroundColor: c.primary,
alignItems: 'center', justifyContent: 'center',
borderWidth: 2,
borderColor: '#1f1f1f',
@@ -586,13 +618,13 @@ const styles = StyleSheet.create({
},
accountMenuName: {
...typography.bodyMedium,
color: colors.text,
color: c.text,
flexShrink: 1,
fontWeight: '600',
},
accountMenuHost: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
marginTop: 2,
},
accountMenuStatusRow: {
@@ -607,17 +639,17 @@ const styles = StyleSheet.create({
backgroundColor: '#22c55e',
},
accountMenuStatusDotOffline: {
backgroundColor: colors.textMuted,
backgroundColor: c.textMuted,
},
accountMenuStatusText: {
fontSize: 10,
lineHeight: 12,
color: colors.textMuted,
color: c.textMuted,
flexShrink: 1,
},
accountMenuDivider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
backgroundColor: c.border,
},
accountMenuAction: {
flexDirection: 'row',
@@ -627,10 +659,10 @@ const styles = StyleSheet.create({
paddingVertical: 10,
minHeight: 40,
},
accountMenuActionPressed: { backgroundColor: colors.surfaceHover },
accountMenuActionPressed: { backgroundColor: c.surfaceHover },
accountMenuActionText: {
...typography.body,
color: colors.text,
color: c.text,
flexShrink: 1,
fontSize: 13,
},
@@ -650,13 +682,24 @@ const styles = StyleSheet.create({
},
sectionHeaderText: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
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: colors.textMuted,
color: c.textMuted,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
@@ -670,10 +713,10 @@ const styles = StyleSheet.create({
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: {
backgroundColor: colors.accent,
borderLeftColor: colors.primary,
backgroundColor: c.accent,
borderLeftColor: c.primary,
},
rowIndent: {
flexDirection: 'row',
@@ -692,11 +735,11 @@ const styles = StyleSheet.create({
rowLabel: {
flex: 1,
...typography.body,
color: colors.text,
color: c.text,
},
rowLabelSelected: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
},
counts: {
flexDirection: 'row',
@@ -707,16 +750,17 @@ const styles = StyleSheet.create({
countUnread: {
...typography.caption,
fontWeight: '600',
color: colors.text,
color: c.text,
},
countSep: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
opacity: 0.6,
},
countTotal: {
...typography.caption,
color: colors.textMuted,
color: c.textMuted,
},
});
});
}
+316
View File
@@ -0,0 +1,316 @@
import React, { useRef, useState } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
/** Right-to-left swipe action (revealed under a leftward drag, sits at right edge). */
leftAction: SwipeAction;
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean; pinned: boolean };
onAction: (action: SwipeAction) => void;
/**
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
*/
mode?: SwipeMode;
}
// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' },
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
};
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
if (action === 'none') return '';
return ACTION_META[action].defaultLabel;
}
// Actions that visibly remove the row from the list (so the row should fly off
// instead of snapping back). Toggle-style actions stay in place.
function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = null;
setOpenSide(null);
};
const openTo = (side: 'left' | 'right') => {
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = side;
setOpenSide(side);
};
const fly = (toValue: number, action: SwipeAction) => {
// For destructive/move actions: race the row off-screen and fire the
// action - the parent will remove the row from the list. For toggle
// actions: fire immediately and snap back so the same row can update in
// place.
if (exitsRow(action)) {
Animated.timing(dx, {
toValue,
duration: 180,
useNativeDriver: true,
}).start(() => {
onAction(action);
// The row is about to be removed; reset translation in case it isn't
// (e.g. the action failed silently) so we don't leave it off-screen.
dx.setValue(0);
});
} else {
onAction(action);
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
};
const fireFromBandTap = (action: SwipeAction) => {
close();
if (action === 'none') return;
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
} else {
onAction(action);
}
};
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
: 0;
const next = base + g.dx;
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
if (mode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openTo('left');
} else {
close();
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
if (mode === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
if (mode === 'reveal') {
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
// are tap targets. The row slides over them; releasing past the activation
// threshold snaps the row to the open position so the band is fully
// visible and tappable.
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<Pressable
onPress={() => fireFromBandTap(action)}
style={[
styles.bandReveal,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
);
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderRevealBand(rightAction, 'left')}
{renderRevealBand(leftAction, 'right')}
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so
a tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
}
// Instant mode: bands fill the gap behind the row as it's dragged.
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({
inputRange,
outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85],
extrapolate: 'clamp',
});
return (
<View
style={[
styles.bandInstant,
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
>
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
<Icon size={22} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</Animated.View>
</View>
);
};
const rightBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
});
const leftBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [1, 0, 0],
extrapolate: 'clamp',
});
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderInstantBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderInstantBand(leftAction, 'right')}
</Animated.View>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
</Animated.View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
bandInstant: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandReveal: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
+142
View File
@@ -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 -6
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Pressable, View, StyleSheet } from 'react-native';
import { colors, radius, componentSizes } from '../theme/tokens';
import { radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface ToggleSwitchProps {
value: boolean;
@@ -16,6 +17,8 @@ interface ToggleSwitchProps {
* - translate-x-6 (checked) / translate-x-1 (unchecked)
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false }: ToggleSwitchProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
style={[
@@ -37,7 +40,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false }:
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
track: {
width: componentSizes.toggleWidth, // w-11 = 44
height: componentSizes.toggleHeight, // h-6 = 24
@@ -45,18 +49,19 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
trackOn: {
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
trackOff: {
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
height: componentSizes.toggleThumb,
borderRadius: radius.full,
backgroundColor: colors.background,
backgroundColor: c.background,
},
disabled: {
opacity: 0.5,
},
});
});
}
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
const VISIBLE_MS = 5000;
export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const entry = useEmailStore((s) => s.pendingUndo);
const undoLast = useEmailStore((s) => s.undoLast);
const clearUndo = useEmailStore((s) => s.clearUndo);
const slideY = React.useRef(new Animated.Value(120)).current;
const opacity = React.useRef(new Animated.Value(0)).current;
// Remember the last non-null entry so the bar's text/handlers stay valid
// while it animates out after pendingUndo flips to null.
const [shown, setShown] = React.useState(entry);
React.useEffect(() => {
if (entry) setShown(entry);
}, [entry]);
// Drive the auto-dismiss off the entry's createdAt so re-renders during
// animation don't reset the timer, and so consecutive actions reset it.
React.useEffect(() => {
if (!entry) {
Animated.parallel([
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start(({ finished }) => {
if (finished) setShown(null);
});
return;
}
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
const elapsed = Date.now() - entry.createdAt;
const remaining = Math.max(0, VISIBLE_MS - elapsed);
const id = setTimeout(() => {
// Only clear if the entry is still the same one we scheduled for.
if (useEmailStore.getState().pendingUndo === entry) {
clearUndo();
}
}, remaining);
return () => clearTimeout(id);
}, [entry, slideY, opacity, clearUndo]);
if (!shown) return null;
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
<Pressable
onPress={() => { void undoLast(); }}
hitSlop={8}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.undoText}>UNDO</Text>
</Pressable>
</View>
</Animated.View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
},
bar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
paddingLeft: spacing.lg,
paddingRight: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.text,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 8,
},
label: {
...typography.body,
color: c.background,
flex: 1,
},
undoBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
},
undoBtnPressed: {
backgroundColor: 'rgba(255,255,255,0.1)',
},
undoText: {
...typography.bodySemibold,
color: c.primary,
letterSpacing: 0.5,
},
});
}
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
export function UpdateBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
const installLatest = useUpdatesStore((s) => s.installLatest);
const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent);
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
if (!hasUpdate()) return null;
if (cachedLatest && dismissedTag === cachedLatest.tag) return null;
if (!cachedLatest?.apkAsset) return null;
return (
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
<Download size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.subtitle}>
v{cachedLatest.tag} is ready to install.
</Text>
</View>
<Pressable
style={styles.installButton}
onPress={() => void installLatest()}
disabled={installing}
>
<Text style={styles.installText}>{installing ? '…' : 'Install'}</Text>
</Pressable>
<Pressable style={styles.dismiss} onPress={dismissCurrent} hitSlop={8}>
<X size={14} color={c.primaryForeground} />
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: c.primary,
},
title: { ...typography.bodyMedium, color: c.primaryForeground },
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85 },
installButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
installText: { ...typography.captionMedium, color: c.primaryForeground },
dismiss: { padding: 4 },
});
}
+49 -38
View File
@@ -1,14 +1,16 @@
import React from 'react';
import { SectionList, StyleSheet, Text, View } from 'react-native';
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
import { CalendarDays } from 'lucide-react-native';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, spacing, typography } from '../../theme/tokens';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
dayKey,
eventsOnDayFromIndex,
getEventStartDate,
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { EventCard } from './EventCard';
@@ -18,6 +20,7 @@ interface AgendaViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
}
@@ -39,46 +42,52 @@ export function AgendaView({
events,
calendars,
eventsByDay,
timeFormat,
onSelectEvent,
}: AgendaViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
);
const sections = React.useMemo<DaySection[]>(() => {
const result: DaySection[] = [];
const start = new Date(fromDate);
start.setHours(0, 0, 0, 0);
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
// every day that actually has events, always include a "Today" anchor, and
// render them sorted ascending. Driving the list off the events (rather than
// a fixed day-counter) guarantees every upcoming loaded event appears - not
// just the ones inside a brittle N-day window from the anchor.
const anchor = startOfDay(fromDate);
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
for (let i = 0; i < daysAhead; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title
// (stable tie-break) — mirrors the comparator the webmail uses for
// packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1;
}
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
if (diff !== 0) return diff;
return (a.title || '').localeCompare(b.title || '');
});
if (dayEvents.length === 0 && !isToday(day)) continue;
result.push({
const days = new Map<string, Date>();
for (const key of index.keys()) {
const day = parseISO(key); // index keys are local yyyy-MM-dd
if (isNaN(day.getTime())) continue;
if (day < anchor || day >= horizonEnd) continue;
days.set(key, day);
}
// Always anchor "Today" so the view has a stable starting point even when
// the current day has no events (webmail injects the same anchor).
const today = startOfDay(new Date());
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
return [...days.values()]
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => ({
title: formatDayHeader(day),
date: day,
data: dayEvents,
});
}
return result;
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
// start, then title), so no extra sort is needed here.
data: eventsOnDayFromIndex(index, day),
}));
}, [fromDate, daysAhead, index]);
if (sections.length === 0) {
return (
<View style={styles.empty}>
<CalendarDays size={40} color={colors.surfaceActive} />
<CalendarDays size={40} color={c.surfaceActive} />
<Text style={styles.emptyTitle}>No upcoming events</Text>
<Text style={styles.emptySubtitle}>The next {daysAhead} days are clear.</Text>
</View>
@@ -104,7 +113,7 @@ export function AgendaView({
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
</View>
)}
renderSectionFooter={({ section }) =>
@@ -118,7 +127,8 @@ export function AgendaView({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
listContent: { paddingBottom: 80 },
sectionHeader: {
flexDirection: 'row',
@@ -126,16 +136,16 @@ const styles = StyleSheet.create({
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
sectionTitle: { ...typography.bodyMedium, color: colors.text },
sectionTitleToday: { color: colors.primary },
sectionSub: { ...typography.caption, color: colors.textMuted },
sectionTitle: { ...typography.bodyMedium, color: c.text },
sectionTitleToday: { color: c.primary },
sectionSub: { ...typography.caption, color: c.textMuted },
itemWrap: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm },
emptyDayWrap: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md },
emptyDayText: { ...typography.caption, color: colors.textMuted, textAlign: 'center' },
emptyDayText: { ...typography.caption, color: c.textMuted, textAlign: 'center' },
empty: {
flex: 1,
alignItems: 'center',
@@ -143,6 +153,7 @@ const styles = StyleSheet.create({
gap: spacing.sm,
paddingVertical: spacing.xxxl,
},
emptyTitle: { ...typography.bodyMedium, color: colors.textSecondary },
emptySubtitle: { ...typography.caption, color: colors.textMuted },
});
emptyTitle: { ...typography.bodyMedium, color: c.textSecondary },
emptySubtitle: { ...typography.caption, color: c.textMuted },
});
}
+232 -57
View File
@@ -11,10 +11,13 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Check, X } from 'lucide-react-native';
import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { getCalendarColor } from '../../lib/calendar-utils';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
interface CalendarSidebarDrawerProps {
visible: boolean;
@@ -22,6 +25,13 @@ interface CalendarSidebarDrawerProps {
hiddenCalendarIds: string[];
onToggle: (id: string) => void;
onClose: () => void;
onImport?: () => void;
onManageSubscriptions?: () => void;
// Long-press actions. Set-default applies to the user's own calendars;
// color change/reset applies to shared calendars (per-viewer recolor).
onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
}
export function CalendarSidebarDrawer({
@@ -30,46 +40,72 @@ export function CalendarSidebarDrawer({
hiddenCalendarIds,
onToggle,
onClose,
onImport,
onManageSubscriptions,
onSetDefault,
onSetColor,
onResetColor,
}: CalendarSidebarDrawerProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
const [expandedId, setExpandedId] = React.useState<string | null>(null);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, {
toValue: 0,
duration: 240,
duration: openDuration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 240,
duration: openDuration,
useNativeDriver: true,
}),
]).start();
} else {
setExpandedId(null);
Animated.parallel([
Animated.timing(slideX, {
toValue: -Dimensions.get('window').width,
duration: 200,
duration: closeDuration,
easing: Easing.in(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 0,
duration: 200,
duration: closeDuration,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideX, overlayOpacity]);
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
const myCalendars = calendars.filter((c) => !c.myRights || c.myRights.mayWrite !== false);
const subscribed = calendars.filter((c) => c.myRights && c.myRights.mayWrite === false);
const sharedCalendars = calendars.filter((cal) => cal.isShared);
const myCalendars = calendars.filter(
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
);
const subscribed = calendars.filter(
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
);
const sectionProps = {
hiddenSet,
onToggle,
expandedId,
onExpand: setExpandedId,
onSetDefault,
onSetColor,
onResetColor,
};
return (
<Modal
@@ -89,7 +125,7 @@ export function CalendarSidebarDrawer({
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Calendars</Text>
</View>
@@ -100,21 +136,38 @@ export function CalendarSidebarDrawer({
)}
{myCalendars.length > 0 && (
<Section
title="My calendars"
calendars={myCalendars}
hiddenSet={hiddenSet}
onToggle={onToggle}
/>
<Section title="My calendars" calendars={myCalendars} {...sectionProps} />
)}
{sharedCalendars.length > 0 && (
<Section title="Shared with me" calendars={sharedCalendars} {...sectionProps} />
)}
{subscribed.length > 0 && (
<Section
title="Subscribed"
calendars={subscribed}
hiddenSet={hiddenSet}
onToggle={onToggle}
/>
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
)}
{(onImport || onManageSubscriptions) && (
<View style={styles.actionsSection}>
{onManageSubscriptions && (
<Pressable
onPress={onManageSubscriptions}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Rss size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Subscriptions</Text>
</Pressable>
)}
{onImport && (
<Pressable
onPress={onImport}
style={({ pressed }) => [styles.actionRow, pressed && styles.rowPressed]}
>
<Upload size={18} color={c.textSecondary} />
<Text style={styles.actionText}>Import from file</Text>
</Pressable>
)}
</View>
)}
</ScrollView>
</SafeAreaView>
@@ -128,48 +181,116 @@ function Section({
calendars,
hiddenSet,
onToggle,
expandedId,
onExpand,
onSetDefault,
onSetColor,
onResetColor,
}: {
title: string;
calendars: Calendar[];
hiddenSet: Set<string>;
onToggle: (id: string) => void;
expandedId: string | null;
onExpand: (id: string | null) => void;
onSetDefault?: (calendar: Calendar) => void;
onSetColor?: (calendar: Calendar, color: string) => void;
onResetColor?: (calendar: Calendar) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{calendars.map((c) => {
const visible = !hiddenSet.has(c.id);
{calendars.map((cal) => {
const visible = !hiddenSet.has(cal.id);
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault;
const canRecolor = !!onSetColor && !!cal.isShared;
const hasActions = canSetDefault || canRecolor;
const expanded = expandedId === cal.id && hasActions;
return (
<Pressable
key={c.id}
onPress={() => onToggle(c.id)}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
<View
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(c) : 'transparent',
borderColor: getCalendarColor(c),
},
<View key={cal.id}>
<Pressable
onPress={() => onToggle(cal.id)}
onLongPress={hasActions ? () => onExpand(expanded ? null : cal.id) : undefined}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
{visible && <Check size={12} color={colors.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
{c.name}
</Text>
</Pressable>
<View
style={[
styles.swatch,
{
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
borderColor: getCalendarColor(cal),
},
]}
>
{visible && <Check size={12} color={c.textInverse} />}
</View>
<Text style={[styles.rowName, !visible && styles.rowNameMuted]} numberOfLines={1}>
{cal.name}
</Text>
{cal.isDefault && !cal.isShared && (
<Star size={14} color={c.textMuted} fill={c.textMuted} />
)}
</Pressable>
{expanded && (
<View style={styles.actionsPanel}>
{canSetDefault && (
<Pressable
onPress={() => { onExpand(null); onSetDefault!(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Set as default calendar</Text>
</Pressable>
)}
{canRecolor && (
<>
<View style={styles.paletteRow}>
{CALENDAR_COLOR_PALETTE.map((color) => {
const active = cal.color?.toLowerCase() === color.toLowerCase();
return (
<Pressable
key={color}
onPress={() => { onExpand(null); onSetColor!(cal, color); }}
style={[
styles.paletteSwatch,
{ backgroundColor: color },
active && styles.paletteSwatchActive,
]}
>
{active && <Check size={12} color={c.textInverse} />}
</Pressable>
);
})}
</View>
{!!onResetColor && cal.colorIsLocalOverride && (
<Pressable
onPress={() => { onExpand(null); onResetColor(cal); }}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
>
<Shuffle size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>Random color</Text>
</Pressable>
)}
</>
)}
</View>
)}
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -182,9 +303,9 @@ const styles = StyleSheet.create({
left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -195,7 +316,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36,
@@ -204,14 +325,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.sm,
},
headerTitle: { ...typography.h3, color: colors.text },
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
scrollContent: { paddingBottom: spacing.lg },
empty: {
...typography.body,
color: colors.textMuted,
color: c.textMuted,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
},
@@ -219,7 +340,7 @@ const styles = StyleSheet.create({
section: { paddingTop: spacing.md },
sectionTitle: {
...typography.bodySemibold,
color: colors.text,
color: c.text,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xs,
},
@@ -231,7 +352,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
minHeight: 44,
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowPressed: { backgroundColor: c.surfaceHover },
swatch: {
width: 18,
height: 18,
@@ -240,6 +361,60 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
rowName: { flex: 1, ...typography.body, color: colors.text },
rowNameMuted: { color: colors.textMuted },
});
rowName: { flex: 1, ...typography.body, color: c.text },
rowNameMuted: { color: c.textMuted },
actionsPanel: {
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
overflow: 'hidden',
},
panelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
minHeight: 40,
},
panelRowText: { ...typography.body, color: c.text },
paletteRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
paletteSwatch: {
width: 24,
height: 24,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
paletteSwatchActive: {
borderWidth: 2,
borderColor: c.text,
},
actionsSection: {
marginTop: spacing.lg,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: c.border,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
minHeight: 44,
},
actionText: { ...typography.body, color: c.text },
});
}
+32 -19
View File
@@ -3,12 +3,19 @@ import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Clock, MapPin, Users } from 'lucide-react-native';
import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
interface EventCardProps {
event: CalendarEvent;
calendars: Calendar[];
timeFormat?: TimeFormat;
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
}
@@ -17,15 +24,18 @@ function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0;
}
function formatTimeRange(event: CalendarEvent): string {
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) return 'All day';
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
const fmt = timePattern(timeFormat);
return `${format(start, fmt)} – ${format(end, fmt)}`;
}
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const color = getEventColor(event, calendars);
const time = formatTimeRange(event);
const time = formatTimeRange(event, timeFormat);
const count = participantCount(event);
return (
@@ -48,13 +58,13 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
</View>
{!event.showWithoutTime && (
<View style={styles.detailRow}>
<Clock size={12} color={colors.textMuted} />
<Clock size={12} color={c.textMuted} />
<Text style={styles.detailText}>{time}</Text>
</View>
)}
{event.description ? (
<View style={styles.detailRow}>
<MapPin size={12} color={colors.textMuted} />
<MapPin size={12} color={c.textMuted} />
<Text style={styles.detailText} numberOfLines={1}>
{event.description}
</Text>
@@ -62,7 +72,7 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
) : null}
{count > 0 && (
<View style={styles.detailRow}>
<Users size={12} color={colors.textMuted} />
<Users size={12} color={c.textMuted} />
<Text style={styles.detailText}>{count} participants</Text>
</View>
)}
@@ -71,17 +81,19 @@ export function EventCard({ event, calendars, onPress, onLongPress }: EventCardP
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
card: {
flexDirection: 'row',
backgroundColor: colors.card,
borderRadius: radius.lg,
backgroundColor: c.card,
borderTopRightRadius: radius.lg,
borderBottomRightRadius: radius.lg,
marginBottom: spacing.sm,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
cardPressed: { backgroundColor: colors.surfaceHover },
cardPressed: { backgroundColor: c.surfaceHover },
colorBar: { width: 3 },
body: { flex: 1, padding: spacing.md, gap: 4 },
headerRow: {
@@ -90,14 +102,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: spacing.sm,
},
title: { ...typography.bodyMedium, color: colors.text, flex: 1 },
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
allDayBadge: {
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: radius.full,
},
allDayText: { ...typography.small, color: colors.primary },
allDayText: { ...typography.small, color: c.primary },
detailRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
detailText: { ...typography.caption, color: colors.textMuted, flexShrink: 1 },
});
detailText: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
});
}
+218 -65
View File
@@ -3,6 +3,7 @@ import {
Animated,
Dimensions,
Easing,
Linking,
Modal,
Pressable,
ScrollView,
@@ -15,34 +16,51 @@ 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';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
eventTimeRange,
getEventColor,
getPrimaryCalendarId,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
findParticipantByEmail,
isOrganizerParticipant,
} from '../../lib/calendar-invitation';
import { useAccountStore } from '../../stores/account-store';
import { useSheetDrag } from '../../lib/use-sheet-drag';
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
timeFormat?: TimeFormat;
onClose: () => void;
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
onDuplicate?: (event: CalendarEvent) => void;
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
}
function formatRange(event: CalendarEvent): string {
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) {
if (
@@ -54,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
}
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
}
const t = timePattern(timeFormat);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
}
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
}
function recurrenceLabel(event: CalendarEvent): string | null {
@@ -82,33 +101,28 @@ function recurrenceLabel(event: CalendarEvent): string | null {
return label;
}
function alertLabel(event: CalendarEvent): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first) return null;
const offset = first.trigger?.offset;
if (!offset) return null;
if (offset === 'PT0S' || offset === '-PT0S') return 'At time of event';
const min = offset.match(/-?PT(\d+)M$/);
if (min) return `${min[1]} minutes before`;
const hr = offset.match(/-?PT(\d+)H$/);
if (hr) return `${hr[1]} hours before`;
const day = offset.match(/-?P(\d+)D/);
if (day) return `${day[1]} days before`;
return null;
}
export function EventDetailSheet({
event,
calendars,
timeFormat,
onClose,
onEdit,
onDelete,
onDuplicate,
onRsvp,
}: EventDetailSheetProps) {
const c = useColors();
const activeEmail = useAccountStore((s) => s.getActiveAccount()?.email ?? null);
const [rsvpBusy, setRsvpBusy] = React.useState(false);
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const visible = !!event;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
React.useEffect(() => {
if (visible) {
@@ -147,10 +161,28 @@ export function EventDetailSheet({
const color = getEventColor(event, calendars);
const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event);
const range = formatRange(event, timeFormat);
const recurrence = recurrenceLabel(event);
const alert = alertLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
const videoUri = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri : undefined;
// Can the signed-in user RSVP? Only when they appear as a non-organizer
// participant and the caller wired an onRsvp handler.
const me = activeEmail ? findParticipantByEmail(event, [activeEmail]) : null;
const canRsvp = Boolean(onRsvp && me && !isOrganizerParticipant(me.participant));
const myStatus = me?.participant.participationStatus;
const doRsvp = async (status: RsvpStatus) => {
if (!onRsvp || !me || rsvpBusy) return;
setRsvpBusy(true);
try {
await onRsvp(event, me.id, status);
} finally {
setRsvpBusy(false);
}
};
return (
<Modal
@@ -166,17 +198,21 @@ export function EventDetailSheet({
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View style={styles.handle} />
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.headerText}>
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
<View style={styles.header}>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={styles.headerText}>
<Text style={styles.title}>{event.title || 'Untitled'}</Text>
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
</View>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
</View>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={colors.textMuted} />
</Pressable>
</View>
<ScrollView
@@ -184,37 +220,85 @@ export function EventDetailSheet({
contentContainerStyle={styles.bodyContent}
showsVerticalScrollIndicator={false}
>
<DetailRow icon={<Clock size={16} color={colors.textMuted} />} text={range} />
<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={colors.textMuted} />}
icon={<AlignLeft size={16} color={c.textMuted} />}
text={event.description}
multiline
/>
) : null}
{recurrence && (
<DetailRow
icon={<Repeat size={16} color={colors.textMuted} />}
icon={<Repeat size={16} color={c.textMuted} />}
text={recurrence}
/>
)}
{alert && (
{reminders.length > 0 && (
<DetailRow
icon={<Bell size={16} color={colors.textMuted} />}
text={alert}
icon={<Bell size={16} color={c.textMuted} />}
text={reminders.map((r) => formatReminder(r.minutesBefore)).join(', ')}
/>
)}
{calendar && (
<DetailRow
icon={<CalendarIcon size={16} color={colors.textMuted} />}
icon={<CalendarIcon size={16} color={c.textMuted} />}
text={calendar.name}
/>
)}
{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}>
<Users size={16} color={colors.textMuted} />
<Users size={16} color={c.textMuted} />
<Text style={styles.participantsHeaderText}>
{participants.length} participants
</Text>
@@ -225,7 +309,7 @@ export function EventDetailSheet({
style={[
styles.participantDot,
{
backgroundColor: statusColor(p.participationStatus),
backgroundColor: statusColor(c, p.participationStatus),
},
]}
/>
@@ -244,21 +328,21 @@ export function EventDetailSheet({
<View style={styles.actions}>
{onEdit && (
<ActionButton
icon={<Pencil size={18} color={colors.text} />}
icon={<Pencil size={18} color={c.text} />}
label="Edit"
onPress={() => onEdit(event)}
/>
)}
{onDuplicate && (
<ActionButton
icon={<Copy size={18} color={colors.text} />}
icon={<Copy size={18} color={c.text} />}
label="Duplicate"
onPress={() => onDuplicate(event)}
/>
)}
{onDelete && (
<ActionButton
icon={<Trash2 size={18} color={colors.error} />}
icon={<Trash2 size={18} color={c.error} />}
label="Delete"
onPress={() => onDelete(event)}
destructive
@@ -280,6 +364,8 @@ function DetailRow({
text: string;
multiline?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.detailRow}>
<View style={styles.detailIcon}>{icon}</View>
@@ -301,6 +387,8 @@ function ActionButton({
onPress: () => void;
destructive?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
@@ -317,16 +405,49 @@ function ActionButton({
);
}
function statusColor(status?: string): string {
function RsvpButton({
label,
icon,
active,
activeColor,
disabled,
onPress,
}: {
label: string;
icon: React.ReactNode;
active: boolean;
activeColor: string;
disabled?: boolean;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={[
styles.rsvpBtn,
active && { backgroundColor: activeColor, borderColor: activeColor },
disabled && { opacity: 0.5 },
]}
>
{icon}
<Text style={[styles.rsvpBtnText, active && { color: c.textInverse }]}>{label}</Text>
</Pressable>
);
}
function statusColor(c: ThemePalette, status?: string): string {
switch (status) {
case 'accepted':
return colors.success;
return c.success;
case 'declined':
return colors.error;
return c.error;
case 'tentative':
return colors.warning;
return c.warning;
default:
return colors.textMuted;
return c.textMuted;
}
}
@@ -345,7 +466,8 @@ function statusLabel(status?: string): string {
}
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -357,20 +479,23 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
maxHeight: '85%',
backgroundColor: colors.background,
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm },
handleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.surfaceActive,
marginBottom: spacing.sm,
backgroundColor: c.surfaceActive,
},
header: {
@@ -382,8 +507,8 @@ const styles = StyleSheet.create({
},
colorBar: { width: 4, alignSelf: 'stretch', borderRadius: 2 },
headerText: { flex: 1 },
title: { ...typography.h3, color: colors.text },
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
title: { ...typography.h3, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
closeBtn: { padding: 4 },
body: { maxHeight: 400 },
@@ -394,7 +519,34 @@ const styles = StyleSheet.create({
},
detailRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'flex-start' },
detailIcon: { width: 16, paddingTop: 2 },
detailText: { flex: 1, ...typography.body, color: colors.text },
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,
@@ -406,7 +558,7 @@ const styles = StyleSheet.create({
gap: spacing.md,
marginBottom: spacing.xs,
},
participantsHeaderText: { ...typography.bodyMedium, color: colors.text },
participantsHeaderText: { ...typography.bodyMedium, color: c.text },
participantRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -414,13 +566,13 @@ const styles = StyleSheet.create({
paddingVertical: 4,
},
participantDot: { width: 8, height: 8, borderRadius: 4 },
participantName: { flex: 1, ...typography.body, color: colors.text },
participantStatus: { ...typography.caption, color: colors.textMuted },
participantName: { flex: 1, ...typography.body, color: c.text },
participantStatus: { ...typography.caption, color: c.textMuted },
actions: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: colors.border,
borderTopColor: c.border,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
},
@@ -432,7 +584,8 @@ const styles = StyleSheet.create({
gap: 4,
borderRadius: radius.sm,
},
actionBtnPressed: { backgroundColor: colors.surfaceHover },
actionLabel: { ...typography.caption, color: colors.text },
actionLabelDestructive: { color: colors.error },
});
actionBtnPressed: { backgroundColor: c.surfaceHover },
actionLabel: { ...typography.caption, color: c.text },
actionLabelDestructive: { color: c.error },
});
}
+270 -128
View File
@@ -17,14 +17,25 @@ 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 { colors, radius, spacing, typography } from '../../theme/tokens';
import type {
Calendar,
CalendarEvent,
Participant,
EventLocation,
RecurrenceRule,
VirtualLocation,
} from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildAllDayDuration,
getCalendarColor,
@@ -32,11 +43,19 @@ import {
getEventStartDate,
getPrimaryCalendarId,
} from '../../lib/calendar-utils';
import {
alertsToReminders,
remindersToAlerts,
formatReminder,
REMINDER_PRESETS,
type Reminder,
} from '../../lib/calendar-alerts';
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
import { Button } from '..';
import { ParticipantInput } from './ParticipantInput';
import { RecurrenceEditor } from './RecurrenceEditor';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
type AlertOption = 'none' | 'at_time' | '5' | '15' | '30' | '60' | '1440';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'none', label: 'Does not repeat' },
@@ -44,16 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
];
const ALERT_OPTIONS: { value: AlertOption; label: string }[] = [
{ value: 'none', label: 'No reminder' },
{ value: 'at_time', label: 'At time of event' },
{ value: '5', label: '5 minutes before' },
{ value: '15', label: '15 minutes before' },
{ value: '30', label: '30 minutes before' },
{ value: '60', label: '1 hour before' },
{ value: '1440', label: '1 day before' },
{ value: 'custom', label: 'Custom…' },
];
interface EventModalProps {
@@ -93,46 +103,41 @@ function buildDuration(start: Date, end: Date): string {
function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption {
const rule = event?.recurrenceRules?.[0];
if (!rule) return 'none';
const f = rule.frequency.toLowerCase();
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
return f as RecurrenceOption;
if (isSimpleRecurrenceRule(rule)) {
const f = rule.frequency.toLowerCase();
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
return f as RecurrenceOption;
}
}
return 'none';
// Anything the simple presets can't represent is edited via the custom
// recurrence editor.
return 'custom';
}
function detectAlert(event: CalendarEvent | null | undefined): AlertOption {
const alerts = event?.alerts;
if (!alerts) return 'none';
const first = Object.values(alerts)[0];
if (!first) return 'none';
const offset = first.trigger?.offset;
if (!offset) return 'none';
if (offset === 'PT0S' || offset === '-PT0S') return 'at_time';
const min = offset.match(/-?PT(\d+)M$/);
if (min) {
const v = min[1];
if (v === '5' || v === '15' || v === '30') return v as AlertOption;
}
const hr = offset.match(/-?PT(\d+)H$/);
if (hr && hr[1] === '1') return '60';
const day = offset.match(/-?P(\d+)D/);
if (day && day[1] === '1') return '1440';
return 'none';
function detectLocation(event: CalendarEvent | null | undefined): string {
const locations = event?.locations;
if (!locations) return '';
const first = Object.values(locations)[0];
return first?.name || '';
}
function alertFromOption(opt: AlertOption): CalendarEvent['alerts'] | undefined {
if (opt === 'none') return undefined;
let offset = '-PT0M';
if (opt === 'at_time') offset = 'PT0S';
else if (opt === '60') offset = '-PT1H';
else if (opt === '1440') offset = '-P1D';
else offset = `-PT${opt}M`;
return {
'a-1': {
trigger: { '@type': 'OffsetTrigger', offset },
action: 'display',
},
};
function detectVideoUrl(event: CalendarEvent | null | undefined): string {
const v = event?.virtualLocations;
if (!v) return '';
const first = Object.values(v)[0];
return first?.uri || '';
}
function buildLocations(name: string): Record<string, EventLocation> | undefined {
const trimmed = name.trim();
if (!trimmed) return undefined;
return { 'loc-1': { '@type': 'Location', name: trimmed } };
}
function buildVirtualLocations(uri: string): Record<string, VirtualLocation> | undefined {
const trimmed = uri.trim();
if (!trimmed) return undefined;
return { 'vloc-1': { '@type': 'VirtualLocation', name: 'Video call', uri: trimmed } };
}
function recurrenceFromOption(opt: RecurrenceOption): CalendarEvent['recurrenceRules'] | undefined {
@@ -150,6 +155,8 @@ export function EventModal({
onDelete,
onClose,
}: EventModalProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isEdit = !!event;
const [title, setTitle] = React.useState('');
@@ -157,12 +164,23 @@ export function EventModal({
const [allDay, setAllDay] = React.useState(false);
const [start, setStart] = React.useState<Date>(() => nextHalfHour(defaultDate ?? new Date()));
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
// New events land in the explicitly requested calendar, else the account's
// default calendar, else the first one.
const fallbackCalendarId =
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '';
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
const [alert, setAlert] = React.useState<AlertOption>('none');
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
const [reminders, setReminders] = React.useState<Reminder[]>([]);
const [location, setLocation] = React.useState('');
const [videoUrl, setVideoUrl] = React.useState('');
const [recurrenceOpen, setRecurrenceOpen] = React.useState(false);
const [alertOpen, setAlertOpen] = React.useState(false);
const [reminderPickerOpen, setReminderPickerOpen] = React.useState(false);
const [calendarOpen, setCalendarOpen] = React.useState(false);
const [showStartDate, setShowStartDate] = React.useState(false);
const [showStartTime, setShowStartTime] = React.useState(false);
@@ -180,8 +198,13 @@ export function EventModal({
setEnd(getEventEndDate(event));
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
setParticipants(event.participants || {});
setRecurrence(detectRecurrence(event));
setAlert(detectAlert(event));
const detected = detectRecurrence(event);
setRecurrence(detected);
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
setRecurrenceEditorOpen(false);
setReminders(alertsToReminders(event.alerts));
setLocation(detectLocation(event));
setVideoUrl(detectVideoUrl(event));
} else {
const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date());
setTitle('');
@@ -189,10 +212,19 @@ export function EventModal({
setAllDay(false);
setStart(d);
setEnd(addHours(d, 1));
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
setCalendarId(
defaultCalendarId
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|| calendars[0]?.id
|| '',
);
setParticipants({});
setRecurrence('none');
setAlert('none');
setCustomRule(null);
setRecurrenceEditorOpen(false);
setReminders([]);
setLocation('');
setVideoUrl('');
}
}, [visible, event, defaultDate, defaultCalendarId, calendars]);
@@ -236,8 +268,16 @@ export function EventModal({
: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
participants: Object.keys(participants).length > 0 ? participants : undefined,
recurrenceRules: recurrenceFromOption(recurrence),
alerts: alertFromOption(alert),
recurrenceRules:
recurrence === 'custom'
? customRule
? [customRule]
: undefined
: recurrenceFromOption(recurrence),
alerts: remindersToAlerts(reminders),
useDefaultAlerts: reminders.length > 0 ? false : undefined,
locations: buildLocations(location),
virtualLocations: buildVirtualLocations(videoUrl),
};
await onSave(data, calendarId);
onClose();
@@ -251,8 +291,25 @@ export function EventModal({
};
const selectedCalendar = calendars.find((c) => c.id === calendarId);
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const alertLabel = ALERT_OPTIONS.find((o) => o.value === alert)?.label || 'None';
const recurrenceLabel =
recurrence === 'custom'
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
: RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
const addReminder = (minutesBefore: number) => {
setReminderPickerOpen(false);
setReminders((prev) =>
prev.some((r) => r.minutesBefore === minutesBefore)
? prev
: [...prev, { minutesBefore }].sort((a, b) => b.minutesBefore - a.minutesBefore),
);
};
const removeReminder = (minutesBefore: number) => {
setReminders((prev) => prev.filter((r) => r.minutesBefore !== minutesBefore));
};
const availablePresets = REMINDER_PRESETS.filter(
(p) => !reminders.some((r) => r.minutesBefore === p),
);
return (
<Modal
@@ -264,7 +321,7 @@ export function EventModal({
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>{isEdit ? 'Edit event' : 'New event'}</Text>
<Button
@@ -287,12 +344,12 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder="Title"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
style={styles.titleInput}
/>
<Section
icon={<CalendarIcon size={16} color={colors.textMuted} />}
icon={<CalendarIcon size={16} color={c.textMuted} />}
label="Calendar"
>
<Pressable
@@ -340,14 +397,14 @@ export function EventModal({
)}
</Section>
<Section icon={<Clock size={16} color={colors.textMuted} />} label="Time">
<Section icon={<Clock size={16} color={c.textMuted} />} label="Time">
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>All-day</Text>
<Switch
value={allDay}
onValueChange={setAllDay}
thumbColor={allDay ? colors.primary : colors.textMuted}
trackColor={{ false: colors.surface, true: colors.primaryBg }}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -392,7 +449,7 @@ export function EventModal({
</View>
</Section>
<Section icon={<Repeat size={16} color={colors.textMuted} />} label="Repeat">
<Section icon={<Repeat size={16} color={c.textMuted} />} label="Repeat">
<Pressable
style={styles.fieldButton}
onPress={() => setRecurrenceOpen((v) => !v)}
@@ -409,8 +466,14 @@ export function EventModal({
opt.value === recurrence && styles.popoverRowActive,
]}
onPress={() => {
setRecurrence(opt.value);
setRecurrenceOpen(false);
if (opt.value === 'custom') {
setRecurrenceEditorOpen(true);
return;
}
setRecurrence(opt.value);
setCustomRule(null);
setRecurrenceEditorOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
@@ -418,52 +481,107 @@ export function EventModal({
))}
</View>
)}
{recurrenceEditorOpen && (
<RecurrenceEditor
rule={customRule}
eventStart={start}
onSave={(rule) => {
setCustomRule(rule);
setRecurrence('custom');
setRecurrenceEditorOpen(false);
}}
onCancel={() => setRecurrenceEditorOpen(false)}
/>
)}
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
<Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}>
<Text style={styles.addRowText}>Edit custom recurrence</Text>
</Pressable>
)}
</Section>
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
{reminders.length > 0 && (
<View style={{ gap: spacing.xs }}>
{reminders.map((r) => (
<View key={r.minutesBefore} style={styles.reminderRow}>
<Text style={styles.fieldText}>{formatReminder(r.minutesBefore)}</Text>
<Pressable
onPress={() => removeReminder(r.minutesBefore)}
hitSlop={8}
style={styles.reminderRemove}
>
<X size={16} color={c.textMuted} />
</Pressable>
</View>
))}
</View>
)}
{availablePresets.length > 0 && (
<Pressable
style={styles.addRow}
onPress={() => setReminderPickerOpen((v) => !v)}
>
<Plus size={16} color={c.primary} />
<Text style={styles.addRowText}>
{reminders.length === 0 ? 'Add reminder' : 'Add another reminder'}
</Text>
</Pressable>
)}
{reminderPickerOpen && (
<View style={styles.popover}>
{availablePresets.map((preset) => (
<Pressable
key={preset}
style={styles.popoverRow}
onPress={() => addReminder(preset)}
>
<Text style={styles.popoverRowText}>{formatReminder(preset)}</Text>
</Pressable>
))}
</View>
)}
</Section>
<Section icon={<Bell size={16} color={colors.textMuted} />} label="Reminder">
<Pressable
<Section icon={<MapPin size={16} color={c.textMuted} />} label="Location">
<TextInput
value={location}
onChangeText={setLocation}
placeholder="Place or address"
placeholderTextColor={c.textMuted}
style={styles.fieldButton}
onPress={() => setAlertOpen((v) => !v)}
>
<Text style={styles.fieldText}>{alertLabel}</Text>
</Pressable>
{alertOpen && (
<View style={styles.popover}>
{ALERT_OPTIONS.map((opt) => (
<Pressable
key={opt.value}
style={[
styles.popoverRow,
opt.value === alert && styles.popoverRowActive,
]}
onPress={() => {
setAlert(opt.value);
setAlertOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
</Pressable>
))}
</View>
)}
/>
</Section>
<Section icon={<Video size={16} color={c.textMuted} />} label="Video call">
<TextInput
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.fieldButton}
/>
</Section>
<Section
icon={<MapPin size={16} color={colors.textMuted} />}
icon={<AlignLeft size={16} color={c.textMuted} />}
label="Description"
>
<TextInput
value={description}
onChangeText={setDescription}
placeholder="Notes, location, link…"
placeholderTextColor={colors.textMuted}
placeholder="Notes, agenda…"
placeholderTextColor={c.textMuted}
multiline
style={styles.descriptionInput}
/>
</Section>
<Section
icon={<Users size={16} color={colors.textMuted} />}
icon={<Users size={16} color={c.textMuted} />}
label="Participants"
>
<ParticipantInput
@@ -480,7 +598,7 @@ export function EventModal({
]}
onPress={handleDelete}
>
<Trash2 size={16} color={colors.error} />
<Trash2 size={16} color={c.error} />
<Text style={styles.deleteBtnText}>Delete event</Text>
</Pressable>
)}
@@ -560,6 +678,8 @@ function Section({
label: string;
children: React.ReactNode;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
@@ -571,15 +691,16 @@ function Section({
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
gap: spacing.md,
},
headerBtn: {
@@ -589,23 +710,23 @@ const styles = StyleSheet.create({
justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { flex: 1, ...typography.h3, color: colors.text },
headerTitle: { flex: 1, ...typography.h3, color: c.text },
body: { flex: 1 },
bodyContent: { padding: spacing.lg, gap: spacing.lg, paddingBottom: spacing.xxxl },
titleInput: {
...typography.h2,
color: colors.text,
color: c.text,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingVertical: spacing.sm,
},
section: { gap: spacing.sm },
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
sectionIcon: { width: 16 },
sectionLabel: { ...typography.bodyMedium, color: colors.textSecondary },
sectionLabel: { ...typography.bodyMedium, color: c.textSecondary },
sectionBody: { gap: spacing.sm },
fieldButton: {
@@ -614,20 +735,40 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
borderColor: c.border,
backgroundColor: c.surface,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: colors.text },
fieldText: { ...typography.body, color: c.text },
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: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
overflow: 'hidden',
},
popoverRow: {
@@ -637,10 +778,10 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
popoverRowActive: { backgroundColor: colors.primaryBg },
popoverRowText: { ...typography.body, color: colors.text },
popoverRowActive: { backgroundColor: c.primaryBg },
popoverRowText: { ...typography.body, color: c.text },
allDayRow: {
flexDirection: 'row',
@@ -649,9 +790,9 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
dateRow: {
flexDirection: 'row',
@@ -660,20 +801,20 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
borderColor: c.border,
backgroundColor: c.surface,
},
dateLabel: { ...typography.body, color: colors.textSecondary, flex: 1 },
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
dateBtn: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.xs,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
},
dateText: { ...typography.body, color: colors.text },
dateText: { ...typography.body, color: c.text },
descriptionInput: {
minHeight: 80,
@@ -681,9 +822,9 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
color: colors.text,
borderColor: c.border,
backgroundColor: c.surface,
color: c.text,
...typography.body,
textAlignVertical: 'top',
},
@@ -696,9 +837,10 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.errorBorder,
borderColor: c.errorBorder,
marginTop: spacing.lg,
},
deleteBtnPressed: { backgroundColor: colors.errorBg },
deleteBtnText: { ...typography.bodyMedium, color: colors.error },
});
deleteBtnPressed: { backgroundColor: c.errorBg },
deleteBtnText: { ...typography.bodyMedium, color: c.error },
});
}
+391
View File
@@ -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 },
});
}
+113 -65
View File
@@ -5,6 +5,7 @@ import {
endOfMonth,
endOfWeek,
format,
getWeek,
isSameDay,
isSameMonth,
isToday,
@@ -12,7 +13,8 @@ import {
startOfWeek,
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, componentSizes, radius, spacing, typography } from '../../theme/tokens';
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
eventsOnDayFromIndex,
@@ -20,7 +22,8 @@ import {
type EventDayIndex,
} from '../../lib/calendar-utils';
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const SUN_FIRST = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const MON_FIRST = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
interface MonthViewProps {
currentDate: Date;
@@ -28,19 +31,26 @@ interface MonthViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
weekStartsOn?: 0 | 1;
showWeekNumbers?: boolean;
onSelectDate: (date: Date) => void;
onLongPressDate?: (date: Date) => void;
}
export function MonthView({
function MonthViewInner({
currentDate,
selectedDate,
events,
calendars,
eventsByDay,
weekStartsOn = 0,
showWeekNumbers = false,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const weekdayLabels = weekStartsOn === 1 ? MON_FIRST : SUN_FIRST;
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
@@ -48,8 +58,8 @@ export function MonthView({
const days = React.useMemo(() => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calStart = startOfWeek(monthStart);
const calEnd = endOfWeek(monthEnd);
const calStart = startOfWeek(monthStart, { weekStartsOn });
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
const result: Date[] = [];
let day = calStart;
while (day <= calEnd) {
@@ -57,78 +67,115 @@ export function MonthView({
day = addDays(day, 1);
}
return result;
}, [currentDate]);
}, [currentDate, weekStartsOn]);
// Group days into rows of 7 so we can prepend a week-number column.
const rows = React.useMemo(() => {
const out: Date[][] = [];
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
return out;
}, [days]);
const renderDay = (d: Date, key: React.Key, flexLayout = false) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS);
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
return (
<Pressable
key={key}
style={[styles.dayCell, flexLayout && styles.dayCellFlex]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.dotsRow}>
{visibleDots.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
};
return (
<View style={styles.grid}>
<View style={styles.weekdayRow}>
{WEEKDAYS.map((wd, i) => (
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
{weekdayLabels.map((wd, i) => (
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
<View style={styles.daysGrid}>
{days.map((d, i) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS);
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
return (
<Pressable
key={i}
style={styles.dayCell}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.dotsRow}>
{visibleDots.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
})}
</View>
{showWeekNumbers ? (
rows.map((row, rowIdx) => (
<View key={rowIdx} style={styles.weekRow}>
<Text style={styles.weekNumberCell}>{getWeek(row[0], { weekStartsOn })}</Text>
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
</View>
))
) : (
<View style={styles.daysGrid}>
{days.map((d, i) => renderDay(d, i))}
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
export const MonthView = React.memo(MonthViewInner);
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
grid: { paddingHorizontal: spacing.sm },
weekdayRow: { flexDirection: 'row', paddingHorizontal: spacing.xs, marginBottom: 4 },
weekdayLabel: {
flex: 1,
textAlign: 'center',
...typography.small,
color: colors.textMuted,
color: c.textMuted,
},
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
weekRow: { flexDirection: 'row', alignItems: 'center' },
weekNumberLabel: {
width: 24,
textAlign: 'center',
...typography.small,
color: c.textMuted,
},
weekNumberCell: {
width: 24,
textAlign: 'center',
...typography.caption,
color: c.textMuted,
paddingVertical: 4,
},
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
dayCellFlex: { width: undefined, flex: 1 },
dayNumber: {
width: 36,
height: 36,
@@ -136,12 +183,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
dayText: { ...typography.body, color: colors.text },
dayTextMuted: { color: colors.textMuted },
todayCircle: { backgroundColor: colors.primary },
todayText: { color: colors.textInverse, fontWeight: '700' },
selectedCircle: { backgroundColor: colors.primaryBg, borderWidth: 1.5, borderColor: colors.primary },
selectedText: { color: colors.primary, fontWeight: '600' },
dayText: { ...typography.body, color: c.text },
dayTextMuted: { color: c.textMuted },
todayCircle: { backgroundColor: c.primary },
todayText: { color: c.textInverse, fontWeight: '700' },
selectedCircle: { backgroundColor: c.primaryBg, borderWidth: 1.5, borderColor: c.primary },
selectedText: { color: c.primary, fontWeight: '600' },
dotsRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -158,9 +205,10 @@ const styles = StyleSheet.create({
borderRadius: componentSizes.eventDot / 2,
},
overflowText: {
color: colors.textMuted,
color: c.textMuted,
fontSize: 9,
lineHeight: 10,
marginLeft: 1,
},
});
});
}
+21 -16
View File
@@ -8,7 +8,8 @@ import {
ScrollView,
} from 'react-native';
import { X } from 'lucide-react-native';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useContactsStore } from '../../stores/contacts-store';
import type { Participant } from '../../api/types';
@@ -44,6 +45,8 @@ function flattenContactEmails(): Suggestion[] {
}
export function ParticipantInput({ participants, onChange }: ParticipantInputProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [draft, setDraft] = React.useState('');
const [allSuggestions] = React.useState(() => flattenContactEmails());
@@ -110,7 +113,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
{p.name || p.email || 'Unknown'}
</Text>
<Pressable onPress={() => removeParticipant(id)} hitSlop={6}>
<X size={12} color={colors.textMuted} />
<X size={12} color={c.textMuted} />
</Pressable>
</View>
))}
@@ -121,7 +124,7 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
value={draft}
onChangeText={setDraft}
placeholder="Add participant by email"
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
@@ -155,45 +158,47 @@ export function ParticipantInput({ participants, onChange }: ParticipantInputPro
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.sm },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
backgroundColor: c.surface,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.full,
paddingHorizontal: spacing.md,
paddingVertical: 4,
maxWidth: '100%',
},
chipText: { ...typography.caption, color: colors.text, maxWidth: 200 },
chipText: { ...typography.caption, color: c.text, maxWidth: 200 },
input: {
height: 40,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
color: colors.text,
color: c.text,
...typography.body,
},
suggestions: {
maxHeight: 180,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
suggestionRow: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
suggestionRowPressed: { backgroundColor: colors.surfaceHover },
suggestionName: { ...typography.bodyMedium, color: colors.text },
suggestionEmail: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
});
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
});
}
@@ -0,0 +1,370 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { ChevronDown } from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { RecurrenceRule } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '..';
import {
EDITOR_FREQUENCIES,
UNIT_LABELS,
WEEKDAYS,
buildRecurrenceSummary,
buildRuleFromEditorValue,
capitalize,
editorValueFromRule,
monthName,
nthLabel,
weekdayName,
type EditorFrequency,
type EndsMode,
type MonthlyMode,
type RecurrenceEditorValue,
} from '../../lib/recurrence';
interface RecurrenceEditorProps {
rule: RecurrenceRule | null;
eventStart: Date;
onSave: (rule: RecurrenceRule) => void;
onCancel: () => void;
}
// Small popover-style select, matching the EventModal field pattern.
function InlineSelect<T extends string | number>({
value,
options,
onChange,
flex,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
flex?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [open, setOpen] = React.useState(false);
const selected = options.find((o) => o.value === value);
return (
<View style={flex ? styles.selectFlex : undefined}>
<Pressable style={styles.selectButton} onPress={() => setOpen((v) => !v)}>
<Text style={styles.selectText} numberOfLines={1}>
{selected?.label ?? String(value)}
</Text>
<ChevronDown size={14} color={c.textMuted} />
</Pressable>
{open && (
<View style={styles.popover}>
{options.map((opt) => (
<Pressable
key={String(opt.value)}
style={[styles.popoverRow, opt.value === value && styles.popoverRowActive]}
onPress={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Text style={styles.popoverRowText}>{opt.label}</Text>
</Pressable>
))}
</View>
)}
</View>
);
}
function NumberInput({
value,
min,
max,
onChange,
}: {
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [text, setText] = React.useState(String(value));
React.useEffect(() => setText(String(value)), [value]);
return (
<TextInput
value={text}
onChangeText={setText}
onEndEditing={() => {
const n = parseInt(text, 10);
const clamped = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
setText(String(clamped));
onChange(clamped);
}}
keyboardType="number-pad"
style={styles.numberInput}
/>
);
}
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
editorValueFromRule(rule, eventStart),
);
const [showUntilPicker, setShowUntilPicker] = React.useState(false);
const update = (patch: Partial<RecurrenceEditorValue>) =>
setValue((prev) => ({ ...prev, ...patch }));
const toggleWeekDay = (day: string) => {
setValue((prev) => ({
...prev,
weekDays: prev.weekDays.includes(day)
? prev.weekDays.length > 1
? prev.weekDays.filter((d) => d !== day)
: prev.weekDays
: [...prev.weekDays, day],
}));
};
const builtRule = buildRuleFromEditorValue(value, eventStart);
const summary = buildRecurrenceSummary(builtRule);
return (
<View style={styles.container}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Every</Text>
<NumberInput
value={value.interval}
min={1}
max={999}
onChange={(interval) => update({ interval })}
/>
<InlineSelect<EditorFrequency>
flex
value={value.frequency}
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))}
onChange={(frequency) => update({ frequency })}
/>
</View>
{value.frequency === 'weekly' && (
<View style={styles.weekdayRow}>
{WEEKDAYS.map((day) => {
const active = value.weekDays.includes(day);
return (
<Pressable
key={day}
onPress={() => toggleWeekDay(day)}
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
>
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
{weekdayName(day, 'short')}
</Text>
</Pressable>
);
})}
</View>
)}
{value.frequency === 'yearly' && (
<View style={styles.row}>
<Text style={styles.rowLabel}>In</Text>
<InlineSelect<number>
flex
value={value.month}
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
value: m,
label: capitalize(monthName(m)),
}))}
onChange={(month) => update({ month })}
/>
</View>
)}
{(value.frequency === 'monthly' || value.frequency === 'yearly') && (
<View style={styles.row}>
<InlineSelect<MonthlyMode>
value={value.monthlyMode}
options={[
{ value: 'day', label: 'On day' },
{ value: 'nth', label: 'On the' },
]}
onChange={(monthlyMode) => update({ monthlyMode })}
/>
{value.monthlyMode === 'day' ? (
<NumberInput
value={value.monthDay}
min={1}
max={31}
onChange={(monthDay) => update({ monthDay })}
/>
) : (
<>
<InlineSelect<number>
flex
value={value.nth}
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))}
onChange={(nth) => update({ nth })}
/>
<InlineSelect<string>
flex
value={value.nthDay}
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))}
onChange={(nthDay) => update({ nthDay })}
/>
</>
)}
</View>
)}
<View style={styles.row}>
<Text style={styles.rowLabel}>Ends</Text>
<InlineSelect<EndsMode>
flex={value.endsMode === 'never'}
value={value.endsMode}
options={[
{ value: 'never', label: 'Never' },
{ value: 'on', label: 'On date' },
{ value: 'after', label: 'After' },
]}
onChange={(endsMode) => update({ endsMode })}
/>
{value.endsMode === 'on' && (
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
<Text style={styles.selectText}>
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
</Text>
</Pressable>
)}
{value.endsMode === 'after' && (
<>
<NumberInput
value={value.count}
min={1}
max={999}
onChange={(count) => update({ count })}
/>
<Text style={styles.muted}>occurrences</Text>
</>
)}
</View>
<View style={styles.footer}>
<Text style={styles.summary} numberOfLines={2}>
{summary}
</Text>
<View style={styles.footerButtons}>
<Button variant="outline" size="sm" onPress={onCancel}>
Cancel
</Button>
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
Done
</Button>
</View>
</View>
{showUntilPicker && (
<DateTimePicker
value={parseISO(value.untilDate)}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowUntilPicker(false);
if (d) update({ untilDate: format(d, 'yyyy-MM-dd') });
}}
/>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
padding: spacing.md,
gap: spacing.sm,
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
rowLabel: { ...typography.body, color: c.textSecondary },
muted: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
numberInput: {
minWidth: 52,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlign: 'center',
},
selectFlex: { flex: 1, minWidth: 0 },
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 36,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
selectText: { ...typography.body, color: c.text, flexShrink: 1 },
popover: {
marginTop: spacing.xs,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.xs,
backgroundColor: c.background,
overflow: 'hidden',
},
popoverRow: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
popoverRowActive: { backgroundColor: c.primaryBg },
popoverRowText: { ...typography.body, color: c.text },
weekdayRow: { flexDirection: 'row', gap: 4 },
weekdayChip: {
flex: 1,
minWidth: 0,
paddingVertical: spacing.xs,
borderRadius: radius.xs,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
},
weekdayChipActive: {
borderColor: c.primary,
backgroundColor: c.primaryBg,
},
weekdayChipText: { ...typography.caption, color: c.textMuted },
weekdayChipTextActive: { color: c.primary },
footer: {
borderTopWidth: 1,
borderTopColor: c.border,
paddingTop: spacing.sm,
gap: spacing.sm,
},
summary: { ...typography.caption, color: c.textMuted },
footerButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
});
}
@@ -1,7 +1,8 @@
import React from 'react';
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
import { Repeat, Trash2 } from 'lucide-react-native';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button } from '..';
export type RecurrenceEditScope = 'this' | 'this_and_future' | 'all';
@@ -25,6 +26,8 @@ export function RecurrenceScopeDialog({
onSelect,
onClose,
}: RecurrenceScopeDialogProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [selected, setSelected] = React.useState<RecurrenceEditScope>('this');
const isDelete = actionType === 'delete';
@@ -39,9 +42,9 @@ export function RecurrenceScopeDialog({
<View style={styles.header}>
<View style={[styles.iconBadge, isDelete ? styles.iconBadgeDanger : styles.iconBadgePrimary]}>
{isDelete ? (
<Trash2 size={20} color={colors.error} />
<Trash2 size={20} color={c.error} />
) : (
<Repeat size={20} color={colors.primary} />
<Repeat size={20} color={c.primary} />
)}
</View>
<View style={styles.headerText}>
@@ -90,7 +93,8 @@ export function RecurrenceScopeDialog({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
@@ -101,9 +105,9 @@ const styles = StyleSheet.create({
dialog: {
width: '100%',
maxWidth: 400,
backgroundColor: colors.background,
backgroundColor: c.background,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.lg,
padding: spacing.xxl,
gap: spacing.lg,
@@ -116,11 +120,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
iconBadgeDanger: { backgroundColor: colors.errorBg },
iconBadgePrimary: { backgroundColor: colors.primaryBg },
iconBadgeDanger: { backgroundColor: c.errorBg },
iconBadgePrimary: { backgroundColor: c.primaryBg },
headerText: { flex: 1 },
title: { ...typography.h3, color: colors.text },
description: { ...typography.body, color: colors.textMuted, marginTop: 4 },
title: { ...typography.h3, color: c.text },
description: { ...typography.body, color: c.textMuted, marginTop: 4 },
options: { gap: spacing.xs },
option: {
flexDirection: 'row',
@@ -132,27 +136,28 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'transparent',
},
optionActive: { backgroundColor: colors.primaryBg, borderColor: colors.primary },
optionActive: { backgroundColor: c.primaryBg, borderColor: c.primary },
radio: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 1.5,
borderColor: colors.border,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
radioActive: { borderColor: colors.primary },
radioActive: { borderColor: c.primary },
radioDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.primary,
backgroundColor: c.primary,
},
optionLabel: { ...typography.body, color: colors.text, flex: 1 },
optionLabel: { ...typography.body, color: c.text, flex: 1 },
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
},
});
});
}
+371
View File
@@ -0,0 +1,371 @@
import React from 'react';
import {
Animated,
Dimensions,
Easing,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker from '@react-native-community/datetimepicker';
import {
Calendar as CalendarIcon,
Check,
ListChecks,
Plus,
Trash2,
X,
} from 'lucide-react-native';
import { format, parseISO } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { getCalendarColor } from '../../lib/calendar-utils';
import { useSheetDrag } from '../../lib/use-sheet-drag';
interface TasksSheetProps {
visible: boolean;
tasks: CalendarEvent[];
calendars: Calendar[];
onClose: () => void;
onCreate: (task: Partial<CalendarEvent>, calendarId: string) => Promise<void> | void;
onToggle: (id: string) => Promise<void> | void;
onDelete: (id: string) => Promise<void> | void;
}
function isCompleted(task: CalendarEvent): boolean {
return task.progress === 'completed';
}
function dueLabel(task: CalendarEvent): string | null {
if (!task.due) return null;
const d = parseISO(task.due);
if (isNaN(d.getTime())) return null;
return format(d, 'EEE, MMM d');
}
// Tasks first by completion (open first), then by due date (soonest first,
// undated last), then title.
function compareTasks(a: CalendarEvent, b: CalendarEvent): number {
const ac = isCompleted(a);
const bc = isCompleted(b);
if (ac !== bc) return ac ? 1 : -1;
const ad = a.due ? parseISO(a.due).getTime() : Infinity;
const bd = b.due ? parseISO(b.due).getTime() : Infinity;
if (ad !== bd) return ad - bd;
return (a.title || '').localeCompare(b.title || '');
}
export function TasksSheet({
visible,
tasks,
calendars,
onClose,
onCreate,
onToggle,
onDelete,
}: TasksSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
const writableCalendars = React.useMemo(
() => calendars.filter((cal) => !cal.myRights || cal.myRights.mayWrite !== false),
[calendars],
);
const [title, setTitle] = React.useState('');
const [calendarId, setCalendarId] = React.useState('');
const [due, setDue] = React.useState<Date | null>(null);
const [showDuePicker, setShowDuePicker] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (visible && !calendarId && writableCalendars[0]) {
setCalendarId(writableCalendars[0].id);
}
}, [visible, calendarId, writableCalendars]);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: Dimensions.get('window').height, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]);
const handleAdd = async () => {
const t = title.trim();
if (!t || !calendarId || saving) return;
setSaving(true);
try {
const task: Partial<CalendarEvent> = {
title: t,
progress: 'needs-action',
};
if (due) {
task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss");
}
await onCreate(task, calendarId);
setTitle('');
setDue(null);
} finally {
setSaving(false);
}
};
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView edges={['bottom']} style={styles.sheetSafe}>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<ListChecks size={20} color={c.text} />
<Text style={styles.headerTitle}>Tasks</Text>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
</View>
</View>
<View style={styles.addRow}>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Add a task…"
placeholderTextColor={c.textMuted}
style={styles.addInput}
returnKeyType="done"
onSubmitEditing={() => { void handleAdd(); }}
/>
<Pressable
onPress={() => setShowDuePicker(true)}
style={[styles.dueChip, due && styles.dueChipActive]}
hitSlop={6}
>
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
</Pressable>
<Pressable
onPress={() => { void handleAdd(); }}
disabled={!title.trim() || !calendarId || saving}
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
>
<Plus size={18} color={c.primaryForeground} />
</Pressable>
</View>
{writableCalendars.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.calChips}
>
{writableCalendars.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{sorted.length === 0 ? (
<View style={styles.empty}>
<ListChecks size={32} color={c.surfaceActive} />
<Text style={styles.emptyText}>No tasks yet</Text>
</View>
) : (
sorted.map((task) => {
const completed = isCompleted(task);
const due = dueLabel(task);
return (
<View key={task.id} style={styles.taskRow}>
<Pressable
onPress={() => { void onToggle(task.id); }}
style={[styles.checkbox, completed && styles.checkboxChecked]}
hitSlop={8}
>
{completed && <Check size={14} color={c.primaryForeground} />}
</Pressable>
<View style={styles.taskText}>
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
{task.title || 'Untitled task'}
</Text>
{due && <Text style={styles.taskDue}>{due}</Text>}
</View>
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
<Trash2 size={16} color={c.textMuted} />
</Pressable>
</View>
);
})
)}
</ScrollView>
</SafeAreaView>
</Animated.View>
{showDuePicker && (
<DateTimePicker
value={due ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
setShowDuePicker(false);
if (d) setDue(d);
}}
/>
)}
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '85%',
backgroundColor: c.background,
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
borderTopWidth: 1,
borderTopColor: c.border,
},
sheetSafe: { paddingTop: spacing.sm, flexShrink: 1 },
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.surfaceActive },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
headerTitle: { flex: 1, ...typography.h3, color: c.text },
closeBtn: { padding: 4 },
addRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
},
addInput: {
flex: 1,
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
},
dueChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
height: 38,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
},
dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
dueChipText: { ...typography.caption, color: c.primary },
addBtn: {
width: 38,
height: 38,
borderRadius: radius.sm,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
addBtnDisabled: { opacity: 0.5 },
calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm },
calChip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
borderWidth: 1,
borderColor: c.border,
maxWidth: 160,
},
calChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg },
calSwatch: { width: 10, height: 10, borderRadius: 5 },
calChipText: { ...typography.caption, color: c.text, flexShrink: 1 },
list: { flexGrow: 0 },
listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg },
empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm },
emptyText: { ...typography.body, color: c.textMuted },
taskRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
checkbox: {
width: 22,
height: 22,
borderRadius: radius.xs,
borderWidth: 2,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary },
taskText: { flex: 1 },
taskTitle: { ...typography.body, color: c.text },
taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted },
taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 },
taskDelete: { padding: 4 },
});
}
+38 -27
View File
@@ -8,7 +8,8 @@ import {
} from 'react-native';
import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
buildTimedFullDayWeekSegments,
@@ -37,9 +38,10 @@ interface WeekViewProps {
onSelectEvent?: (event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void;
weekStartsOn?: 0 | 1;
timeFormat?: '12h' | '24h';
}
export function WeekView({
function WeekViewInner({
selectedDate,
events,
calendars,
@@ -48,7 +50,10 @@ export function WeekView({
onSelectEvent,
onCreateAtTime,
weekStartsOn = 0,
timeFormat = '24h',
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const scrollRef = React.useRef<ScrollView>(null);
const index = React.useMemo(
@@ -84,7 +89,7 @@ export function WeekView({
? allDayRowCount * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP) + spacing.xs
: 0;
// Timed grid excludes events that fill the full day on that day — they're
// Timed grid excludes events that fill the full day on that day - they're
// already promoted to the all-day strip above.
const layoutsByDay = React.useMemo(() => {
return weekDays.map((day) => {
@@ -170,7 +175,7 @@ export function WeekView({
{weekDays.map((day) => (
<View key={day.toISOString()} style={styles.allDayCol} />
))}
{/* Segments overlay — multi-day events span their date range */}
{/* Segments overlay - multi-day events span their date range */}
<View style={styles.allDayOverlay} pointerEvents="box-none">
{allDaySegments.map((segment) => {
const color = getEventColor(segment.event, calendars);
@@ -221,7 +226,9 @@ export function WeekView({
<View key={h} style={[styles.hourLabelCell, { height: HOUR_HEIGHT }]}>
{h > 0 && (
<Text style={styles.hourLabel}>
{h.toString().padStart(2, '0')}:00
{timeFormat === '12h'
? `${((h % 12) || 12)} ${h < 12 ? 'AM' : 'PM'}`
: `${h.toString().padStart(2, '0')}:00`}
</Text>
)}
</View>
@@ -303,18 +310,21 @@ export function WeekView({
);
}
export const WeekView = React.memo(WeekViewInner);
function minutesToTimeLabel(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
headerRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
gutter: { width: GUTTER_WIDTH },
dayHeaders: { flex: 1, flexDirection: 'row' },
@@ -326,7 +336,7 @@ const styles = StyleSheet.create({
},
dayHeaderWeekday: {
...typography.small,
color: colors.textMuted,
color: c.textMuted,
textTransform: 'uppercase',
},
dayHeaderNumber: {
@@ -336,29 +346,29 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
dayHeaderNumberToday: { backgroundColor: colors.primary },
dayHeaderNumberToday: { backgroundColor: c.primary },
dayHeaderNumberSelected: {
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
borderWidth: 1,
borderColor: colors.primary,
borderColor: c.primary,
},
dayHeaderNumberText: { ...typography.bodyMedium, color: colors.text },
dayHeaderNumberTextToday: { color: colors.textInverse, fontWeight: '700' },
dayHeaderNumberTextSelected: { color: colors.primary, fontWeight: '600' },
dayHeaderNumberText: { ...typography.bodyMedium, color: c.text },
dayHeaderNumberTextToday: { color: c.textInverse, fontWeight: '700' },
dayHeaderNumberTextSelected: { color: c.primary, fontWeight: '600' },
allDayStrip: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
paddingVertical: 2,
},
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
allDayLabel: { ...typography.small, color: colors.textMuted },
allDayLabel: { ...typography.small, color: c.textMuted },
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
allDayCol: {
flex: 1,
borderLeftWidth: 1,
borderLeftColor: colors.borderLight,
borderLeftColor: c.borderLight,
},
allDayOverlay: {
position: 'absolute',
@@ -374,7 +384,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 4,
justifyContent: 'center',
},
allDayChipText: { fontSize: 10, color: colors.text, fontWeight: '500' },
allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
scroll: { flex: 1 },
scrollContent: {},
@@ -383,19 +393,19 @@ const styles = StyleSheet.create({
hourLabelCell: { paddingRight: 4, alignItems: 'flex-end' },
hourLabel: {
...typography.small,
color: colors.textMuted,
color: c.textMuted,
transform: [{ translateY: -6 }],
},
dayCols: { flex: 1, flexDirection: 'row' },
dayCol: {
flex: 1,
borderLeftWidth: 1,
borderLeftColor: colors.border,
borderLeftColor: c.border,
position: 'relative',
},
hourSlot: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
eventBlock: {
position: 'absolute',
@@ -405,8 +415,8 @@ const styles = StyleSheet.create({
paddingVertical: 2,
overflow: 'hidden',
},
eventBlockTitle: { fontSize: 10, color: colors.text, fontWeight: '600' },
eventBlockTime: { fontSize: 9, color: colors.textMuted, marginTop: 1 },
eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
nowLine: {
position: 'absolute',
@@ -419,7 +429,8 @@ const styles = StyleSheet.create({
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: colors.error,
backgroundColor: c.error,
},
nowBar: { flex: 1, height: 1, backgroundColor: colors.error },
});
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
});
}
@@ -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 },
});
}
+108 -84
View File
@@ -8,8 +8,10 @@ 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 { colors, radius, spacing, typography } from '../../theme/tokens';
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
@@ -82,8 +84,11 @@ interface Props {
}
export function ContactActivity({ contact }: Props) {
const c = useColors();
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(',');
@@ -142,94 +147,106 @@ export function ContactActivity({ contact }: Props) {
<View style={styles.wrap}>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MailIcon size={14} color={colors.primary} />
<Text style={styles.sectionTitle}>Recent emails</Text>
</View>
{emailsLoading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.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={colors.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>
);
}
const styles = StyleSheet.create({
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
section: {
backgroundColor: colors.card,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
borderLeftWidth: 3,
borderLeftColor: colors.primary,
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
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: colors.text },
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
emptyText: { ...typography.caption, color: colors.textMuted },
emptyText: { ...typography.caption, color: c.textMuted },
row: {
flexDirection: 'row',
@@ -238,21 +255,28 @@ const styles = StyleSheet.create({
paddingVertical: 6,
borderRadius: radius.xs,
},
rowPressed: { backgroundColor: colors.surface },
rowPressed: { backgroundColor: c.surface },
rowContent: { flex: 1, minWidth: 0 },
rowTop: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm },
rowSender: { ...typography.bodyMedium, color: colors.text, flex: 1 },
rowDate: { ...typography.caption, color: colors.textMuted },
rowSubject: { ...typography.body, color: colors.text },
rowPreview: { ...typography.caption, color: colors.textMuted },
rowSender: { ...typography.bodyMedium, color: c.text, flex: 1 },
rowDate: { ...typography.caption, color: c.textMuted },
rowSubject: { ...typography.body, color: c.text },
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: colors.textMuted,
width: 64,
color: c.textMuted,
width: 52,
},
eventInfo: { flex: 1, minWidth: 0 },
eventTitle: { ...typography.body, color: colors.text },
eventDate: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
});
eventTitle: { ...typography.body, color: c.text },
eventDate: { ...typography.caption, color: c.textMuted, marginTop: 2 },
});
}
@@ -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,
},
});
}
+20 -15
View File
@@ -10,7 +10,8 @@ import {
isGroup,
} from '../../lib/contact-utils';
import SenderAvatar from '../SenderAvatar';
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface ContactListRowProps {
contact: ContactCard;
@@ -29,6 +30,8 @@ export default function ContactListRow({
selectionMode,
secondaryText,
}: ContactListRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const name = getContactDisplayName(contact) || 'Unnamed';
const email = getContactPrimaryEmail(contact);
const org = getPrimaryOrg(contact);
@@ -50,7 +53,7 @@ export default function ContactListRow({
>
{selectionMode && (
<View style={[styles.checkbox, selected && styles.checkboxSelected]}>
{selected && <Check size={14} color={colors.primaryForeground} />}
{selected && <Check size={14} color={c.primaryForeground} />}
</View>
)}
@@ -58,7 +61,7 @@ export default function ContactListRow({
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
) : group ? (
<View style={styles.groupAvatar}>
<Users size={18} color={colors.primary} />
<Users size={18} color={c.primary} />
</View>
) : (
<SenderAvatar name={name} email={email} size={componentSizes.avatarMd} />
@@ -77,7 +80,8 @@ export default function ContactListRow({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -85,37 +89,38 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
gap: spacing.md,
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowSelected: { backgroundColor: colors.primaryBg },
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.primaryBg },
checkbox: {
width: 22,
height: 22,
borderRadius: radius.xs,
borderWidth: 2,
borderColor: colors.border,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
checkboxSelected: {
backgroundColor: colors.primary,
borderColor: colors.primary,
backgroundColor: c.primary,
borderColor: c.primary,
},
avatarImage: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: componentSizes.avatarMd / 2,
backgroundColor: colors.surface,
backgroundColor: c.surface,
},
groupAvatar: {
width: componentSizes.avatarMd,
height: componentSizes.avatarMd,
borderRadius: componentSizes.avatarMd / 2,
backgroundColor: colors.primaryBg,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: colors.text },
subtitle: { ...typography.caption, color: colors.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: colors.textSecondary, marginTop: 1 },
});
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
});
}
+52 -34
View File
@@ -12,7 +12,9 @@ import {
} from '../../stores/contacts-store';
import { matchesContactSearch, isGroup } from '../../lib/contact-utils';
import ContactListRow from './ContactListRow';
import { colors, spacing, radius, typography, componentSizes } from '../../theme/tokens';
import { useSheetDrag } from '../../lib/use-sheet-drag';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Props {
visible: boolean;
@@ -26,9 +28,11 @@ interface Props {
export default function ContactPickerSheet({
visible, onClose, onSelect, title = 'Add Contact', excludedIds, multi = true,
}: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const allContacts = useContactsStore((s) => s.contacts);
const sorted = React.useMemo(
() => sortContactsByDisplayName(allContacts.filter((c) => !isGroup(c))),
() => sortContactsByDisplayName(allContacts.filter((contact) => !isGroup(contact))),
[allContacts],
);
const [query, setQuery] = React.useState('');
@@ -36,6 +40,11 @@ export default function ContactPickerSheet({
const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current;
const overlay = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({
slideY,
closedY: Dimensions.get('window').height,
onClose,
});
React.useEffect(() => {
if (visible) {
@@ -90,37 +99,41 @@ export default function ContactPickerSheet({
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }] }]}>
<SafeAreaView style={{ flex: 1 }} edges={['bottom']}>
<View style={styles.handle} />
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
<X size={20} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>{title}</Text>
{multi && (
<Pressable
onPress={confirm}
disabled={selected.size === 0}
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
hitSlop={8}
>
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
<View {...dragHandlers}>
<View style={styles.handleHit}>
<View style={styles.handle} />
</View>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
)}
<Text style={styles.headerTitle}>{title}</Text>
{multi && (
<Pressable
onPress={confirm}
disabled={selected.size === 0}
style={[styles.doneBtn, selected.size === 0 && styles.doneBtnDisabled]}
hitSlop={8}
>
<Text style={styles.doneLabel}>Add{selected.size > 0 ? ` (${selected.size})` : ''}</Text>
</Pressable>
)}
</View>
</View>
<View style={styles.searchBar}>
<Search size={16} color={colors.textMuted} />
<Search size={16} color={c.textMuted} />
<TextInput
style={styles.searchInput}
placeholder="Search contacts..."
placeholderTextColor={colors.textMuted}
placeholderTextColor={c.textMuted}
value={query}
onChangeText={setQuery}
autoFocus
/>
{query.length > 0 && (
<Pressable onPress={() => setQuery('')} hitSlop={8}>
<X size={16} color={colors.textMuted} />
<X size={16} color={c.textMuted} />
</Pressable>
)}
</View>
@@ -150,24 +163,28 @@ export default function ContactPickerSheet({
);
}
const styles = StyleSheet.create({
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,
top: '15%',
backgroundColor: colors.background,
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
},
handleHit: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
handle: {
alignSelf: 'center',
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: colors.border,
marginTop: spacing.sm,
backgroundColor: c.border,
},
header: {
flexDirection: 'row',
@@ -176,23 +193,23 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
borderBottomColor: c.borderLight,
},
headerBtn: {
width: 40, height: 40,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.full,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1, marginLeft: spacing.xs },
headerTitle: { ...typography.h3, color: c.text, flex: 1, marginLeft: spacing.xs },
doneBtn: {
paddingHorizontal: spacing.md,
height: 36,
justifyContent: 'center',
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: radius.full,
},
doneBtnDisabled: { opacity: 0.5 },
doneLabel: { ...typography.bodyMedium, color: colors.primaryForeground },
doneLabel: { ...typography.bodyMedium, color: c.primaryForeground },
searchBar: {
flexDirection: 'row',
@@ -202,14 +219,15 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
height: componentSizes.inputHeight,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
borderRadius: radius.sm,
gap: spacing.sm,
},
searchInput: { flex: 1, ...typography.body, color: colors.text, paddingVertical: 0 },
searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 },
separator: { height: 1, backgroundColor: colors.borderLight, marginLeft: 68 },
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
empty: { paddingVertical: spacing.xxxl, alignItems: 'center' },
emptyText: { ...typography.body, color: colors.textMuted },
});
emptyText: { ...typography.body, color: c.textMuted },
});
}
@@ -9,7 +9,8 @@ import {
import type { ContactCategory } from '../../stores/contacts-store';
import { useContactsStore } from '../../stores/contacts-store';
import { getContactDisplayName, getContactKeywords, isGroup } from '../../lib/contact-utils';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
interface Props {
visible: boolean;
@@ -25,6 +26,8 @@ function isSameCategory(a: ContactCategory, b: ContactCategory): boolean {
}
export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const selectedCategory = useContactsStore((s) => s.selectedCategory);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const contacts = useContactsStore((s) => s.contacts);
@@ -93,14 +96,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
<X size={20} color={colors.text} />
<X size={20} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>Contacts</Text>
</View>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
<CategoryRow
icon={<Inbox size={16} color={colors.primary} />}
icon={<Inbox size={16} color={c.primary} />}
label="All Contacts"
count={totalCount}
active={selectedCategory.type === 'all'}
@@ -115,7 +118,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.books && books.map((book) => (
<CategoryRow
key={book.id}
icon={<BookUser size={16} color={colors.textSecondary} />}
icon={<BookUser size={16} color={c.textSecondary} />}
label={book.name}
count={book.count}
active={isSameCategory(selectedCategory, { type: 'addressBook', addressBookId: book.id })}
@@ -124,7 +127,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
))}
{expanded.books && (
<CategoryRow
icon={<BookUser size={16} color={colors.textMuted} />}
icon={<BookUser size={16} color={c.textMuted} />}
label="Uncategorized"
count={0}
active={selectedCategory.type === 'uncategorized'}
@@ -142,7 +145,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.groups && groups.map((g) => (
<CategoryRow
key={g.id}
icon={<Users size={16} color={colors.textSecondary} />}
icon={<Users size={16} color={c.textSecondary} />}
label={getContactDisplayName(g) || 'Group'}
count={g.members ? Object.keys(g.members).length : 0}
active={isSameCategory(selectedCategory, { type: 'group', groupId: g.id })}
@@ -160,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
{expanded.tags && keywords.map((kw) => (
<CategoryRow
key={kw.keyword}
icon={<Tag size={16} color={colors.textSecondary} />}
icon={<Tag size={16} color={c.textSecondary} />}
label={kw.keyword}
count={kw.count}
active={isSameCategory(selectedCategory, { type: 'keyword', keyword: kw.keyword })}
@@ -175,12 +178,14 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
}
function SectionHeader({ label, expanded, onPress }: { label: string; expanded: boolean; onPress: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable style={styles.sectionHeader} onPress={onPress}>
{expanded ? (
<ChevronDown size={14} color={colors.textMuted} />
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{label}</Text>
</Pressable>
@@ -196,6 +201,8 @@ function CategoryRow({
active: boolean;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
@@ -212,7 +219,8 @@ function CategoryRow({
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
overlayPress: { flex: 1 },
drawer: {
@@ -220,9 +228,9 @@ const styles = StyleSheet.create({
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: colors.border,
borderRightColor: c.border,
},
drawerSafe: { flex: 1 },
@@ -233,14 +241,14 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
headerClose: {
width: 36, height: 36,
alignItems: 'center', justifyContent: 'center',
borderRadius: radius.sm,
},
headerTitle: { ...typography.h3, color: colors.text },
headerTitle: { ...typography.h3, color: c.text },
scroll: { flex: 1 },
scrollContent: { paddingBottom: spacing.md },
@@ -253,7 +261,7 @@ const styles = StyleSheet.create({
paddingTop: spacing.md,
paddingBottom: 4,
},
sectionHeaderText: { ...typography.bodySemibold, color: colors.text },
sectionHeaderText: { ...typography.bodySemibold, color: c.text },
row: {
flexDirection: 'row',
@@ -263,14 +271,15 @@ const styles = StyleSheet.create({
borderLeftWidth: 2,
borderLeftColor: 'transparent',
},
rowPressed: { backgroundColor: colors.surfaceHover },
rowActive: { backgroundColor: colors.accent, borderLeftColor: colors.primary },
rowPressed: { backgroundColor: c.surfaceHover },
rowActive: { backgroundColor: c.accent, borderLeftColor: c.primary },
rowIcon: {
width: 20, height: 20,
alignItems: 'center', justifyContent: 'center',
marginRight: spacing.sm,
},
rowLabel: { flex: 1, ...typography.body, color: colors.text },
rowLabelActive: { ...typography.bodySemibold, color: colors.text },
rowCount: { ...typography.caption, color: colors.textMuted, marginLeft: spacing.sm },
});
rowLabel: { flex: 1, ...typography.body, color: c.text },
rowLabelActive: { ...typography.bodySemibold, color: c.text },
rowCount: { ...typography.caption, color: c.textMuted, marginLeft: spacing.sm },
});
}
+66 -57
View File
@@ -1,19 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, X } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
export type CategoryAccent = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'calendar' | 'notes';
const ACCENT_COLORS: Record<CategoryAccent, string> = {
contact: colors.primary,
work: colors.calendar.orange,
location: colors.calendar.green,
personal: colors.calendar.pink,
digital: colors.calendar.teal,
calendar: colors.calendar.indigo,
notes: colors.calendar.purple,
};
function accentColor(category: CategoryAccent, c: ThemePalette): string {
switch (category) {
case 'contact': return c.primary;
case 'work': return c.calendar.orange;
case 'location': return c.calendar.green;
case 'personal': return c.calendar.pink;
case 'digital': return c.calendar.teal;
case 'calendar': return c.calendar.indigo;
case 'notes': return c.calendar.purple;
}
}
interface FieldBlockProps {
title: string;
@@ -27,7 +30,9 @@ interface FieldBlockProps {
export default function FieldBlock({
title, category = 'contact', icon, onAdd, addLabel, children,
}: FieldBlockProps) {
const accent = ACCENT_COLORS[category];
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accent = accentColor(category, c);
return (
<View style={[styles.block, { borderLeftColor: accent }]}>
<View style={styles.header}>
@@ -36,7 +41,7 @@ export default function FieldBlock({
<View style={{ flex: 1 }} />
{onAdd && (
<Pressable onPress={onAdd} hitSlop={8} style={styles.addBtn}>
<Plus size={14} color={colors.primary} />
<Plus size={14} color={c.primary} />
{!!addLabel && <Text style={styles.addLabel}>{addLabel}</Text>}
</Pressable>
)}
@@ -52,59 +57,63 @@ interface FieldRowProps {
}
export function FieldRow({ onRemove, children }: FieldRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.row}>
<View style={{ flex: 1 }}>{children}</View>
{onRemove && (
<Pressable onPress={onRemove} hitSlop={8} style={styles.removeBtn}>
<X size={14} color={colors.textMuted} />
<X size={14} color={c.textMuted} />
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
block: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
paddingVertical: spacing.sm,
borderLeftWidth: 3,
borderRadius: radius.sm,
backgroundColor: colors.card,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
},
title: { ...typography.bodySemibold, color: colors.text },
addBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
},
addLabel: { ...typography.caption, color: colors.primary },
body: { gap: spacing.sm },
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
removeBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: colors.surface,
marginTop: 4,
},
});
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
block: {
marginHorizontal: spacing.lg,
marginBottom: spacing.md,
paddingLeft: spacing.md,
paddingRight: spacing.sm,
paddingVertical: spacing.sm,
borderLeftWidth: 3,
borderRadius: radius.sm,
backgroundColor: c.card,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
},
title: { ...typography.bodySemibold, color: c.text },
addBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
addLabel: { ...typography.caption, color: c.primary },
body: { gap: spacing.sm },
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
},
removeBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.full,
backgroundColor: c.surface,
marginTop: 4,
},
});
}
+195
View File
@@ -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 },
});
}
+3
View File
@@ -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 },
});
}
+425
View File
@@ -0,0 +1,425 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
type RolePreset = 'read' | 'readWrite' | 'manager';
// Same presets the webmail's share dialog offers (#408). "custom" rights set
// by another client are shown as-is and left untouched until the user picks a
// preset for that principal.
const FILE_PRESETS: Record<RolePreset, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
const PRESET_LABELS: Record<RolePreset, string> = {
read: 'Viewer',
readWrite: 'Editor',
manager: 'Manager',
};
const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager'];
function detectPreset(rights: FileNodeRights): RolePreset | 'custom' {
for (const preset of PRESET_ORDER) {
const expected = FILE_PRESETS[preset];
const keys = Object.keys(expected) as (keyof FileNodeRights)[];
if (keys.every((k) => expected[k] === (rights[k] ?? false))) return preset;
}
return 'custom';
}
interface ShareSheetProps {
node: FileNode | null;
onClose: () => void;
/** Fired after any successful share change so the parent can refresh. */
onChanged: () => void;
}
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [principals, setPrincipals] = useState<Principal[]>([]);
const [loadingPrincipals, setLoadingPrincipals] = useState(false);
const [shares, setShares] = useState<Record<string, FileNodeRights>>({});
const [search, setSearch] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
if (!node) return;
setShares(node.shareWith ?? {});
setSearch('');
setLoadingPrincipals(true);
getPrincipals()
.then(setPrincipals)
.catch(() => setPrincipals([]))
.finally(() => setLoadingPrincipals(false));
}, [node]);
const principalsById = useMemo(() => {
const map = new Map<string, Principal>();
for (const p of principals) map.set(p.id, p);
return map;
}, [principals]);
const sharedEntries = useMemo(
() => Object.entries(shares).filter(([, rights]) => rights != null),
[shares],
);
const candidates = useMemo(() => {
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
.filter((p) => p.id !== self && shares[p.id] == null)
.filter((p) =>
!q ||
p.name?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) ||
p.description?.toLowerCase().includes(q))
.slice(0, 25);
}, [principals, shares, search]);
const applyShare = useCallback(
async (principalId: string, rights: FileNodeRights | null) => {
if (!node || savingId) return;
setSavingId(principalId);
try {
await setFileNodeShare(node.id, principalId, rights);
setShares((prev) => {
const next = { ...prev };
if (rights == null) delete next[principalId];
else next[principalId] = rights;
return next;
});
onChanged();
} catch (e) {
Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e));
} finally {
setSavingId(null);
}
},
[node, savingId, onChanged],
);
if (!node) return null;
const Icon = isFolder(node) ? Folder : FileText;
const renderPrincipalLabel = (principalId: string) => {
const p = principalsById.get(principalId);
return (
<View style={styles.principalInfo}>
<Text style={styles.principalName} numberOfLines={1}>
{p?.description || p?.name || principalId}
</Text>
{p?.email ? (
<Text style={styles.principalEmail} numberOfLines={1}>
{p.email}
</Text>
) : null}
</View>
);
};
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop}>
<TouchableWithoutFeedback>
<View style={styles.sheet}>
<View style={styles.titleRow}>
<Icon size={18} color={c.textMuted} />
<Text style={styles.title} numberOfLines={1}>
Share “{node.name}”
</Text>
</View>
{isFolder(node) ? (
<Text style={styles.subtitle}>
Sharing a folder shares everything inside it.
</Text>
) : null}
<ScrollView
style={styles.scroll}
keyboardShouldPersistTaps="handled"
>
{sharedEntries.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionLabel}>Shared with</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectPreset(rights);
const busy = savingId === principalId;
return (
<View key={principalId} style={styles.shareRow}>
{renderPrincipalLabel(principalId)}
<View style={styles.roleChips}>
{PRESET_ORDER.map((p) => (
<Pressable
key={p}
onPress={() => void applyShare(principalId, FILE_PRESETS[p])}
disabled={busy}
style={[styles.chip, preset === p && styles.chipActive]}
>
<Text
style={[styles.chipText, preset === p && styles.chipTextActive]}
>
{PRESET_LABELS[p]}
</Text>
</Pressable>
))}
{preset === 'custom' ? (
<Text style={styles.customLabel}>Custom</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Pressable
onPress={() => void applyShare(principalId, null)}
hitSlop={8}
accessibilityLabel="Remove access"
>
<Trash2 size={18} color={c.error} />
</Pressable>
)}
</View>
);
})}
</View>
) : (
<View style={styles.emptyRow}>
<Users size={18} color={c.textMuted} />
<Text style={styles.emptyText}>Not shared with anyone yet</Text>
</View>
)}
<View style={styles.section}>
<Text style={styles.sectionLabel}>Add people</Text>
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search by name or email"
placeholderTextColor={c.textMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
/>
{loadingPrincipals ? (
<ActivityIndicator
size="small"
color={c.textMuted}
style={{ marginTop: spacing.sm }}
/>
) : candidates.length === 0 ? (
<Text style={styles.noMatches}>
{principals.length === 0 ? 'No other users found' : 'No matches'}
</Text>
) : (
candidates.map((p) => (
<Pressable
key={p.id}
onPress={() => void applyShare(p.id, FILE_PRESETS.read)}
disabled={savingId != null}
style={({ pressed }) => [
styles.candidateRow,
pressed && { backgroundColor: c.surfaceHover },
]}
>
{renderPrincipalLabel(p.id)}
{savingId === p.id ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<UserPlus size={18} color={c.primary} />
)}
</Pressable>
))
)}
</View>
</ScrollView>
<Pressable onPress={onClose} style={styles.doneBtn}>
<Text style={styles.doneText}>Done</Text>
</Pressable>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
paddingHorizontal: spacing.md,
maxHeight: '80%',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
},
title: {
...typography.h3,
color: c.text,
flexShrink: 1,
},
subtitle: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.sm,
marginTop: 2,
},
scroll: {
marginTop: spacing.sm,
},
section: {
marginTop: spacing.md,
},
sectionLabel: {
fontSize: typography.caption.fontSize,
fontWeight: '600' as const,
color: c.textMuted,
textTransform: 'uppercase' as const,
paddingHorizontal: spacing.sm,
marginBottom: spacing.xs,
},
shareRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
principalInfo: {
flex: 1,
minWidth: 0,
},
principalName: {
fontSize: typography.body.fontSize,
color: c.text,
},
principalEmail: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
roleChips: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
chip: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
chipActive: {
backgroundColor: c.primaryBg,
borderColor: c.primary,
},
chipText: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
},
chipTextActive: {
color: c.primary,
fontWeight: '600' as const,
},
customLabel: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
fontStyle: 'italic' as const,
},
emptyRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.md,
},
emptyText: {
fontSize: typography.body.fontSize,
color: c.textMuted,
},
input: {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: c.text,
fontSize: typography.body.fontSize,
backgroundColor: c.surface,
marginHorizontal: spacing.sm,
},
noMatches: {
fontSize: typography.caption.fontSize,
color: c.textMuted,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
candidateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
doneBtn: {
marginTop: spacing.md,
alignSelf: 'stretch',
alignItems: 'center',
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.primary,
},
doneText: {
color: c.primaryForeground,
fontWeight: '600' as const,
fontSize: typography.body.fontSize,
},
});
}
+467
View File
@@ -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,
},
});
}
+255
View File
@@ -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 },
});
}
@@ -0,0 +1,348 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const DAY_OPTIONS = [
{ value: '1', label: 'Last 24 hours' },
{ value: '3', label: 'Last 3 days' },
{ value: '7', label: 'Last 7 days' },
{ value: '14', label: 'Last 14 days' },
{ value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
];
const MAX_MB_OPTIONS = [
{ value: '25', label: '25 MB' },
{ value: '50', label: '50 MB' },
{ value: '100', label: '100 MB' },
{ value: '250', label: '250 MB' },
{ value: '500', label: '500 MB' },
];
function formatRelativeTime(ms: number | undefined): string {
if (!ms) return 'never';
const diff = Date.now() - ms;
if (diff < 60_000) return 'just now';
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
}
const DEBUG_CATEGORIES = [
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
{ id: 'sync', label: 'Sync', description: 'Background sync operations.' },
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
export function AboutDataSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const resetExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const [debugMode, setDebugMode] = useState(false);
const [debugCategories, setDebugCategories] = useState<Record<string, boolean>>(
Object.fromEntries(DEBUG_CATEGORIES.map((c) => [c.id, true]))
);
const [syncEnabled, setSyncEnabled] = useState(true);
const [confirmReset, setConfirmReset] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const queuedChanges = useOutboxStore((s) => s.entries.length);
const flushOutbox = useOutboxStore((s) => s.flush);
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
const sync = useOfflineCacheStore((s) => s.sync);
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
useEffect(() => {
if (!hydrated) void hydrate();
if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
// Also drain any queued offline changes while we're at it.
void flushOutbox();
};
const handleClearCache = () => {
Alert.alert(
'Clear offline mail',
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
],
);
};
const handleReset = () => {
resetExternalContentPolicy('ask');
setSenderFaviconsStore(true);
for (const email of trustedSenders) removeTrustedSender(email);
setConfirmReset(false);
Alert.alert('Settings reset', 'Saved settings have been restored to defaults.');
};
return (
<View style={styles.container}>
<View style={styles.aboutBox}>
<View style={styles.aboutRow}>
<View style={styles.logo}>
<Text style={styles.logoText}>B</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.aboutTitle}>About</Text>
<Text style={styles.aboutVersion}>
v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</Text>
</View>
<Pressable
style={styles.ghLink}
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={c.mutedForeground} />
</Pressable>
</View>
</View>
<SettingsSection
title="Offline mail"
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
>
<SettingItem
label="Cache recent mail"
description="When on, the app keeps message bodies for the selected window on this device."
>
<ToggleSwitch
checked={offlineEnabled}
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
/>
</SettingItem>
<SettingItem
label="Window"
description="How far back to cache, measured by message receipt date."
>
<Select
value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={DAY_OPTIONS}
/>
</SettingItem>
<SettingItem
label="Maximum size"
description="Oldest messages are removed when the cache grows past this."
>
<Select
value={String(offlineMaxMB)}
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
options={MAX_MB_OPTIONS}
/>
</SettingItem>
<View style={styles.cacheStatsBox}>
<View style={styles.cacheStatsHeader}>
<CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}>
{cacheCount === 0
? 'Nothing cached yet'
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
</Text>
</View>
<Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching'
? `Downloading ${sync.completed}/${sync.total}…`
: sync.phase === 'scanning'
? 'Scanning recent mail…'
: sync.phase === 'error'
? `Last sync failed: ${sync.message ?? 'unknown error'}`
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
</Text>
{queuedChanges > 0 && (
<Text style={styles.cacheStatsSub}>
{`${queuedChanges} change${queuedChanges === 1 ? '' : 's'} waiting to sync`}
</Text>
)}
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
]}
/>
</View>
)}
<View style={styles.cacheActions}>
<Button
variant="outline"
size="sm"
onPress={handleSyncNow}
disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{syncBusy ? 'Syncing…' : 'Sync now'}
</Button>
<Button
variant="outline"
size="sm"
onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy}
>
Clear cache
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
</SettingItem>
{debugMode && (
<View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}>
Which categories to include in debug output.
</Text>
{DEBUG_CATEGORIES.map((cat) => (
<SettingItem key={cat.id} label={cat.label} description={cat.description}>
<ToggleSwitch
checked={debugCategories[cat.id] !== false}
onChange={(v) => setDebugCategories({ ...debugCategories, [cat.id]: v })}
/>
</SettingItem>
))}
</View>
)}
<SettingItem label="Settings sync" description="Sync settings across your signed-in devices.">
<ToggleSwitch checked={syncEnabled} onChange={setSyncEnabled} />
</SettingItem>
<SettingItem
label="Sender favicons"
description="Fetch website favicons for sender avatars (experimental)."
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem>
<SettingItem label="Reset settings" description="Restore saved preferences (external content, trusted senders, favicons) to defaults.">
<Button
variant={confirmReset ? 'destructive' : 'outline'}
size="sm"
onPress={() => {
if (confirmReset) {
handleReset();
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 5000);
}
}}
>
{confirmReset ? 'Confirm reset' : 'Reset'}
</Button>
</SettingItem>
</SettingsSection>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
aboutBox: {
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.card,
},
aboutRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.lg,
},
logo: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: c.text },
aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
aboutCommit: { color: c.mutedForeground, opacity: 0.6 },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
ghText: { ...typography.caption, color: c.mutedForeground },
debugCategoriesBox: {
marginLeft: spacing.lg,
paddingLeft: spacing.lg,
borderLeftWidth: 2,
borderLeftColor: c.muted,
gap: 4,
},
debugCategoriesHint: {
...typography.caption,
color: c.mutedForeground,
marginBottom: spacing.sm,
},
cacheStatsBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
gap: spacing.sm,
},
cacheStatsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
progressTrack: {
height: 4,
borderRadius: radius.full,
backgroundColor: c.border,
overflow: 'hidden',
},
progressFill: { height: '100%', backgroundColor: c.primary },
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
});
}
@@ -1,195 +1,733 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { jmapClient } from '../../api/jmap-client';
import {
clearClientCertAlias,
getClientCertAlias,
isClientCertSupported,
pickClientCertAlias,
} from '../../lib/client-cert';
import {
isStalwartSupported,
fetchAuthInfo,
fetchEncryptionType,
fetchPrincipal,
changePassword,
updateDisplayName,
enableTotp,
disableTotp,
createAppPassword,
removeAppPassword,
createApiKey,
removeApiKey,
type AppCredentialInfo,
type AppCredentialInput,
type AuthInfo,
type EncryptionType,
} from '../../api/account-security';
import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp';
interface Session {
id: string;
device: string;
location: string;
current: boolean;
// ── TLS client certificate (mobile-only) ──────────────────
// Picks an installed Android cert for mTLS handshakes. Independent of the
// Stalwart account features below, so it stays visible even on non-Stalwart
// servers.
function ClientCertSection() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [certAlias, setCertAlias] = useState<string | null>(null);
const [certBusy, setCertBusy] = useState(false);
useEffect(() => {
void getClientCertAlias().then(setCertAlias);
}, []);
const onPickCert = async () => {
if (certBusy) return;
setCertBusy(true);
try {
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
const alias = await pickClientCertAlias(host);
setCertAlias(alias);
} catch (err) {
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
} finally {
setCertBusy(false);
}
};
const onClearCert = () => {
if (certBusy) return;
Alert.alert(
'Stop using client certificate?',
'New requests will no longer present a certificate. The certificate stays installed in Android.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
setCertBusy(true);
try {
await clearClientCertAlias();
setCertAlias(null);
} finally {
setCertBusy(false);
}
},
},
],
);
};
return (
<SettingsSection
title="TLS client certificate"
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
>
<View style={styles.certRow}>
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.certStatus}>{certAlias ? `Active: ${certAlias}` : 'Not set'}</Text>
<Text style={styles.certHint}>
Install the certificate via Android Settings → Security → Encryption &amp; credentials, then pick it here.
</Text>
</View>
</View>
<View style={styles.rowGap}>
<Button variant="outline" size="sm" disabled={certBusy} onPress={() => { void onPickCert(); }}>
{certAlias ? 'Choose another' : 'Pick certificate'}
</Button>
{certAlias && (
<Button variant="outline" size="sm" disabled={certBusy} onPress={onClearCert}>
Clear
</Button>
)}
</View>
</SettingsSection>
);
}
const MOCK_SESSIONS: Session[] = [
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
];
export function AccountSecuritySettings() {
// ── Password change ───────────────────────────────────────
function PasswordChangeSection() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [currentPwd, setCurrentPwd] = useState('');
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 [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 (
<View style={styles.container}>
<SettingsSection title="Password" description="Change the password for this account.">
<View style={{ gap: spacing.md }}>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>Current password</Text>
<View style={styles.pwInputRow}>
<Input
value={currentPwd}
onChangeText={setCurrentPwd}
secureTextEntry={!showCurrent}
containerStyle={{ flex: 1 }}
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
{showCurrent ? (
<EyeOff size={16} color={colors.mutedForeground} />
) : (
<Eye size={16} color={colors.mutedForeground} />
)}
</Pressable>
</View>
</View>
<View style={styles.pwField}>
<Text style={styles.pwLabel}>New password</Text>
<View style={styles.pwInputRow}>
<Input
value={newPwd}
onChangeText={setNewPwd}
secureTextEntry={!showNew}
containerStyle={{ flex: 1 }}
/>
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
{showNew ? (
<EyeOff size={16} color={colors.mutedForeground} />
) : (
<Eye size={16} color={colors.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={colors.primaryForeground} />}>
Change Password
</Button>
<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>
</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={colors.primary} />
<Text style={styles.infoText}>
Scan the QR code in your authenticator app to finish setup.
</Text>
<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>
)}
</SettingsSection>
</View>
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
{appPwds.length === 0 ? (
<View style={styles.empty}>
<Lock size={24} color={colors.mutedForeground} />
<Text style={styles.emptyText}>No app passwords yet.</Text>
</View>
) : null}
<View style={{ alignItems: 'flex-start' }}>
<Button variant="outline" size="sm">
Generate New
<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>
</SettingsSection>
</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={colors.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={colors.error} />
</Pressable>
)}
// ── 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>
))}
</SettingsSection>
<View>
<Text style={styles.pwLabel}>Current password</Text>
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
</View>
<View>
<Text style={styles.pwLabel}>Verification code</Text>
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} />
</View>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<View style={styles.rowGap}>
<Button size="sm" loading={saving} disabled={saving || !password || !otpCode} onPress={() => { void confirmEnable(); }}>
Confirm
</Button>
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
</View>
</View>
)}
{disableOpen && (
<View style={styles.panel}>
<Text style={styles.panelHint}>Enter your password to turn off two-factor authentication.</Text>
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" placeholder="Current password" />
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<View style={styles.rowGap}>
<Button variant="destructive" size="sm" loading={saving} disabled={saving || !password} onPress={() => { void confirmDisable(); }}>
Disable
</Button>
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
pwField: { gap: 4 },
pwLabel: { ...typography.caption, color: colors.mutedForeground },
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
eyeBtn: { padding: spacing.sm },
infoBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
backgroundColor: colors.primaryBg,
borderRadius: radius.sm,
alignItems: 'center',
},
infoText: {
...typography.caption,
color: colors.primary,
flex: 1,
},
empty: {
alignItems: 'center',
padding: spacing.xl,
gap: spacing.sm,
},
emptyText: {
...typography.body,
color: colors.mutedForeground,
},
sessionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
sessionIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
backgroundColor: colors.muted,
alignItems: 'center',
justifyContent: 'center',
},
sessionDevice: { ...typography.bodyMedium, color: colors.text },
sessionMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
trashBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
});
// ── 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>
)}
{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}
{!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>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
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 },
});
}
+10 -5
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
@@ -29,6 +30,8 @@ function formatFileSize(bytes: number): string {
}
export function AccountSettings(props: AccountSettingsProps = {}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -99,10 +102,11 @@ export function AccountSettings(props: AccountSettingsProps = {}) {
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
value: {
...typography.body,
color: colors.text,
color: c.text,
maxWidth: 240,
},
storage: {
@@ -112,13 +116,13 @@ const styles = StyleSheet.create({
bar: {
width: 128,
height: 8,
backgroundColor: colors.muted,
backgroundColor: c.muted,
borderRadius: radius.full,
overflow: 'hidden',
},
barFill: {
height: '100%',
backgroundColor: colors.primary,
backgroundColor: c.primary,
borderRadius: radius.full,
},
demoRow: {
@@ -137,3 +141,4 @@ const styles = StyleSheet.create({
color: '#fbbf24',
},
});
}
@@ -1,169 +0,0 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
import Constants from 'expo-constants';
import { ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useSettingsStore } from '../../stores/settings-store';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const DEBUG_CATEGORIES = [
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
{ id: 'sync', label: 'Sync', description: 'Background sync operations.' },
{ id: 'render', label: 'Render', description: 'React render counts and timings.' },
];
export function AdvancedSettings() {
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const resetExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const [debugMode, setDebugMode] = useState(false);
const [debugCategories, setDebugCategories] = useState<Record<string, boolean>>(
Object.fromEntries(DEBUG_CATEGORIES.map((c) => [c.id, true]))
);
const [syncEnabled, setSyncEnabled] = useState(true);
const [confirmReset, setConfirmReset] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const handleReset = () => {
resetExternalContentPolicy('ask');
setSenderFaviconsStore(true);
for (const email of trustedSenders) removeTrustedSender(email);
setConfirmReset(false);
Alert.alert('Settings reset', 'Saved settings have been restored to defaults.');
};
return (
<View style={styles.container}>
<View style={styles.aboutBox}>
<View style={styles.aboutRow}>
<View style={styles.logo}>
<Text style={styles.logoText}>B</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.aboutTitle}>About</Text>
<Text style={styles.aboutVersion}>
v{APP_VERSION}{' '}
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
</Text>
</View>
<Pressable
style={styles.ghLink}
onPress={() => Linking.openURL('https://github.com/bulwarkmail/webmail')}
>
<Text style={styles.ghText}>GitHub</Text>
<ExternalLink size={12} color={colors.mutedForeground} />
</Pressable>
</View>
</View>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
</SettingItem>
{debugMode && (
<View style={styles.debugCategoriesBox}>
<Text style={styles.debugCategoriesHint}>
Which categories to include in debug output.
</Text>
{DEBUG_CATEGORIES.map((cat) => (
<SettingItem key={cat.id} label={cat.label} description={cat.description}>
<ToggleSwitch
checked={debugCategories[cat.id] !== false}
onChange={(v) => setDebugCategories({ ...debugCategories, [cat.id]: v })}
/>
</SettingItem>
))}
</View>
)}
<SettingItem label="Settings sync" description="Sync settings across your signed-in devices.">
<ToggleSwitch checked={syncEnabled} onChange={setSyncEnabled} />
</SettingItem>
<SettingItem
label="Sender favicons"
description="Fetch website favicons for sender avatars (experimental)."
>
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
</SettingItem>
<SettingItem label="Reset settings" description="Restore saved preferences (external content, trusted senders, favicons) to defaults.">
<Button
variant={confirmReset ? 'destructive' : 'outline'}
size="sm"
onPress={() => {
if (confirmReset) {
handleReset();
} else {
setConfirmReset(true);
setTimeout(() => setConfirmReset(false), 5000);
}
}}
>
{confirmReset ? 'Confirm reset' : 'Reset'}
</Button>
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
aboutBox: {
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
},
aboutRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.lg,
},
logo: {
width: 48,
height: 48,
borderRadius: radius.md,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 24, fontWeight: '700', color: colors.primaryForeground },
aboutTitle: { ...typography.bodyMedium, color: colors.text },
aboutVersion: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
aboutCommit: { color: colors.mutedForeground, opacity: 0.6 },
ghLink: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
ghText: { ...typography.caption, color: colors.mutedForeground },
debugCategoriesBox: {
marginLeft: spacing.lg,
paddingLeft: spacing.lg,
borderLeftWidth: 2,
borderLeftColor: colors.muted,
gap: 4,
},
debugCategoriesHint: {
...typography.caption,
color: colors.mutedForeground,
marginBottom: spacing.sm,
},
});
+37 -69
View File
@@ -1,14 +1,11 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { PlayCircle } from 'lucide-react-native';
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store';
type Theme = 'light' | 'dark' | 'system';
type FontSize = 'small' | 'medium' | 'large';
type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
type ToolbarPosition = 'top' | 'below-subject';
type Theme = ThemeMode;
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
@@ -24,6 +21,8 @@ const PREVIEW_ROWS = [
];
function DensityPreview({ density }: { density: Density }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const cfg = DENSITY_PREVIEW[density];
return (
<View style={styles.densityPreview}>
@@ -50,7 +49,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySender,
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
row.unread ? { color: c.text, fontWeight: '600' } : { color: c.mutedForeground },
]}
numberOfLines={1}
>
@@ -61,7 +60,7 @@ function DensityPreview({ density }: { density: Density }) {
<Text
style={[
styles.densitySubject,
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
row.unread ? { color: c.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
]}
numberOfLines={1}
>
@@ -80,16 +79,24 @@ function DensityPreview({ density }: { density: Density }) {
}
export function AppearanceSettings() {
const [theme, setTheme] = useState<Theme>('dark');
const [fontSize, setFontSize] = useState<FontSize>('medium');
const [density, setDensity] = useState<Density>('regular');
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition>('top');
const [showToolbarLabels, setShowToolbarLabels] = useState(true);
const [hideAccountSwitcher, setHideAccountSwitcher] = useState(false);
const [showRailAccountList, setShowRailAccountList] = useState(false);
const [colorfulSidebarIcons, setColorfulSidebarIcons] = useState(true);
const [animationsEnabled, setAnimationsEnabled] = useState(true);
const [language, setLanguage] = useState('en');
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
const fontSize = useSettingsStore((s) => s.fontSize);
const setFontSize = useSettingsStore((s) => s.setFontSize);
const density = useSettingsStore((s) => s.density);
const setDensity = useSettingsStore((s) => s.setDensity);
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
React.useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
return (
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
@@ -105,18 +112,6 @@ export function AppearanceSettings() {
/>
</SettingItem>
<SettingItem label="Language" description="Interface language.">
<RadioGroup
value={language}
onChange={setLanguage}
options={[
{ value: 'en', label: 'EN' },
{ value: 'de', label: 'DE' },
{ value: 'fr', label: 'FR' },
]}
/>
</SettingItem>
<SettingItem label="Font Size" description="Adjust text size.">
<RadioGroup
value={fontSize}
@@ -145,64 +140,36 @@ export function AppearanceSettings() {
<View style={styles.divider} />
</View>
<SettingItem label="Toolbar Position" description="Where to place the message toolbar.">
<RadioGroup
value={toolbarPosition}
onChange={(v) => setToolbarPosition(v as ToolbarPosition)}
options={[
{ value: 'top', label: 'Top' },
{ value: 'below-subject', label: 'Below' },
]}
/>
</SettingItem>
<SettingItem label="Toolbar Labels" description="Show text labels on toolbar buttons.">
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
</SettingItem>
<SettingItem label="Hide Account Switcher" description="Hide the account switcher from the navigation rail.">
<ToggleSwitch checked={hideAccountSwitcher} onChange={setHideAccountSwitcher} />
</SettingItem>
<SettingItem label="Rail Account List" description="Show all accounts inside the navigation rail.">
<ToggleSwitch checked={showRailAccountList} onChange={setShowRailAccountList} />
</SettingItem>
<SettingItem label="Colorful Sidebar Icons" description="Display sidebar icons with their brand colors.">
<ToggleSwitch checked={colorfulSidebarIcons} onChange={setColorfulSidebarIcons} />
</SettingItem>
<SettingItem label="Animations" description="Enable or disable interface animations.">
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
</SettingItem>
<SettingItem label="Restart Tour" description="Replay the onboarding walkthrough.">
<Button variant="outline" size="sm" icon={<PlayCircle size={14} color={colors.text} />}>
Restart
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
densityPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: c.border,
overflow: 'hidden',
backgroundColor: colors.background,
backgroundColor: c.background,
},
densityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
borderBottomColor: colors.border,
borderBottomColor: c.border,
},
densityAvatar: {
borderRadius: radius.full,
backgroundColor: colors.muted,
backgroundColor: c.muted,
},
densityHeader: {
flexDirection: 'row',
@@ -216,7 +183,7 @@ const styles = StyleSheet.create({
},
densityTime: {
fontSize: 10,
color: colors.mutedForeground,
color: c.mutedForeground,
},
densitySubject: {
...typography.caption,
@@ -227,7 +194,8 @@ const styles = StyleSheet.create({
},
divider: {
height: 1,
backgroundColor: colors.border,
backgroundColor: c.border,
marginTop: spacing.md,
},
});
}

Some files were not shown because too many files have changed in this diff Show More