chore: add Android release CI and sync local work

CI:
- .github/workflows/release-android.yml builds APK on release publish
  and via workflow_dispatch, attaches it to the release.
- android/app/build.gradle: optional release signingConfig driven by
  Gradle properties (ANDROID_KEYSTORE_BASE64 secret etc.); falls back
  to the debug keystore when no secrets are set.
- .gitignore: untrack /android so the runner can build directly;
  ignore build outputs (build/, .cxx/), Metro/Expo dev logs, scratch.

Track android/ sources including BulwarkFcmPackage.kt and
google-services.json (Firebase Android keys are public per Google's
docs and intended to ship in the APK).

JS sync: push notifications + background task, vacation responder
(api + store), contact activity component, search utilities, settings
polish, calendar/contacts/email store and screen tweaks.

Tooling: scripts/poll-reload.mjs — Windows polling-watcher that hits
Metro /reload on file change (workaround for unreliable fs.watch
without Watchman).
This commit is contained in:
Linus Rath
2026-04-26 04:12:08 +02:00
parent 2fe15e6d70
commit 365ffefd33
102 changed files with 7914 additions and 214 deletions
+80
View File
@@ -0,0 +1,80 @@
name: Release Android APK
on:
release:
types: [published]
workflow_dispatch:
inputs:
attach_to_release_tag:
description: "Optional release tag to attach the APK to (leave empty to skip upload)"
required: false
default: ""
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
- name: Install JS dependencies
run: npm ci
- name: Decode release keystore
id: keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$KEYSTORE_BASE64" ]; then
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT"
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Build release APK
working-directory: android
env:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.keystore.outputs.path }}
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
- name: Rename APK with version
id: artifact
run: |
VERSION=$(node -p "require('./app.json').expo.version")
SRC=android/app/build/outputs/apk/release/app-release.apk
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
cp "$SRC" "$DEST"
echo "path=$DEST" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.artifact.outputs.path }}
path: ${{ steps.artifact.outputs.path }}
if-no-files-found: error
- name: Attach APK to release
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.attach_to_release_tag != '')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.attach_to_release_tag }}
files: ${{ steps.artifact.outputs.path }}
+23 -2
View File
@@ -18,8 +18,15 @@ expo-env.d.ts
*.key
*.mobileprovision
# Metro
# Metro / Expo dev-server logs
.metro-health-check*
metro.log
expo.log
expo-run.log
scripts/*.log
# Scratch
tmp-*
# debug
npm-debug.*
@@ -38,4 +45,18 @@ yarn-error.*
# generated native folders
/ios
/android
# Android build outputs (track the project itself, not its build artifacts)
/android/.gradle/
/android/.idea/
/android/.kotlin/
/android/build/
/android/app/build/
/android/app/.cxx/
/android/local.properties
/android/captures/
/android/*.iml
/android/app/*.iml
/android/build-release.log
/android/expo*.log
/android/metro.log
+103 -5
View File
@@ -1,12 +1,21 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { startPushUpdates } from './src/api/push';
import {
addMessageListener,
addNotificationTapListener,
addTokenRefreshListener,
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
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';
@@ -19,6 +28,7 @@ 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 { 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';
@@ -28,6 +38,16 @@ import { colors, spacing, typography } from './src/theme/tokens';
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabsParamList>();
const navigationRef = createNavigationContainerRef<RootStackParamList>();
function navigateToNotificationTap(payload: NotificationTapPayload): void {
if (!navigationRef.isReady()) return;
navigationRef.navigate('EmailThread', {
emailId: payload.emailId,
threadId: payload.threadId,
subject: payload.subject,
});
}
function LoadingScreen({ message }: { message: string }) {
return (
@@ -135,6 +155,11 @@ export default function App() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const client = useAuthStore((state) => state.client);
const restoreSession = useAuthStore((state) => state.restoreSession);
// 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
// JMAP session comes up in the background.
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
React.useEffect(() => {
if (!hasRestoredSession) {
@@ -146,6 +171,74 @@ export default function App() {
void useSettingsStore.getState().hydrate();
}, []);
// Listen for FCM messages so the app can refresh state even when woken
// from the background — the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
React.useEffect(() => {
const unsubscribe = addMessageListener((payload) => {
console.log('[push] fcm message', payload.title);
});
return unsubscribe;
}, []);
// When the user taps a notification the app lands here with an email id
// either stashed on the cold-start intent or delivered as a live event.
// Wait until auth is restored so the navigation target has credentials to
// load the thread.
React.useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
void (async () => {
const initial = await getInitialNotificationTap();
if (cancelled || !initial) return;
navigateToNotificationTap(initial);
})();
const unsubscribe = addNotificationTapListener((payload) => {
navigateToNotificationTap(payload);
});
return () => {
cancelled = true;
unsubscribe();
};
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates.
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl) return;
try {
await setupPushNotifications({
relayBaseUrl,
accountLabel: client.username ?? undefined,
});
if (cancelled) return;
} catch (error) {
console.warn(
'[push] relay setup failed:',
error instanceof Error ? error.message : error,
);
}
};
void doSetup();
const unsubscribe = addTokenRefreshListener(() => {
void doSetup();
});
return () => {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
return;
@@ -186,16 +279,20 @@ export default function App() {
};
}, [client, isAuthenticated]);
if (!hasRestoredSession) {
// Skip the "Restoring session" flash for returning users: if we already
// have a persisted active account, render the main UI immediately with
// whatever the email-store hydrated from cache. restoreSession still runs
// in the background and swaps in fresh data once it completes.
if (!hasRestoredSession && !hasPersistedAccount) {
return (
<>
<StatusBar style="light" />
<LoadingScreen message="Restoring session..." />
<LoadingScreen message="Loading..." />
</>
);
}
if (!isAuthenticated) {
if (hasRestoredSession && !isAuthenticated) {
return (
<>
<StatusBar style="light" />
@@ -205,7 +302,7 @@ export default function App() {
}
return (
<NavigationContainer>
<NavigationContainer ref={navigationRef}>
<StatusBar style="light" />
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
@@ -261,3 +358,4 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
},
});
+16
View File
@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle
+195
View File
@@ -0,0 +1,195 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply plugin: "com.google.gms.google-services"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.anonymous.bulwarkmobile'
defaultConfig {
applicationId 'com.anonymous.bulwarkmobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
if (project.hasProperty('BULWARK_RELEASE_STORE_FILE')
&& project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) {
release {
storeFile file(project.property('BULWARK_RELEASE_STORE_FILE'))
storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD')
keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS')
keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD')
}
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
signingConfig signingConfigs.findByName('release') ?: signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
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.
// 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")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
{
"project_info": {
"project_number": "504486613721",
"project_id": "bulwark-mobile",
"storage_bucket": "bulwark-mobile.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
"android_client_info": {
"package_name": "com.anonymous.bulwarkmobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
"android_client_info": {
"package_name": "com.bulwark.bulwarkmobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+14
View File
@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
+34
View File
@@ -0,0 +1,34 @@
<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.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.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service android:name=".BulwarkMessagingService" android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
</application>
</manifest>
@@ -0,0 +1,179 @@
package com.anonymous.bulwarkmobile
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import androidx.core.app.NotificationCompat
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 com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
import com.google.firebase.messaging.FirebaseMessaging
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
class BulwarkFcmModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
init {
synchronized(BulwarkFcmModule::class.java) {
currentInstance = this
}
BulwarkMessagingService.ensureChannel(reactContext)
}
override fun getName(): String = "BulwarkFcm"
@ReactMethod
fun getToken(promise: Promise) {
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token -> promise.resolve(token) }
.addOnFailureListener { err -> promise.reject("fcm_token_failed", err) }
}
@ReactMethod
fun deleteToken(promise: Promise) {
FirebaseMessaging.getInstance().deleteToken()
.addOnSuccessListener { promise.resolve(null) }
.addOnFailureListener { err -> promise.reject("fcm_delete_failed", err) }
}
@ReactMethod
fun showNotification(options: ReadableMap, promise: Promise) {
val notificationId = options.getString("notificationId")
if (notificationId.isNullOrBlank()) {
promise.reject("bad_args", "notificationId is required")
return
}
val title = options.getString("title") ?: "New mail"
val body = options.getString("body") ?: ""
val initials = options.getString("initials") ?: "?"
val bgColorHex = options.getString("bgColorHex") ?: "#2563eb"
val iconUrl = options.takeIf { it.hasKey("iconUrl") }?.getString("iconUrl")
val emailId = options.getString("emailId")
val threadId = options.getString("threadId")
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
thread(name = "bulwark-notification") {
val largeIcon = iconUrl?.let { fetchBitmap(it) }
?: makeLetterAvatar(initials, bgColorHex)
postNotification(
notificationId, title, body, largeIcon, bgColorHex,
emailId, threadId, subject,
)
promise.resolve(null)
}
}
@ReactMethod
fun getInitialNotification(promise: Promise) {
val payload = NotificationTapStore.consume()
promise.resolve(payload?.toMap())
}
private fun postNotification(
notificationId: String,
title: String,
body: String,
largeIcon: Bitmap,
colorHex: String,
emailId: String?,
threadId: String?,
subject: String?,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
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)
}
val pending = PendingIntent.getActivity(
ctx,
notificationId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_email)
.setLargeIcon(largeIcon)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pending)
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notificationId.hashCode(), builder.build())
}
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
val size = 192
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = parseColor(bgHex, fallback = Color.parseColor("#2563eb"))
}
canvas.drawCircle(size / 2f, size / 2f, size / 2f, bgPaint)
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = size * 0.44f
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
val baseline = size / 2f - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials.take(2).uppercase(), size / 2f, baseline, textPaint)
return bitmap
}
private fun fetchBitmap(url: String): Bitmap? = try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 3000
conn.readTimeout = 3000
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", "bulwark-mobile")
conn.inputStream.use { BitmapFactory.decodeStream(it) }
} catch (_: Exception) {
null
}
private fun parseColor(hex: String, fallback: Int): Int = try {
Color.parseColor(hex)
} catch (_: IllegalArgumentException) {
fallback
}
// NativeEventEmitter required no-ops.
@ReactMethod
fun addListener(eventName: String) {}
@ReactMethod
fun removeListeners(count: Int) {}
companion object {
@Volatile private var currentInstance: BulwarkFcmModule? = null
fun emit(eventName: String, params: WritableMap?) {
val module = currentInstance ?: return
val ctx = module.reactApplicationContext
if (!ctx.hasActiveReactInstance()) return
ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params)
}
}
}
@@ -0,0 +1,14 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(BulwarkFcmModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
@@ -0,0 +1,80 @@
package com.anonymous.bulwarkmobile
import android.app.ActivityManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.core.app.NotificationCompat
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class BulwarkMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
val params = Arguments.createMap().apply { putString("token", token) }
BulwarkFcmModule.emit("fcm:newToken", params)
}
override fun onMessageReceived(message: RemoteMessage) {
ensureChannel(this)
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
// 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.
if (!isAppInForeground()) {
startHeadlessTask(data)
}
val params = Arguments.createMap().apply {
val dataMap = Arguments.createMap()
for ((k, v) in data) dataMap.putString(k, v)
putMap("data", dataMap)
}
BulwarkFcmModule.emit("fcm:message", params)
}
private fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
private fun startHeadlessTask(data: Map<String, String>) {
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
applicationContext.startService(intent)
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
}
companion object {
const val CHANNEL_ID = "bulwark_mail"
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Mail notifications",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Incoming email alerts"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
}
}
@@ -0,0 +1,18 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.HeadlessJsTaskService
class BulwarkPushTaskService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
return HeadlessJsTaskConfig(
"BulwarkPushTask",
Arguments.fromBundle(extras),
30000L,
false,
)
}
}
@@ -0,0 +1,70 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
NotificationTapStore.captureFromIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}
@@ -0,0 +1,56 @@
package com.anonymous.bulwarkmobile
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): MutableList<ReactPackage> {
val packages: MutableList<ReactPackage> = PackageList(this).packages
packages.add(BulwarkFcmPackage())
return packages
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}
@@ -0,0 +1,49 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
// Process-wide holder for the most recent notification tap. MainActivity fills
// this on onCreate / onNewIntent; JS drains it via BulwarkFcm.getInitialNotification
// or reacts to the live "fcm:notificationTap" event.
object NotificationTapStore {
@Volatile
private var pending: TapPayload? = null
fun consume(): TapPayload? {
val value = pending
pending = null
return value
}
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)
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)
return payload
}
data class TapPayload(
val emailId: String,
val threadId: String,
val subject: String?,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
}
}
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
const val EXTRA_SUBJECT = "bulwark.notification.subject"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<resources/>
@@ -0,0 +1,6 @@
<resources>
<color name="splashscreen_background">#09090b</color>
<color name="iconBackground">#09090b</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#09090b</color>
</resources>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Bulwark Mobile</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
</resources>
@@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#09090b</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>
+25
View File
@@ -0,0 +1,25 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
classpath('com.google.gms:google-services:4.4.2')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
+65
View File
@@ -0,0 +1,65 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
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
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+39
View File
@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'bulwark-mobile'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"expo": {
"name": "bulwark-mobile",
"name": "Bulwark Mobile",
"slug": "bulwark-mobile",
"version": "1.0.0",
"orientation": "portrait",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+7
View File
@@ -1,6 +1,13 @@
import { AppRegistry } from 'react-native';
import { registerRootComponent } from 'expo';
import App from './App';
import { pushBackgroundTask } from './src/lib/push-background-task';
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
// from BulwarkMessagingService on an FCM data message. Must be registered
// before the native service tries to invoke the task.
AppRegistry.registerHeadlessTask('BulwarkPushTask', () => pushBackgroundTask);
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
+14 -14
View File
@@ -5189,6 +5189,20 @@
}
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
"license": "MIT",
"dependencies": {
"@expo/config": "~12.0.13",
"@expo/env": "~2.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -5651,20 +5665,6 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
"license": "MIT",
"dependencies": {
"@expo/config": "~12.0.13",
"@expo/env": "~2.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-font": {
"version": "14.0.11",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
+48
View File
@@ -0,0 +1,48 @@
import { readdir, stat } from 'node:fs/promises';
import { watchFile } from 'node:fs';
import { join, extname } from 'node:path';
const ROOTS = ['src', 'App.tsx', 'index.ts', 'app.json'];
const EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.json']);
const INTERVAL = 500;
const METRO = 'http://localhost:8081/reload';
async function* walk(p) {
const s = await stat(p).catch(() => null);
if (!s) return;
if (s.isFile()) { yield p; return; }
for (const e of await readdir(p, { withFileTypes: true })) {
const f = join(p, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
yield* walk(f);
} else if (e.isFile() && EXTS.has(extname(e.name))) {
yield f;
}
}
}
let pending = false;
async function reload(file) {
if (pending) return;
pending = true;
try {
console.log(`change: ${file}`);
const r = await fetch(METRO, { method: 'GET' });
console.log(`reload -> ${r.status}`);
} catch (e) {
console.log(`reload failed: ${e.message}`);
}
setTimeout(() => { pending = false; }, 500);
}
const files = [];
for (const r of ROOTS) {
for await (const f of walk(r)) files.push(f);
}
console.log(`watching ${files.length} files (poll ${INTERVAL}ms)`);
for (const f of files) {
watchFile(f, { interval: INTERVAL }, (curr, prev) => {
if (curr.mtimeMs !== prev.mtimeMs) reload(f);
});
}
+20 -1
View File
@@ -1,6 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
import { toWildcardQuery } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
@@ -131,7 +132,7 @@ export async function searchEmails(
limit = 30,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = { text: query };
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
if (mailboxId) filter.inMailbox = mailboxId;
const res = await jmapClient.request([
@@ -145,6 +146,24 @@ export async function searchEmails(
return res.methodResponses[0][1].ids;
}
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
// to search "from OR to <addresses>" without picking a specific mailbox).
export async function queryEmailsByFilter(
filter: Record<string, unknown>,
limit = 5,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
]);
return res.methodResponses[0][1].ids;
}
export async function sendEmail(
email: {
from: EmailAddress[];
+84 -1
View File
@@ -1,9 +1,92 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { StateChange } from './types';
import type { PushSubscription, StateChange } from './types';
export type StateChangeHandler = (change: StateChange) => void;
// ─── PushSubscription (RFC 8620 §7.2) ───────────────────
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
const res = await jmapClient.request(
[['PushSubscription/get', { ids: null }, '0']],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
return (body?.list as PushSubscription[]) ?? [];
}
/**
* Create a PushSubscription pointing the JMAP server at the given relay URL.
* Returns the server-assigned id (which the client also registers with the
* relay so the relay can route incoming pushes to an Expo token).
*/
export async function createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
}): Promise<string> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{
create: {
new: {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
},
},
},
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
const created = body?.created?.new as { id?: string } | undefined;
if (!created?.id) {
const notCreated = body?.notCreated?.new;
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
);
}
return created.id;
}
/**
* 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(
id: string,
verificationCode: string,
): Promise<void> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{ update: { [id]: { verificationCode } } },
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
if (body?.notUpdated?.[id]) {
throw new Error(
`PushSubscription verification failed: ${JSON.stringify(body.notUpdated[id])}`,
);
}
}
export async function destroyPushSubscription(id: string): Promise<void> {
await jmapClient.request(
[['PushSubscription/set', { destroy: [id] }, '0']],
[CAPABILITIES.CORE],
);
}
/**
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
* Returns a cleanup function to close the connection.
+12
View File
@@ -418,6 +418,18 @@ export interface StateChange {
changed: Record<string, Record<string, string>>;
}
// RFC 8620 §7.2. A PushSubscription is session-scoped (not per-account) and
// tells the server where to deliver StateChange events for the listed types.
export interface PushSubscription {
id: string;
deviceClientId: string;
url: string;
keys?: { p256dh: string; auth: string } | null;
verificationCode?: string | null;
expires?: string | null;
types?: string[] | null;
}
// ─── Capability URNs ────────────────────────────────────
export const CAPABILITIES = {
+67
View File
@@ -0,0 +1,67 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
export interface VacationResponse {
id: string;
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
}
const VACATION_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.VACATION];
const DEFAULT: VacationResponse = {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
};
export async function getVacationResponse(): Promise<VacationResponse> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['VacationResponse/get', { accountId, ids: ['singleton'] }, '0']],
VACATION_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'VacationResponse/get') {
const list = (resp[1] as { list?: VacationResponse[] }).list ?? [];
return list[0] ?? DEFAULT;
}
throw new Error('Unexpected response from VacationResponse/get');
}
export async function setVacationResponse(
updates: Partial<Omit<VacationResponse, 'id'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['VacationResponse/set', {
accountId,
update: { singleton: updates },
}, '0']],
VACATION_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'VacationResponse/set') {
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
if (result.notUpdated?.singleton) {
throw new Error(result.notUpdated.singleton.description ?? 'Failed to update vacation responder');
}
return;
}
throw new Error('Unexpected response from VacationResponse/set');
}
export function isVacationSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
const caps = session.capabilities ?? {};
return CAPABILITIES.VACATION in caps;
}
+11 -3
View File
@@ -54,9 +54,17 @@ export function AgendaView({
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) => getEventStartDate(a).getTime() - getEventStartDate(b).getTime(),
);
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({
title: formatDayHeader(day),
+68 -23
View File
@@ -11,9 +11,14 @@ import type { Calendar, CalendarEvent } from '../../api/types';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import {
buildEventDayIndex,
buildTimedFullDayWeekSegments,
buildWeekSegmentsRaw,
eventsOnDayFromIndex,
getEventColor,
isTimedEventFullDayOnDate,
layoutOverlappingEvents,
packWeekSegments,
type CalendarWeekSegment,
type EventDayIndex,
} from '../../lib/calendar-utils';
@@ -56,25 +61,38 @@ export function WeekView({
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStartsOn]);
const allDayByDay = React.useMemo(() => {
return weekDays.map((day) =>
eventsOnDayFromIndex(index, day).filter((e) => e.showWithoutTime),
// Multi-day all-day events render as one bar across the days they span.
// Timed events that consume the entire day get promoted to this strip too.
const allDaySegments = React.useMemo<CalendarWeekSegment[]>(() => {
const explicit = buildWeekSegmentsRaw(
events.filter((e) => e.showWithoutTime),
weekDays,
);
}, [weekDays, index]);
const timedFull = buildTimedFullDayWeekSegments(
events.filter((e) => !e.showWithoutTime),
weekDays,
);
return packWeekSegments([...explicit, ...timedFull]);
}, [events, weekDays]);
const allDayRowCount = React.useMemo(() => {
return allDayByDay.reduce((max, list) => Math.max(max, list.length), 0);
}, [allDayByDay]);
return allDaySegments.reduce((max, s) => Math.max(max, s.row + 1), 0);
}, [allDaySegments]);
const allDayStripHeight =
allDayRowCount > 0
? 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
// already promoted to the all-day strip above.
const layoutsByDay = React.useMemo(() => {
return weekDays.map((day) =>
layoutOverlappingEvents(eventsOnDayFromIndex(index, day), day),
);
return weekDays.map((day) => {
const dayEvents = eventsOnDayFromIndex(index, day).filter(
(e) => !e.showWithoutTime && !isTimedEventFullDayOnDate(e, day),
);
return layoutOverlappingEvents(dayEvents, day);
});
}, [weekDays, index]);
const [nowMinutes, setNowMinutes] = React.useState(() => {
@@ -148,25 +166,45 @@ export function WeekView({
<Text style={styles.allDayLabel}>all-day</Text>
</View>
<View style={styles.allDayGrid}>
{allDayByDay.map((dayEvents, idx) => (
<View key={idx} style={styles.allDayCol}>
{dayEvents.map((event) => (
{/* Empty per-day columns to draw vertical separators */}
{weekDays.map((day) => (
<View key={day.toISOString()} style={styles.allDayCol} />
))}
{/* 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);
const leftPct = (segment.startIndex / weekDays.length) * 100;
const widthPct = (segment.span / weekDays.length) * 100;
return (
<Pressable
key={event.id}
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
onPress={() => onSelectEvent?.(segment.event)}
style={[
styles.allDayChip,
{ backgroundColor: getEventColor(event, calendars) + '33' },
{ borderLeftColor: getEventColor(event, calendars) },
{
position: 'absolute',
left: `${leftPct}%`,
width: `${widthPct}%`,
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
backgroundColor: color + '33',
borderLeftColor: color,
borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
marginHorizontal: 1,
},
]}
onPress={() => onSelectEvent?.(event)}
>
<Text style={styles.allDayChipText} numberOfLines={1}>
{event.title || 'Untitled'}
{segment.continuesBefore ? '… ' : ''}
{segment.event.title || 'Untitled'}
</Text>
</Pressable>
))}
</View>
))}
);
})}
</View>
</View>
</View>
)}
@@ -316,11 +354,18 @@ const styles = StyleSheet.create({
},
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4 },
allDayLabel: { ...typography.small, color: colors.textMuted },
allDayGrid: { flex: 1, flexDirection: 'row' },
allDayGrid: { flex: 1, flexDirection: 'row', position: 'relative' },
allDayCol: {
flex: 1,
paddingHorizontal: 1,
gap: ALL_DAY_GAP,
borderLeftWidth: 1,
borderLeftColor: colors.borderLight,
},
allDayOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
allDayChip: {
height: ALL_DAY_CHIP_HEIGHT,
+258
View File
@@ -0,0 +1,258 @@
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
import { format, isAfter, parseISO } from 'date-fns';
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 SenderAvatar from '../SenderAvatar';
import { colors, radius, spacing, typography } from '../../theme/tokens';
const EMAIL_LIMIT = 5;
const EVENT_LIMIT = 5;
type Nav = NativeStackNavigationProp<RootStackParamList>;
function getContactEmails(contact: ContactCard): string[] {
if (!contact.emails) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const e of Object.values(contact.emails)) {
const addr = e.address?.trim().toLowerCase();
if (addr && !seen.has(addr)) {
seen.add(addr);
out.push(addr);
}
}
return out;
}
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
for (const addr of addresses) {
conditions.push({ from: addr });
conditions.push({ to: addr });
}
if (conditions.length === 1) return conditions[0];
return { operator: 'OR', conditions };
}
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
if (event.participants) {
for (const p of Object.values(event.participants)) {
const addr = p.email?.trim().toLowerCase();
if (addr && addresses.has(addr)) return true;
if (p.sendTo) {
for (const target of Object.values(p.sendTo)) {
const m = typeof target === 'string' ? target.match(/mailto:(.+)/i) : null;
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
}
}
}
}
return false;
}
function formatRelativeDate(iso: string): string {
try {
const d = parseISO(iso);
const now = new Date();
return d.getFullYear() === now.getFullYear()
? format(d, 'MMM d')
: format(d, 'MMM d, yyyy');
} catch {
return '';
}
}
function formatEventTime(event: CalendarEvent): string {
if (event.showWithoutTime) return 'All day';
try {
return format(parseISO(event.start), 'HH:mm');
} catch {
return '';
}
}
interface Props {
contact: ContactCard;
}
export function ContactActivity({ contact }: Props) {
const navigation = useNavigation<Nav>();
const calendarEvents = useCalendarStore((s) => s.events);
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
const addressKey = addresses.join(',');
const [emails, setEmails] = React.useState<Email[] | null>(null);
const [emailsLoading, setEmailsLoading] = React.useState(false);
const [emailsError, setEmailsError] = React.useState(false);
React.useEffect(() => {
if (addresses.length === 0) {
setEmails([]);
return;
}
let cancelled = false;
setEmailsLoading(true);
setEmailsError(false);
(async () => {
try {
const ids = await queryEmailsByFilter(buildEmailFilter(addresses), EMAIL_LIMIT);
const fetched = ids.length > 0 ? await getEmails(ids) : [];
if (cancelled) return;
setEmails(fetched);
} catch {
if (cancelled) return;
setEmailsError(true);
setEmails([]);
} finally {
if (!cancelled) setEmailsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
const upcomingEvents = React.useMemo(() => {
if (addresses.length === 0) return [];
const addrSet = new Set(addresses);
const now = new Date();
return calendarEvents
.filter((e) => eventInvolvesContact(e, addrSet))
.filter((e) => {
try {
return isAfter(parseISO(e.utcStart || e.start), now);
} catch {
return false;
}
})
.sort((a, b) => (a.utcStart || a.start).localeCompare(b.utcStart || b.start))
.slice(0, EVENT_LIMIT);
}, [calendarEvents, addresses]);
if (addresses.length === 0) return null;
return (
<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>
) : 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>
</Pressable>
);
})
)}
</View>
<View style={styles.section}>
<View style={styles.sectionHeader}>
<CalendarDays size={14} color={colors.primary} />
<Text style={styles.sectionTitle}>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>
</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,
},
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
sectionTitle: { ...typography.bodySemibold, color: colors.text },
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
emptyText: { ...typography.caption, color: colors.textMuted },
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingVertical: 6,
borderRadius: radius.xs,
},
rowPressed: { backgroundColor: colors.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 },
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
eventTime: {
...typography.caption,
color: colors.textMuted,
width: 64,
},
eventInfo: { flex: 1, minWidth: 0 },
eventTitle: { ...typography.body, color: colors.text },
eventDate: { ...typography.caption, color: colors.textMuted, marginTop: 2 },
});
@@ -0,0 +1,195 @@
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 { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Session {
id: string;
device: string;
location: string;
current: boolean;
}
const MOCK_SESSIONS: Session[] = [
{ id: '1', device: 'iPhone 15 Pro — Bulwark Mobile', location: 'Berlin, DE', current: true },
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
];
export function AccountSecuritySettings() {
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 }[]>([]);
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>
</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>
)}
</SettingsSection>
<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
</Button>
</View>
</SettingsSection>
<SettingsSection title="Active Sessions" description="Devices currently signed into your account.">
{MOCK_SESSIONS.map((s) => (
<View key={s.id} style={styles.sessionRow}>
<View style={styles.sessionIcon}>
<Monitor size={18} color={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>
)}
</View>
))}
</SettingsSection>
</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,
},
});
+139
View File
@@ -0,0 +1,139 @@
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 { useAuthStore } from '../../stores/auth-store';
import { useAccountStore } from '../../stores/account-store';
interface AccountSettingsProps {
displayName?: string;
email?: string;
username?: string;
serverUrl?: string;
authMode?: 'oauth' | 'basic';
quotaUsed?: number;
quotaTotal?: number;
isDemoMode?: boolean;
}
function formatFileSize(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
export function AccountSettings(props: AccountSettingsProps = {}) {
const authUsername = useAuthStore((s) => s.username);
const authServerUrl = useAuthStore((s) => s.serverUrl);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const account = useAccountStore((s) =>
activeAccountId ? s.accounts.find((a) => a.id === activeAccountId) : undefined,
);
const displayName = props.displayName ?? account?.displayName ?? authUsername ?? 'Unknown';
const email = props.email ?? account?.email ?? authUsername ?? '';
const username = props.username ?? account?.username ?? authUsername ?? undefined;
const serverUrl = props.serverUrl ?? account?.serverUrl ?? authServerUrl ?? '';
const authMode = props.authMode ?? 'basic';
const quotaUsed = props.quotaUsed ?? 0;
const quotaTotal = props.quotaTotal ?? 0;
const isDemoMode = props.isDemoMode ?? false;
const percent = quotaTotal ? Math.round((quotaUsed / quotaTotal) * 100) : 0;
return (
<SettingsSection title="Account" description="Your account information.">
<SettingItem label="Display Name">
<Text style={styles.value}>{displayName}</Text>
</SettingItem>
<SettingItem label="Email Address">
<Text style={styles.value}>{email}</Text>
</SettingItem>
{username && username !== email && (
<SettingItem label="Username">
<Text style={styles.value}>{username}</Text>
</SettingItem>
)}
<SettingItem label="Authentication">
<Text style={styles.value}>
{authMode === 'oauth' ? 'OAuth' : 'Basic'}
</Text>
</SettingItem>
<SettingItem label="Server">
<Text style={styles.value} numberOfLines={1}>{serverUrl}</Text>
</SettingItem>
{quotaTotal > 0 && (
<SettingItem
label="Storage"
description={`${formatFileSize(quotaUsed)} of ${formatFileSize(quotaTotal)} used`}
>
<View style={styles.storage}>
<Text style={styles.value}>{percent}%</Text>
<View style={styles.bar}>
<View style={[styles.barFill, { width: `${percent}%` }]} />
</View>
</View>
</SettingItem>
)}
{isDemoMode && (
<SettingItem label="Account Type">
<View style={styles.demoRow}>
<View style={styles.demoDot} />
<Text style={styles.demoText}>Demo account</Text>
</View>
</SettingItem>
)}
</SettingsSection>
);
}
const styles = StyleSheet.create({
value: {
...typography.body,
color: colors.text,
maxWidth: 240,
},
storage: {
alignItems: 'flex-end',
gap: 6,
},
bar: {
width: 128,
height: 8,
backgroundColor: colors.muted,
borderRadius: radius.full,
overflow: 'hidden',
},
barFill: {
height: '100%',
backgroundColor: colors.primary,
borderRadius: radius.full,
},
demoRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
demoDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#f59e0b',
},
demoText: {
...typography.bodyMedium,
color: '#fbbf24',
},
});
@@ -0,0 +1,168 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
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 = '0.0.0';
const GIT_COMMIT = 'unknown';
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,
},
});
@@ -0,0 +1,233 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { PlayCircle } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
type Theme = 'light' | 'dark' | 'system';
type FontSize = 'small' | 'medium' | 'large';
type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
type ToolbarPosition = 'top' | 'below-subject';
const DENSITY_PREVIEW: Record<Density, { py: number; gap: number; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 2, gap: 6, showAvatar: false, showPreview: false },
compact: { py: 4, gap: 8, showAvatar: true, showPreview: false },
regular: { py: 10, gap: 12, showAvatar: true, showPreview: true },
comfortable: { py: 16, gap: 16, showAvatar: true, showPreview: true },
};
const PREVIEW_ROWS = [
{ unread: true, sender: 'Alice Johnson', subject: 'Project update - Q1 roadmap', preview: 'Here are the latest numbers from…' },
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
];
function DensityPreview({ density }: { density: Density }) {
const cfg = DENSITY_PREVIEW[density];
return (
<View style={styles.densityPreview}>
{PREVIEW_ROWS.map((row, i) => (
<View
key={i}
style={[
styles.densityRow,
{
paddingVertical: cfg.py,
gap: cfg.gap,
borderBottomWidth: i < PREVIEW_ROWS.length - 1 ? 1 : 0,
},
]}
>
{cfg.showAvatar && (
<View style={[
styles.densityAvatar,
density === 'comfortable' ? { width: 32, height: 32 } : { width: 24, height: 24 },
]} />
)}
<View style={{ flex: 1 }}>
<View style={styles.densityHeader}>
<Text
style={[
styles.densitySender,
row.unread ? { color: colors.text, fontWeight: '600' } : { color: colors.mutedForeground },
]}
numberOfLines={1}
>
{row.sender}
</Text>
<Text style={styles.densityTime}>12:00</Text>
</View>
<Text
style={[
styles.densitySubject,
row.unread ? { color: colors.text, fontWeight: '500' } : { color: 'rgba(250,250,250,0.8)' },
]}
numberOfLines={1}
>
{row.subject}
</Text>
{cfg.showPreview && (
<Text style={styles.densityPreviewText} numberOfLines={1}>
{row.preview}
</Text>
)}
</View>
</View>
))}
</View>
);
}
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');
return (
<SettingsSection title="Appearance" description="Customize how the interface looks and feels.">
<SettingItem label="Theme" description="Choose between light, dark, or system theme.">
<RadioGroup
value={theme}
onChange={(v) => setTheme(v as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
]}
/>
</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}
onChange={(v) => setFontSize(v as FontSize)}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</SettingItem>
<View>
<SettingItem label="List Density" description="How tight the email list is packed." noBorder />
<RadioGroup
value={density}
onChange={(v) => setDensity(v as Density)}
options={[
{ value: 'extra-compact', label: 'Extra' },
{ value: 'compact', label: 'Compact' },
{ value: 'regular', label: 'Regular' },
{ value: 'comfortable', label: 'Comfy' },
]}
/>
<DensityPreview density={density} />
<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({
densityPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
backgroundColor: colors.background,
},
densityRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
borderBottomColor: colors.border,
},
densityAvatar: {
borderRadius: radius.full,
backgroundColor: colors.muted,
},
densityHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: spacing.sm,
},
densitySender: {
...typography.caption,
flex: 1,
},
densityTime: {
fontSize: 10,
color: colors.mutedForeground,
},
densitySubject: {
...typography.caption,
},
densityPreviewText: {
fontSize: 11,
color: 'rgba(161,161,170,0.7)',
},
divider: {
height: 1,
backgroundColor: colors.border,
marginTop: spacing.md,
},
});
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
export function CalendarSettings() {
const [viewMode, setViewMode] = useState('month');
const [firstDay, setFirstDay] = useState('1');
const [timeFormat, setTimeFormat] = useState('24h');
const [showTimeInMonth, setShowTimeInMonth] = useState(true);
const [showWeekNumbers, setShowWeekNumbers] = useState(false);
const [hoverPreview, setHoverPreview] = useState('delay-500ms');
const [birthdayCal, setBirthdayCal] = useState(true);
const [tasksEnabled, setTasksEnabled] = useState(false);
const [showTasksOnCal, setShowTasksOnCal] = useState(true);
return (
<SettingsSection title="Calendar">
<SettingItem label="Default view">
<Select
value={viewMode}
onChange={setViewMode}
options={[
{ value: 'month', label: 'Month' },
{ value: 'week', label: 'Week' },
{ value: 'day', label: 'Day' },
{ value: 'agenda', label: 'Agenda' },
]}
/>
</SettingItem>
<SettingItem label="Week starts on">
<Select
value={firstDay}
onChange={setFirstDay}
options={[
{ value: '1', label: 'Monday' },
{ value: '0', label: 'Sunday' },
]}
/>
</SettingItem>
<SettingItem label="Time format">
<RadioGroup
value={timeFormat}
onChange={setTimeFormat}
options={[
{ value: '12h', label: '12-hour' },
{ value: '24h', label: '24-hour' },
]}
/>
</SettingItem>
<SettingItem
label="Show time in month view"
description="Show start times next to events in the month grid."
>
<ToggleSwitch checked={showTimeInMonth} onChange={setShowTimeInMonth} />
</SettingItem>
<SettingItem
label="Show week numbers"
description="Display ISO week numbers in month and week views."
>
<ToggleSwitch checked={showWeekNumbers} onChange={setShowWeekNumbers} />
</SettingItem>
<SettingItem
label="Hover preview"
description="Show event details on hover or tap-and-hold."
>
<Select
value={hoverPreview}
onChange={setHoverPreview}
options={[
{ value: 'instant', label: 'Instant' },
{ value: 'delay-500ms', label: '500ms delay' },
{ value: 'delay-1s', label: '1s delay' },
{ value: 'delay-2s', label: '2s delay' },
{ value: 'off', label: 'Off' },
]}
/>
</SettingItem>
<SettingItem
label="Show birthday calendar"
description="Automatically include birthdays from your contacts."
>
<ToggleSwitch checked={birthdayCal} onChange={setBirthdayCal} />
</SettingItem>
<SettingItem
label="Enable tasks"
description="Show task management alongside events."
>
<ToggleSwitch checked={tasksEnabled} onChange={setTasksEnabled} />
</SettingItem>
{tasksEnabled && (
<SettingItem
label="Show tasks on calendar"
description="Overlay tasks with due dates on the calendar."
>
<ToggleSwitch checked={showTasksOnCal} onChange={setShowTasksOnCal} />
</SettingItem>
)}
</SettingsSection>
);
}
@@ -0,0 +1,87 @@
import React from 'react';
import { Alert, Share, Text } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Download } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { useSettingsStore } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { contactsToVCard } from '../../lib/vcard';
import { isGroup } from '../../lib/contact-utils';
import { colors, typography } from '../../theme/tokens';
export function ContactsSettings() {
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
const contacts = useContactsStore((s) => s.contacts);
const [exporting, setExporting] = React.useState(false);
const exportable = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
const exportLabel =
exportable.length === 0
? 'No contacts to export'
: `Export ${exportable.length} contact${exportable.length === 1 ? '' : 's'} as a single vCard file.`;
const handleExport = async () => {
if (exportable.length === 0 || exporting) return;
setExporting(true);
try {
const vcf = contactsToVCard(exportable);
const filename = `contacts-${new Date().toISOString().slice(0, 10)}.vcf`;
const path = `${FileSystem.cacheDirectory}${filename}`;
await FileSystem.writeAsStringAsync(path, vcf);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(path, {
mimeType: 'text/vcard',
UTI: 'public.vcard',
dialogTitle: 'Export contacts',
});
} else {
await Share.share({ message: vcf });
}
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unknown error');
} finally {
setExporting(false);
}
};
return (
<SettingsSection
title="Contacts"
description="Display preferences and address-book tools."
>
<SettingItem
label="Group by first letter"
description="Show alphabetical section headers in the contacts list."
>
<ToggleSwitch checked={groupByLetter} onChange={setGroupByLetter} />
</SettingItem>
<SettingItem
label="Export contacts"
description={exportLabel}
>
<Button
variant="outline"
size="sm"
icon={<Download size={14} color={colors.text} />}
onPress={() => { void handleExport(); }}
disabled={exportable.length === 0 || exporting}
loading={exporting}
>
Export
</Button>
</SettingItem>
<SettingItem
label="Import contacts"
description="Importing vCard files is currently available on the web client only."
>
<Text style={[typography.caption, { color: colors.textMuted }]}>Web only</Text>
</SettingItem>
</SettingsSection>
);
}
+529
View File
@@ -0,0 +1,529 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
type MailLayout = 'split' | 'focus';
type DeleteAction = 'trash' | 'permanent';
type ArchiveMode = 'single' | 'year' | 'month';
type AttachmentAction = 'preview' | 'download';
type AttachmentPosition = 'beside-sender' | 'below-header';
const DEFAULT_KEYWORDS = ['attached', 'attachment', 'enclosed', 'attaching'];
function MailLayoutPreview({ value }: { value: MailLayout }) {
const isSplit = value === 'split';
return (
<View style={styles.layoutPreview}>
<View>
<Text style={styles.layoutTitle}>{isSplit ? 'Split' : 'Focus'}</Text>
<Text style={styles.layoutDesc}>
{isSplit ? 'List and reader side by side.' : 'Single column for focused reading.'}
</Text>
</View>
<View style={styles.layoutFrame}>
<View style={styles.layoutRail} />
{isSplit ? (
<>
<View style={styles.layoutList}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.layoutRow, i === 2 && styles.layoutRowSel]}
>
<Text style={styles.layoutRowName}>Alice</Text>
<Text style={styles.layoutRowSub}>Subject line</Text>
</View>
))}
</View>
<View style={styles.layoutReader}>
<View style={[styles.layoutBar, { width: 80 }]} />
<View style={[styles.layoutBar, { width: '100%', marginTop: 8 }]} />
<View style={[styles.layoutBar, { width: '85%', marginTop: 6 }]} />
<View style={[styles.layoutBar, { width: '60%', marginTop: 6 }]} />
</View>
</>
) : (
<View style={{ flex: 1, padding: 8 }}>
{[1, 2, 3].map((i) => (
<View
key={i}
style={[styles.layoutFocus, i === 2 && styles.layoutFocusSel]}
>
<Text style={styles.layoutFocusText}>
<Text style={{ fontWeight: '500' }}>Alice </Text>
Subject line preview…
</Text>
</View>
))}
</View>
)}
</View>
</View>
);
}
export function EmailSettings() {
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const setExternalContentPolicyStore = useSettingsStore((s) => s.setExternalContentPolicy);
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const [markAsReadDelay, setMarkAsReadDelay] = useState('0');
const [deleteAction, setDeleteAction] = useState<DeleteAction>('trash');
const [permanentlyDeleteJunk, setPermanentlyDeleteJunk] = useState(false);
const [showPreview, setShowPreview] = useState(true);
const [mailLayout, setMailLayout] = useState<MailLayout>('split');
const [disableThreading, setDisableThreading] = useState(false);
const [autoSelectReplyIdentity, setAutoSelectReplyIdentity] = useState(true);
const [plainTextMode, setPlainTextMode] = useState(false);
const [emailsPerPage, setEmailsPerPage] = useState('25');
const [mailAttachmentAction, setMailAttachmentAction] = useState<AttachmentAction>('preview');
const [attachmentPosition, setAttachmentPosition] = useState<AttachmentPosition>('beside-sender');
const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false);
const [archiveMode, setArchiveMode] = useState<ArchiveMode>('single');
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(DEFAULT_KEYWORDS);
const [newKeyword, setNewKeyword] = useState('');
const [hideInlineImageAttachments, setHideInlineImageAttachments] = useState(true);
const [trustedSendersAddressBook, setTrustedSendersAddressBook] = useState(false);
const [showTrustedList, setShowTrustedList] = useState(false);
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
return (
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
<SettingItem label="Mark as Read" description="When to flag an email as read.">
<Select
value={markAsReadDelay}
onChange={setMarkAsReadDelay}
options={[
{ value: '0', label: 'Instant' },
{ value: '3000', label: 'After 3s' },
{ value: '5000', label: 'After 5s' },
{ value: '-1', label: 'Never' },
]}
/>
</SettingItem>
<View style={styles.group}>
<SettingItem label="Delete Action" description="Where deleted emails go." noBorder />
<Select
value={deleteAction}
onChange={(v) => setDeleteAction(v as DeleteAction)}
options={[
{ value: 'trash', label: 'Move to Trash' },
{ value: 'permanent', label: 'Permanently delete' },
]}
/>
{deleteAction === 'permanent' && (
<View style={styles.warning}>
<AlertTriangle size={14} color={colors.error} />
<Text style={styles.warningText}>Permanent deletion cannot be undone.</Text>
</View>
)}
<View style={styles.divider} />
</View>
<View style={styles.group}>
<SettingItem label="Archive Mode" description="Organize archive into subfolders." noBorder />
<Select
value={archiveMode}
onChange={(v) => setArchiveMode(v as ArchiveMode)}
options={[
{ value: 'single', label: 'Single folder' },
{ value: 'year', label: 'By year' },
{ value: 'month', label: 'By year/month' },
]}
/>
{archiveMode !== 'single' && (
<Pressable style={styles.inlineBtn}>
<FolderSync size={14} color={colors.text} />
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
</Pressable>
)}
<View style={styles.divider} />
</View>
<SettingItem label="Permanently Delete Junk" description="Skip the trash when deleting spam.">
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={setPermanentlyDeleteJunk} />
</SettingItem>
<View style={styles.group}>
<SettingItem label="Mail Layout" description="Choose between split view and focus view." noBorder />
<RadioGroup
value={mailLayout}
onChange={(v) => setMailLayout(v as MailLayout)}
options={[
{ value: 'split', label: 'Split' },
{ value: 'focus', label: 'Focus' },
]}
/>
<MailLayoutPreview value={mailLayout} />
<View style={styles.divider} />
</View>
<SettingItem label="Show Preview" description="Preview text under each subject.">
<ToggleSwitch checked={showPreview} onChange={setShowPreview} />
</SettingItem>
<SettingItem label="Disable Thread Grouping" description="Show emails individually instead of threaded.">
<ToggleSwitch checked={disableThreading} onChange={setDisableThreading} />
</SettingItem>
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
<ToggleSwitch checked={plainTextMode} onChange={setPlainTextMode} />
</SettingItem>
<SettingItem label="Auto-select Reply Identity" description="Pick the best identity when replying.">
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<SettingItem label="Attachment Reminder" description="Warn when the word 'attached' is present but no file is attached.">
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={setAttachmentReminderEnabled}
/>
</SettingItem>
{attachmentReminderEnabled && (
<View style={styles.group}>
<Text style={styles.subLabel}>Trigger Keywords</Text>
<Text style={styles.subDesc}>Add words that should trigger the reminder.</Text>
<View style={styles.chipRow}>
{attachmentReminderKeywords.map((kw) => (
<View key={kw} style={styles.chip}>
<Text style={styles.chipText}>{kw}</Text>
<Pressable
onPress={() =>
setAttachmentReminderKeywords((list) => list.filter((k) => k !== kw))
}
hitSlop={6}
>
<X size={12} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
<View style={styles.addKeywordRow}>
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="Add a keyword"
placeholderTextColor={colors.mutedForeground}
style={styles.keywordInput}
onSubmitEditing={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
/>
<Pressable
style={styles.addKeywordBtn}
onPress={() => {
const t = newKeyword.trim().toLowerCase();
if (t && !attachmentReminderKeywords.includes(t)) {
setAttachmentReminderKeywords([...attachmentReminderKeywords, t]);
}
setNewKeyword('');
}}
>
<Text style={styles.addKeywordText}>Add</Text>
</Pressable>
</View>
<View style={styles.divider} />
</View>
)}
<SettingItem label="Hide Inline Image Attachments" description="Don't list inline images in the attachment list.">
<ToggleSwitch checked={hideInlineImageAttachments} onChange={setHideInlineImageAttachments} />
</SettingItem>
<SettingItem label="Attachment Click Action" description="What happens when you tap an attachment.">
<Select
value={mailAttachmentAction}
onChange={(v) => setMailAttachmentAction(v as AttachmentAction)}
options={[
{ value: 'preview', label: 'Preview' },
{ value: 'download', label: 'Download' },
]}
/>
</SettingItem>
<SettingItem label="Attachment Position" description="Where attachments appear in messages.">
<Select
value={attachmentPosition}
onChange={(v) => setAttachmentPosition(v as AttachmentPosition)}
options={[
{ value: 'beside-sender', label: 'Beside sender' },
{ value: 'below-header', label: 'Below header' },
]}
/>
</SettingItem>
<SettingItem label="Emails Per Page" description="How many emails to load at a time.">
<Select
value={emailsPerPage}
onChange={setEmailsPerPage}
options={[
{ value: '10', label: '10' },
{ value: '25', label: '25' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
]}
/>
</SettingItem>
<SettingItem label="Always Light Mode for Emails" description="Render email content on a white background.">
<ToggleSwitch checked={emailAlwaysLightMode} onChange={setEmailAlwaysLightMode} />
</SettingItem>
<SettingItem label="External Content" description="How to handle images and remote resources in incoming email.">
<Select
value={externalContentPolicy}
onChange={(v) => setExternalContentPolicyStore(v as ExternalContentPolicy)}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'block', label: 'Block' },
{ value: 'allow', label: 'Allow' },
]}
/>
</SettingItem>
<View style={styles.group}>
<SettingItem
label="Trusted Senders"
description="Senders whose external content loads without asking."
noBorder
>
<Pressable style={styles.inlineBtn} onPress={() => setShowTrustedList((v) => !v)}>
<Text style={styles.inlineBtnText}>
{trustedSenders.length === 0
? 'No trusted senders'
: `${trustedSenders.length} sender${trustedSenders.length === 1 ? '' : 's'}`}
</Text>
</Pressable>
</SettingItem>
{showTrustedList && trustedSenders.length > 0 && (
<View style={styles.trustedList}>
{trustedSenders.map((email) => (
<View key={email} style={styles.trustedRow}>
<Text style={styles.trustedEmail} numberOfLines={1}>{email}</Text>
<Pressable onPress={() => removeTrustedSender(email)} hitSlop={8}>
<X size={14} color={colors.mutedForeground} />
</Pressable>
</View>
))}
</View>
)}
<View style={styles.divider} />
</View>
<SettingItem label="Sync Trusted Senders" description="Store trusted senders in the address book.">
<ToggleSwitch
checked={trustedSendersAddressBook}
onChange={setTrustedSendersAddressBook}
/>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
group: {},
warning: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
backgroundColor: 'rgba(239,68,68,0.1)',
borderRadius: radius.sm,
marginTop: spacing.sm,
},
warningText: {
...typography.caption,
color: colors.error,
flex: 1,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginTop: spacing.md,
},
inlineBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
backgroundColor: colors.muted,
borderRadius: radius.sm,
alignSelf: 'flex-start',
marginTop: spacing.sm,
},
inlineBtnText: {
...typography.body,
color: colors.text,
},
subLabel: {
...typography.bodyMedium,
color: colors.text,
marginTop: spacing.sm,
},
subDesc: {
...typography.caption,
color: colors.mutedForeground,
marginTop: 2,
marginBottom: spacing.sm,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.muted,
},
chipText: {
...typography.caption,
color: colors.text,
},
addKeywordRow: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.sm,
},
keywordInput: {
flex: 1,
height: 32,
paddingHorizontal: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
backgroundColor: colors.background,
color: colors.text,
...typography.body,
},
addKeywordBtn: {
paddingHorizontal: spacing.md,
justifyContent: 'center',
backgroundColor: colors.muted,
borderRadius: radius.sm,
},
addKeywordText: {
...typography.body,
color: colors.text,
},
layoutPreview: {
marginTop: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
padding: spacing.md,
},
layoutTitle: {
...typography.bodyMedium,
color: colors.text,
},
layoutDesc: {
...typography.caption,
color: colors.mutedForeground,
marginTop: 2,
},
layoutFrame: {
flexDirection: 'row',
height: 112,
marginTop: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
backgroundColor: 'rgba(24,24,27,0.4)',
},
layoutRail: {
width: 32,
borderRightWidth: 1,
borderRightColor: colors.border,
backgroundColor: 'rgba(24,24,27,0.6)',
},
layoutList: {
width: 96,
borderRightWidth: 1,
borderRightColor: colors.border,
backgroundColor: colors.background,
},
layoutRow: {
paddingHorizontal: 6,
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
layoutRowSel: {
backgroundColor: 'rgba(59,130,246,0.1)',
},
layoutRowName: {
fontSize: 9,
fontWeight: '500',
color: colors.text,
},
layoutRowSub: {
fontSize: 9,
color: colors.mutedForeground,
},
layoutReader: {
flex: 1,
padding: spacing.md,
backgroundColor: colors.background,
},
layoutBar: {
height: 6,
borderRadius: 3,
backgroundColor: 'rgba(250,250,250,0.1)',
},
layoutFocus: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
backgroundColor: 'rgba(24,24,27,0.4)',
borderRadius: radius.xs,
marginBottom: 4,
},
layoutFocusSel: {
backgroundColor: 'rgba(59,130,246,0.1)',
},
layoutFocusText: {
fontSize: 9,
color: colors.text,
},
trustedList: {
marginTop: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
trustedRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
trustedEmail: { ...typography.caption, color: colors.text, flex: 1 },
});
+262
View File
@@ -0,0 +1,262 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import {
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
type FolderLayout = 'inline' | 'sidebar';
type ViewMode = 'list' | 'grid';
type SortKey = 'name' | 'size' | 'modified';
type SortDir = 'asc' | 'desc';
interface FilesPrefs {
folderLayout: FolderLayout;
defaultViewMode: ViewMode;
defaultSortKey: SortKey;
defaultSortDir: SortDir;
showIcons: boolean;
coloredIcons: boolean;
showThumbnails: boolean;
showHiddenFiles: boolean;
}
const DEFAULTS: FilesPrefs = {
folderLayout: 'inline',
defaultViewMode: 'list',
defaultSortKey: 'name',
defaultSortDir: 'asc',
showIcons: true,
coloredIcons: true,
showThumbnails: true,
showHiddenFiles: false,
};
interface SampleFile {
name: string;
isFolder: boolean;
size: number;
modified: string;
hidden?: boolean;
}
const SAMPLE: SampleFile[] = [
{ name: 'Documents', isFolder: true, size: 0, modified: '03-10' },
{ name: 'Photos', isFolder: true, size: 0, modified: '03-14' },
{ name: 'report.pdf', isFolder: false, size: 245000, modified: '03-15' },
{ name: 'notes.md', isFolder: false, size: 1200, modified: '03-12' },
{ name: 'song.mp3', isFolder: false, size: 5200000, modified: '03-01' },
{ name: '.config', isFolder: false, size: 340, modified: '02-20', hidden: true },
];
function formatSize(b: number) {
if (b === 0) return '-';
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
function getIcon(file: SampleFile, colored: boolean, sz: number) {
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : colors.mutedForeground} />;
const ext = file.name.split('.').pop()?.toLowerCase();
switch (ext) {
case 'jpg': case 'png': case 'gif':
return <ImageIcon size={sz} color={colored ? '#4ade80' : colors.mutedForeground} />;
case 'mp3': case 'wav':
return <FileAudio size={sz} color={colored ? '#a78bfa' : colors.mutedForeground} />;
case 'pdf':
return <FileText size={sz} color={colored ? '#f87171' : colors.mutedForeground} />;
case 'md': case 'json': case 'js': case 'ts':
return <FileCode size={sz} color={colored ? '#fbbf24' : colors.mutedForeground} />;
default:
return <File size={sz} color={colors.mutedForeground} />;
}
}
function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
const files = SAMPLE.filter((f) => {
if (!prefs.showHiddenFiles && f.hidden) return false;
return true;
}).sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
let cmp = 0;
if (prefs.defaultSortKey === 'name') cmp = a.name.localeCompare(b.name);
if (prefs.defaultSortKey === 'size') cmp = a.size - b.size;
if (prefs.defaultSortKey === 'modified') cmp = a.modified.localeCompare(b.modified);
return prefs.defaultSortDir === 'desc' ? -cmp : cmp;
});
if (prefs.defaultViewMode === 'grid') {
return (
<View style={styles.previewBox}>
<View style={styles.gridWrap}>
{files.map((f) => (
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
</View>
))}
</View>
</View>
);
}
return (
<View style={styles.previewBox}>
<View style={styles.listHeader}>
<Text style={[styles.listHeaderText, { flex: 1 }]}>Name</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>Size</Text>
<Text style={[styles.listHeaderText, { width: 60, textAlign: 'right' }]}>Modified</Text>
</View>
{files.map((f) => (
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
<Text
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
numberOfLines={1}
>
{f.name}
</Text>
<Text style={styles.listMeta}>{formatSize(f.size)}</Text>
<Text style={styles.listMeta}>{f.modified}</Text>
</View>
))}
</View>
);
}
export function FilesSettings() {
const [prefs, setPrefs] = useState<FilesPrefs>(DEFAULTS);
const update = (patch: Partial<FilesPrefs>) => setPrefs((p) => ({ ...p, ...patch }));
return (
<View style={styles.container}>
<View>
<Text style={styles.previewLabel}>Preview</Text>
<FilesPreview prefs={prefs} />
</View>
<SettingsSection title="Display" description="How files and folders are presented.">
<SettingItem label="Folder layout" description="Choose how folders appear in the browser.">
<RadioGroup
value={prefs.folderLayout}
onChange={(v) => update({ folderLayout: v as FolderLayout })}
options={[
{ value: 'inline', label: 'Inline' },
{ value: 'sidebar', label: 'Sidebar' },
]}
/>
</SettingItem>
<SettingItem label="Default view" description="List or grid view.">
<RadioGroup
value={prefs.defaultViewMode}
onChange={(v) => update({ defaultViewMode: v as ViewMode })}
options={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
]}
/>
</SettingItem>
<SettingItem label="Default sort" description="Primary sort key.">
<RadioGroup
value={prefs.defaultSortKey}
onChange={(v) => update({ defaultSortKey: v as SortKey })}
options={[
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
{ value: 'modified', label: 'Modified' },
]}
/>
</SettingItem>
<SettingItem label="Sort direction">
<RadioGroup
value={prefs.defaultSortDir}
onChange={(v) => update({ defaultSortDir: v as SortDir })}
options={[
{ value: 'asc', label: 'Ascending' },
{ value: 'desc', label: 'Descending' },
]}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title="Icons" description="Appearance of file and folder icons.">
<SettingItem label="Show icons" description="Display icons for files and folders.">
<ToggleSwitch checked={prefs.showIcons} onChange={(v) => update({ showIcons: v })} />
</SettingItem>
<SettingItem label="Colored icons" description="Use file-type-specific colors.">
<ToggleSwitch
checked={prefs.coloredIcons}
onChange={(v) => update({ coloredIcons: v })}
disabled={!prefs.showIcons}
/>
</SettingItem>
<SettingItem label="Show thumbnails" description="Generate previews for images.">
<ToggleSwitch checked={prefs.showThumbnails} onChange={(v) => update({ showThumbnails: v })} />
</SettingItem>
</SettingsSection>
<SettingsSection title="Behavior">
<SettingItem label="Show hidden files" description="Include dotfiles (.name) in listings.">
<ToggleSwitch
checked={prefs.showHiddenFiles}
onChange={(v) => update({ showHiddenFiles: v })}
/>
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
previewLabel: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
previewBox: {
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
overflow: 'hidden',
minHeight: 160,
},
listHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.muted,
},
listHeaderText: { fontSize: 10, fontWeight: '500', color: colors.mutedForeground },
listRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 6,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
listName: { fontSize: 11, color: colors.text, flex: 1 },
listMeta: { fontSize: 10, color: colors.mutedForeground, width: 60, textAlign: 'right' },
gridWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
padding: spacing.sm,
},
gridItem: {
alignItems: 'center',
gap: 4,
padding: spacing.sm,
width: 72,
borderRadius: radius.sm,
},
gridName: { fontSize: 9, color: colors.text, textAlign: 'center' },
});
+171
View File
@@ -0,0 +1,171 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
import { SettingsSection, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Rule {
id: string;
name: string;
enabled: boolean;
summary: string;
action: string;
}
const SAMPLE_RULES: Rule[] = [];
export function FilterSettings() {
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
const [expandedView, setExpandedView] = useState(false);
const [isOpaque] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const toggle = (id: string) => {
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
};
const remove = (id: string) => {
setRules((rs) => rs.filter((r) => r.id !== id));
setDeleteId(null);
};
return (
<View style={styles.container}>
<SettingsSection title="Filters & Rules" description="Server-side rules that process incoming mail.">
{isOpaque && (
<View style={styles.opaqueWarning}>
<AlertTriangle size={16} color="#fbbf24" />
<View style={{ flex: 1 }}>
<Text style={styles.opaqueText}>
Your Sieve script contains advanced features. Edit raw or reset to visual builder.
</Text>
<View style={styles.opaqueActions}>
<Pressable>
<Text style={styles.linkText}>Open Sieve Editor</Text>
</Pressable>
<Pressable style={styles.resetLink}>
<RotateCcw size={12} color={colors.error} />
<Text style={styles.resetText}>Reset to visual</Text>
</Pressable>
</View>
</View>
</View>
)}
{rules.length === 0 && !isOpaque && (
<View style={styles.emptyState}>
<Filter size={40} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
<Text style={styles.emptyText}>No rules yet.</Text>
</View>
)}
{rules.length > 0 && (
<View style={{ gap: 4 }}>
{rules.map((rule) => (
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
<View style={styles.grip}>
<GripVertical size={16} color={colors.mutedForeground} />
</View>
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
<View style={{ flex: 1 }}>
<Text style={styles.ruleName}>{rule.name}</Text>
<Text style={styles.ruleSummary} numberOfLines={expandedView ? 0 : 1}>
{rule.summary} → {rule.action}
</Text>
</View>
{deleteId === rule.id ? (
<View style={{ flexDirection: 'row', gap: 4 }}>
<Button variant="destructive" size="sm" onPress={() => remove(rule.id)}>
Confirm
</Button>
<Button variant="ghost" size="sm" onPress={() => setDeleteId(null)}>
Cancel
</Button>
</View>
) : (
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
<X size={16} color={colors.mutedForeground} />
</Pressable>
)}
</View>
))}
</View>
)}
</SettingsSection>
<View style={styles.footer}>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
{!isOpaque && (
<Button variant="outline" size="sm" icon={<Plus size={14} color={colors.text} />}>
Add Rule
</Button>
)}
<Button variant="outline" size="sm" icon={<Code size={14} color={colors.text} />}>
Raw Editor
</Button>
</View>
{!isOpaque && rules.length > 0 && (
<View style={styles.expandedToggle}>
<Text style={styles.expandedLabel}>Expanded view</Text>
<ToggleSwitch checked={expandedView} onChange={setExpandedView} />
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xl },
opaqueWarning: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
alignItems: 'flex-start',
},
opaqueText: { ...typography.caption, color: '#fde68a' },
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
linkText: { ...typography.caption, color: colors.primary, fontWeight: '500' },
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
resetText: { ...typography.caption, color: colors.error, fontWeight: '500' },
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: colors.mutedForeground },
ruleRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
ruleDisabled: { opacity: 0.6 },
grip: { paddingTop: 2 },
ruleName: { ...typography.bodyMedium, color: colors.text },
ruleSummary: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
flexWrap: 'wrap',
},
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
expandedLabel: { ...typography.caption, color: colors.mutedForeground },
});
+127
View File
@@ -0,0 +1,127 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
} from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useEmailStore } from '../../stores/email-store';
import type { Mailbox } from '../../api/types';
const ROLE_LABEL: Record<string, string> = {
inbox: 'Inbox', drafts: 'Drafts', sent: 'Sent', trash: 'Trash',
junk: 'Junk', archive: 'Archive', important: 'Important', all: 'All',
};
const ROLE_ICON: Record<string, any> = {
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
junk: ShieldAlert, archive: Archive,
};
function getIcon(mb: Mailbox) {
if (mb.role && ROLE_ICON[mb.role]) return ROLE_ICON[mb.role];
return Folder;
}
export function FolderSettings() {
const mailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
useEffect(() => {
if (mailboxes.length === 0) void fetchMailboxes();
}, [mailboxes.length, fetchMailboxes]);
const sorted = [...mailboxes].sort((a, b) => {
const ar = a.role ? 0 : 1;
const br = b.role ? 0 : 1;
if (ar !== br) return ar - br;
return a.name.localeCompare(b.name);
});
const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0);
return (
<View style={styles.container}>
<SettingsSection
title="Folders"
description={`Your mail folders. ${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread.`}
>
{mailboxes.length === 0 ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : (
<View>
{sorted.map((mb) => {
const Icon = getIcon(mb);
return (
<View key={mb.id} style={styles.folderRow}>
<View style={styles.folderLeft}>
<Icon
size={16}
color={mb.role ? colors.primary : colors.mutedForeground}
/>
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{ROLE_LABEL[mb.role]}</Text>
</View>
)}
</View>
<View style={styles.folderRight}>
{mb.unreadEmails > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadText}>{mb.unreadEmails}</Text>
</View>
)}
<Text style={styles.total}>{mb.totalEmails}</Text>
</View>
</View>
);
})}
</View>
)}
</SettingsSection>
<SettingsSection
title="Management"
description="Creating, renaming and deleting folders is not yet available in the mobile app. Use the web client."
>
<SettingItem label="Read-only">
<Text style={styles.caption}>Folder list shown above is live.</Text>
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: colors.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: colors.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: colors.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: colors.primaryForeground },
total: { ...typography.caption, color: colors.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: colors.mutedForeground },
});
@@ -0,0 +1,104 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { colors, typography, spacing, radius } from '../../theme/tokens';
import { useSettingsStore } from '../../stores/settings-store';
export function IdentitySettings() {
const identities = useSettingsStore((s) => s.identities);
const loading = useSettingsStore((s) => s.loading);
const error = useSettingsStore((s) => s.error);
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
useEffect(() => {
void fetchIdentities();
}, [fetchIdentities]);
const count = identities.length;
const countLabel =
count === 0 ? 'No identities' : count === 1 ? '1 identity' : `${count} identities`;
return (
<SettingsSection
title="Identities"
description="Manage sender names, email addresses, and signatures."
>
<SettingItem
label="Identities"
description="Your configured sender identities."
>
<View style={styles.row}>
{loading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Text style={styles.count}>{countLabel}</Text>
)}
</View>
</SettingItem>
{error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{identities.map((identity) => (
<View key={identity.id} style={styles.identityRow}>
<View style={{ flex: 1 }}>
<Text style={styles.identityName}>{identity.name || '(no name)'}</Text>
<Text style={styles.identityEmail}>{identity.email}</Text>
</View>
{!identity.mayDelete && (
<View style={styles.badge}>
<Text style={styles.badgeText}>primary</Text>
</View>
)}
</View>
))}
<SettingItem
label="Sub-addressing"
description="Use plus-addressing (you+tag@example.com) to route mail."
>
<Button variant="outline" size="sm">Learn More</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
count: {
...typography.body,
color: colors.text,
},
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
},
errorText: { ...typography.caption, color: colors.error },
identityRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.muted,
},
identityName: { ...typography.bodyMedium, color: colors.text },
identityEmail: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
badge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: colors.primary },
});
+273
View File
@@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Keyword {
id: string;
label: string;
color: keyof typeof colors.tags;
}
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
const DEFAULT_KEYWORDS: Keyword[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
];
export function KeywordSettings() {
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const saveKeyword = (kw: Keyword, editing: boolean) => {
if (editing) {
setKeywords((ks) => ks.map((k) => k.id === editingId ? kw : k));
setEditingId(null);
} else {
setKeywords((ks) => [...ks, kw]);
setIsAdding(false);
}
};
const deleteKeyword = (id: string) => {
setKeywords((ks) => ks.filter((k) => k.id !== id));
};
const resetDefaults = () => {
setKeywords(DEFAULT_KEYWORDS);
};
return (
<SettingsSection title="Keywords & Labels" description="Colored tags to organize your mail.">
<View style={{ gap: spacing.sm }}>
{keywords.map((kw) => {
if (editingId === kw.id) {
return (
<KeywordForm
key={kw.id}
initial={kw}
existingIds={keywords.filter((k) => k.id !== kw.id).map((k) => k.id)}
onSave={(k) => saveKeyword(k, true)}
onCancel={() => setEditingId(null)}
/>
);
}
const palette = colors.tags[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)}>
<Pencil size={14} color={colors.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
</Pressable>
</View>
</View>
);
})}
{isAdding && (
<KeywordForm
existingIds={keywords.map((k) => k.id)}
onSave={(k) => saveKeyword(k, false)}
onCancel={() => setIsAdding(false)}
/>
)}
{!isAdding && editingId === null && (
<View style={styles.bottomActions}>
<Pressable style={styles.outlineBtn} onPress={() => setIsAdding(true)}>
<Plus size={14} color={colors.mutedForeground} />
<Text style={styles.outlineBtnText}>Add keyword</Text>
</Pressable>
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
<RotateCcw size={14} color={colors.mutedForeground} />
<Text style={styles.outlineBtnText}>Reset defaults</Text>
</Pressable>
</View>
)}
</View>
</SettingsSection>
);
}
interface KeywordFormProps {
initial?: Keyword;
existingIds: string[];
onSave: (kw: Keyword) => void;
onCancel: () => void;
}
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
const [label, setLabel] = useState(initial?.label ?? '');
const [color, setColor] = useState<keyof typeof colors.tags>(initial?.color ?? 'blue');
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const handleSave = () => {
if (!isValid) return;
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<View style={styles.form}>
<View>
<Text style={styles.formLabel}>Label</Text>
<TextInput
value={label}
onChangeText={setLabel}
placeholder="e.g. Important"
placeholderTextColor={colors.mutedForeground}
style={styles.input}
maxLength={30}
autoFocus
/>
{isDuplicate && <Text style={styles.errorText}>An ID with that name already exists.</Text>}
</View>
<View>
<Text style={styles.formLabel}>Color</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = colors.tags[key];
return (
<Pressable
key={key}
onPress={() => setColor(key)}
style={[
styles.colorSwatch,
{ backgroundColor: p.dot },
color === key && styles.colorSwatchSelected,
]}
/>
);
})}
</View>
</View>
<View style={styles.formActions}>
<Pressable style={styles.cancelFormBtn} onPress={onCancel}>
<X size={14} color={colors.text} />
<Text style={styles.cancelFormText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.saveBtn, !isValid && { opacity: 0.5 }]}
onPress={handleSave}
disabled={!isValid}
>
<Check size={14} color={colors.primaryForeground} />
<Text style={styles.saveBtnText}>{initial ? 'Save' : 'Add'}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
kwRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
},
kwDot: { width: 20, height: 20, borderRadius: 10 },
kwLabel: { ...typography.bodyMedium, color: colors.text, flex: 1 },
kwId: { fontSize: 10, color: colors.mutedForeground, fontFamily: 'monospace' },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
bottomActions: {
flexDirection: 'row',
gap: spacing.sm,
paddingTop: spacing.sm,
},
outlineBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.border,
},
outlineBtnText: { ...typography.caption, color: colors.mutedForeground },
form: {
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.primaryBorder,
backgroundColor: colors.accent,
},
formLabel: { ...typography.caption, color: colors.mutedForeground, marginBottom: 4 },
input: {
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
errorText: { ...typography.caption, color: colors.error, marginTop: 4 },
palette: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
colorSwatch: { width: 24, height: 24, borderRadius: 12 },
colorSwatchSelected: {
borderWidth: 2,
borderColor: colors.text,
},
formActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
cancelFormBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
cancelFormText: { ...typography.caption, color: colors.text },
saveBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.primary,
},
saveBtnText: { ...typography.caption, color: colors.primaryForeground, fontWeight: '500' },
});
@@ -0,0 +1,271 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native';
import Button from '../Button';
import Dialog from '../Dialog';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { colors, radius, spacing, typography } from '../../theme/tokens';
import { useAuthStore } from '../../stores/auth-store';
import {
DEFAULT_RELAY_BASE_URL,
getStoredRelayBaseUrl,
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
} from '../../lib/push-notifications';
const SOUNDS = [
{ value: 'default', label: 'Default' },
{ value: 'chime', label: 'Chime' },
{ value: 'ping', label: 'Ping' },
{ value: 'pop', label: 'Pop' },
{ value: 'none', label: 'Silent' },
];
type PushStatus =
| { kind: 'idle' }
| { kind: 'busy'; message: string }
| { kind: 'enabled' }
| { kind: 'error'; message: string };
export function NotificationSettings() {
const username = useAuthStore((s) => s.username);
const [sound, setSound] = useState('default');
const [emailEnabled, setEmailEnabled] = useState(true);
const [emailSound, setEmailSound] = useState(true);
const [calEnabled, setCalEnabled] = useState(true);
const [calSound, setCalSound] = useState(true);
const [invitationParsing, setInvitationParsing] = useState(true);
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [hasSaved, setHasSaved] = useState(false);
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
const [confirmDisable, setConfirmDisable] = useState(false);
useEffect(() => {
void (async () => {
const stored = await getStoredRelayBaseUrl();
if (stored) {
setRelayUrl(stored);
setHasSaved(true);
setPushStatus({ kind: 'enabled' });
}
})();
}, []);
const trimmed = relayUrl.trim().replace(/\/+$/, '');
const isValidUrl = /^https?:\/\/.+/i.test(trimmed);
const busy = pushStatus.kind === 'busy';
const canEnable = isValidUrl && !busy;
const handleEnable = async () => {
if (!isValidUrl) {
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
return;
}
setPushStatus({ kind: 'busy', message: 'Registering device…' });
try {
await setStoredRelayBaseUrl(trimmed);
await setupPushNotifications({
relayBaseUrl: trimmed,
accountLabel: username ?? undefined,
});
setHasSaved(true);
setPushStatus({ kind: 'enabled' });
} catch (error) {
setPushStatus({
kind: 'error',
message: error instanceof Error ? error.message : 'Setup failed',
});
}
};
const handleDisable = () => {
setConfirmDisable(true);
};
const performDisable = async () => {
setConfirmDisable(false);
setPushStatus({ kind: 'busy', message: 'Disabling…' });
try {
await teardownPushNotifications();
await setStoredRelayBaseUrl(null);
setRelayUrl(DEFAULT_RELAY_BASE_URL);
setHasSaved(false);
setPushStatus({ kind: 'idle' });
} catch (error) {
setPushStatus({
kind: 'error',
message: error instanceof Error ? error.message : 'Teardown failed',
});
}
};
return (
<View style={styles.container}>
<SettingsSection
title="Push Notifications"
description="Delivered via the Bulwark push relay. The relay only sees an opaque token and new-mail timing — never mail content."
>
<View style={styles.pushCard}>
<View style={styles.pushHeader}>
<Text style={styles.fieldLabel}>Relay base URL</Text>
<View style={styles.statusRow}>
{pushStatus.kind === 'busy' && (
<>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.statusText}>{pushStatus.message}</Text>
</>
)}
{pushStatus.kind === 'enabled' && (
<>
<CheckCircle2 size={14} color={colors.success} />
<Text style={[styles.statusText, { color: colors.success }]}>Active</Text>
</>
)}
{pushStatus.kind === 'error' && (
<>
<XCircle size={14} color={colors.error} />
<Text style={[styles.statusText, { color: colors.error }]}>
{pushStatus.message}
</Text>
</>
)}
{pushStatus.kind === 'idle' && (
<Text style={[styles.statusText, { color: colors.mutedForeground }]}>
Not configured
</Text>
)}
</View>
</View>
<Text style={styles.fieldDescription}>
Defaults to the hosted Bulwark relay. Change only if you self-host.
</Text>
<TextInput
value={relayUrl}
onChangeText={setRelayUrl}
placeholder={DEFAULT_RELAY_BASE_URL}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!busy}
style={styles.urlInput}
/>
<View style={styles.actions}>
<Button
variant="default"
onPress={handleEnable}
disabled={!canEnable}
loading={busy}
>
{hasSaved ? 'Re-register' : 'Enable'}
</Button>
{hasSaved && (
<Button variant="outline" onPress={handleDisable} disabled={busy}>
Disable
</Button>
)}
</View>
</View>
</SettingsSection>
<SettingsSection title="Sound Selection" description="Pick the sound used for new notifications.">
<SettingItem label="Notification Sound" description="Played when a new notification arrives.">
<View style={styles.row}>
<Button
variant="ghost"
size="icon"
icon={<Volume2 size={16} color={colors.text} />}
/>
<Select
value={sound}
onChange={setSound}
options={SOUNDS}
/>
</View>
</SettingItem>
</SettingsSection>
<SettingsSection title="Email" description="Notifications for incoming email.">
<SettingItem label="Email Notifications" description="Show desktop alerts for new email.">
<ToggleSwitch checked={emailEnabled} onChange={setEmailEnabled} />
</SettingItem>
<SettingItem label="Email Sound" description="Play a sound when new email arrives.">
<ToggleSwitch checked={emailSound} onChange={setEmailSound} disabled={!emailEnabled} />
</SettingItem>
</SettingsSection>
<SettingsSection title="Calendar" description="Notifications for events and invitations.">
<SettingItem label="Calendar Notifications" description="Show alerts for upcoming events.">
<ToggleSwitch checked={calEnabled} onChange={setCalEnabled} />
</SettingItem>
<SettingItem label="Calendar Sound" description="Play a sound for calendar alerts.">
<ToggleSwitch checked={calSound} onChange={setCalSound} disabled={!calEnabled} />
</SettingItem>
<SettingItem label="Invitation Parsing" description="Automatically detect and parse event invitations.">
<ToggleSwitch checked={invitationParsing} onChange={setInvitationParsing} />
</SettingItem>
</SettingsSection>
<Dialog
visible={confirmDisable}
variant="destructive"
title="Disable push notifications?"
message="The device will stop receiving new-mail alerts from this server."
confirmText="Disable"
cancelText="Cancel"
onCancel={() => setConfirmDisable(false)}
onConfirm={performDisable}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
pushCard: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.md,
padding: spacing.lg,
gap: spacing.sm,
},
pushHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
},
fieldLabel: {
...typography.bodyMedium,
color: colors.text,
},
fieldDescription: {
...typography.caption,
color: colors.mutedForeground,
},
urlInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 8,
alignSelf: 'stretch',
marginTop: 4,
},
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1 },
statusText: { ...typography.caption, color: colors.text },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.md },
});
+224
View File
@@ -0,0 +1,224 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Puzzle, AlertTriangle, Lock, Trash2, ChevronDown, ChevronRight } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Plugin {
id: string;
name: string;
author: string;
version: string;
type: string;
description?: string;
enabled: boolean;
status: 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
error?: string;
permissions: string[];
forceEnabled?: boolean;
}
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
installed: { bg: colors.muted, fg: colors.mutedForeground },
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
disabled: { bg: colors.muted, fg: colors.mutedForeground },
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
export function PluginsSettings() {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const togglePlugin = (id: string) => {
setPlugins((ps) => ps.map((p) => {
if (p.id !== id) return p;
const enabled = !p.enabled;
return { ...p, enabled, status: enabled ? 'enabled' : 'disabled' };
}));
};
const uninstall = (id: string) => {
setPlugins((ps) => ps.filter((p) => p.id !== id));
};
return (
<SettingsSection
title="Plugins"
description="Manage installed plugins. Upload plugin .zip files to add new functionality."
experimental
experimentalDescription="Plugins is an experimental feature. The plugin API is not yet stable. Only install plugins from sources you trust."
>
{plugins.length === 0 ? (
<View style={styles.empty}>
<Puzzle size={48} color={colors.mutedForeground} style={{ opacity: 0.3 }} />
<Text style={styles.emptyTitle}>No plugins installed</Text>
<Text style={styles.emptyDesc}>Upload a plugin .zip file to get started.</Text>
</View>
) : (
<View style={{ gap: spacing.sm }}>
{plugins.map((plugin) => {
const expanded = expandedId === plugin.id;
const status = STATUS_STYLE[plugin.status];
return (
<View
key={plugin.id}
style={[
styles.card,
plugin.status === 'error' && { borderColor: colors.errorBorder },
]}
>
<View style={styles.cardHeader}>
<Pressable
style={{ flex: 1 }}
onPress={() => setExpandedId(expanded ? null : plugin.id)}
>
<View style={styles.headerRow}>
<Text style={styles.pluginName} numberOfLines={1}>{plugin.name}</Text>
<View style={[styles.statusPill, { backgroundColor: status.bg }]}>
<Text style={[styles.statusText, { color: status.fg }]}>{plugin.status}</Text>
</View>
{plugin.forceEnabled && (
<View style={styles.forcedPill}>
<Lock size={10} color="#fbbf24" />
<Text style={styles.forcedText}>Forced</Text>
</View>
)}
</View>
<View style={styles.metaRow}>
<Text style={styles.meta}>{plugin.author}</Text>
<Text style={styles.metaDot}>v{plugin.version}</Text>
<Text style={styles.metaDot}>{plugin.type}</Text>
</View>
</Pressable>
<ToggleSwitch
checked={plugin.enabled}
onChange={() => togglePlugin(plugin.id)}
disabled={plugin.forceEnabled}
/>
</View>
{expanded && (
<View style={styles.cardBody}>
{plugin.description && (
<Text style={styles.description}>{plugin.description}</Text>
)}
{plugin.error && (
<View style={styles.errorBox}>
<AlertTriangle size={14} color={colors.error} />
<Text style={styles.errorText}>{plugin.error}</Text>
</View>
)}
{plugin.permissions.length > 0 && (
<View>
<Text style={styles.sectionLabel}>Permissions</Text>
<View style={styles.permWrap}>
{plugin.permissions.map((p) => (
<View key={p} style={styles.permPill}>
<Text style={styles.permText}>{p}</Text>
</View>
))}
</View>
</View>
)}
<View style={styles.uninstallRow}>
<Button
variant="destructive"
size="sm"
icon={<Trash2 size={14} color="#fff" />}
onPress={() => uninstall(plugin.id)}
disabled={plugin.forceEnabled}
>
Uninstall
</Button>
</View>
</View>
)}
</View>
);
})}
</View>
)}
<SettingItem label="Upload Plugin" description="Install a new plugin from a .zip file.">
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Upload .zip
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
empty: {
alignItems: 'center',
paddingVertical: spacing.xxxl,
gap: spacing.sm,
},
emptyTitle: { ...typography.body, color: colors.mutedForeground },
emptyDesc: { ...typography.caption, color: colors.mutedForeground },
card: {
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
},
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
pluginName: { ...typography.bodyMedium, color: colors.text },
statusPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full },
statusText: { fontSize: 10, fontWeight: '500' },
forcedPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(251, 191, 36, 0.15)',
},
forcedText: { fontSize: 10, fontWeight: '500', color: '#fbbf24' },
metaRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 2 },
meta: { ...typography.caption, color: colors.mutedForeground },
metaDot: { ...typography.caption, color: colors.mutedForeground, opacity: 0.6 },
cardBody: {
borderTopWidth: 1,
borderTopColor: colors.border,
padding: spacing.md,
gap: spacing.md,
},
description: { ...typography.caption, color: colors.mutedForeground },
errorBox: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
alignItems: 'flex-start',
},
errorText: { ...typography.caption, color: colors.error, flex: 1 },
sectionLabel: { ...typography.captionMedium, color: colors.text, marginBottom: 4 },
permWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 },
permPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: colors.muted,
},
permText: { fontSize: 10, color: colors.mutedForeground },
uninstallRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.border,
},
});
@@ -0,0 +1,288 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import {
Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface SidebarApp {
id: string;
name: string;
url: string;
icon: string;
openMode: 'tab' | 'inline';
showOnMobile: boolean;
}
export function SidebarAppsSettings() {
const [keepLoaded, setKeepLoaded] = useState(false);
const [apps, setApps] = useState<SidebarApp[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const addApp = (data: Omit<SidebarApp, 'id'>) => {
setApps((list) => [...list, { ...data, id: `app-${Math.random().toString(36).slice(2)}` }]);
setIsAdding(false);
};
const updateApp = (id: string, data: Omit<SidebarApp, 'id'>) => {
setApps((list) => list.map((a) => a.id === id ? { ...data, id } : a));
setEditingId(null);
};
const removeApp = (id: string) => {
setApps((list) => list.filter((a) => a.id !== id));
};
return (
<View style={styles.container}>
<SettingsSection title="Sidebar Apps" description="Embedded web apps accessible from the sidebar.">
<SettingItem label="Keep apps loaded" description="Maintain app state when switching away.">
<ToggleSwitch checked={keepLoaded} onChange={setKeepLoaded} />
</SettingItem>
</SettingsSection>
<SettingsSection title="Manage Apps" description="Add, edit, or remove sidebar apps.">
<View style={{ gap: spacing.md }}>
{apps.length === 0 && !isAdding && (
<Text style={styles.emptyText}>No apps added yet.</Text>
)}
{apps.map((app) => {
if (editingId === app.id) {
return (
<AppForm
key={app.id}
initial={app}
onSave={(data) => updateApp(app.id, data)}
onCancel={() => setEditingId(null)}
/>
);
}
return (
<View key={app.id} style={styles.appRow}>
<GripVertical size={16} color={colors.mutedForeground} style={{ opacity: 0.5 }} />
<View style={styles.appIcon}>
<Globe size={16} color={colors.text} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
<Text style={styles.appUrl} numberOfLines={1}>{app.url}</Text>
</View>
<View style={[
styles.modeBadge,
app.openMode === 'inline' ? styles.modeBadgeInline : styles.modeBadgeTab,
]}>
<Text style={[
styles.modeBadgeText,
{ color: app.openMode === 'inline' ? '#60a5fa' : colors.mutedForeground },
]}>
{app.openMode === 'inline' ? 'Inline' : 'Tab'}
</Text>
</View>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)}>
<Pencil size={14} color={colors.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => removeApp(app.id)}>
<Trash2 size={14} color={colors.mutedForeground} />
</Pressable>
</View>
);
})}
{isAdding && (
<AppForm
onSave={addApp}
onCancel={() => setIsAdding(false)}
/>
)}
{!isAdding && editingId === null && (
<Pressable style={styles.addBtn} onPress={() => setIsAdding(true)}>
<Plus size={16} color={colors.text} />
<Text style={styles.addBtnText}>Add app</Text>
</Pressable>
)}
</View>
</SettingsSection>
</View>
);
}
interface AppFormProps {
initial?: SidebarApp;
onSave: (data: Omit<SidebarApp, 'id'>) => void;
onCancel: () => void;
}
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
const [name, setName] = useState(initial?.name ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
const [showOnMobile, setShowOnMobile] = useState(initial?.showOnMobile ?? false);
const canSave = name.trim().length > 0 && url.trim().length > 0;
return (
<View style={formStyles.form}>
<View>
<Text style={formStyles.label}>Name</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="App name"
placeholderTextColor={colors.mutedForeground}
style={formStyles.input}
/>
</View>
<View>
<Text style={formStyles.label}>URL</Text>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://example.com"
placeholderTextColor={colors.mutedForeground}
style={formStyles.input}
autoCapitalize="none"
keyboardType="url"
/>
</View>
<View>
<Text style={formStyles.label}>Open mode</Text>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<Pressable
onPress={() => setOpenMode('tab')}
style={[formStyles.modeBtn, openMode === 'tab' && formStyles.modeBtnActive]}
>
<ExternalLink size={14} color={openMode === 'tab' ? colors.primary : colors.text} />
<Text style={[formStyles.modeBtnText, openMode === 'tab' && { color: colors.primary }]}>
New tab
</Text>
</Pressable>
<Pressable
onPress={() => setOpenMode('inline')}
style={[formStyles.modeBtn, openMode === 'inline' && formStyles.modeBtnActive]}
>
<PanelRight size={14} color={openMode === 'inline' ? colors.primary : colors.text} />
<Text style={[formStyles.modeBtnText, openMode === 'inline' && { color: colors.primary }]}>
Inline
</Text>
</Pressable>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={formStyles.label}>Show on mobile</Text>
<ToggleSwitch checked={showOnMobile} onChange={setShowOnMobile} />
</View>
<View style={{ flexDirection: 'row', gap: spacing.sm, justifyContent: 'flex-end' }}>
<Button variant="ghost" size="sm" onPress={onCancel}>Cancel</Button>
<Button
size="sm"
disabled={!canSave}
onPress={() => onSave({ name: name.trim(), url: url.trim(), icon: 'Globe', openMode, showOnMobile })}
>
{initial ? 'Update' : 'Add'}
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
emptyText: {
...typography.body,
color: colors.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
appRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
appIcon: {
width: 32,
height: 32,
borderRadius: radius.sm,
backgroundColor: colors.muted,
alignItems: 'center',
justifyContent: 'center',
},
appName: { ...typography.bodyMedium, color: colors.text },
appUrl: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
modeBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
},
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
modeBadgeTab: { backgroundColor: colors.muted },
modeBadgeText: { fontSize: 10, fontWeight: '500' },
iconBtn: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
addBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
addBtnText: { ...typography.body, color: colors.text },
});
const formStyles = StyleSheet.create({
form: {
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.muted,
},
label: { ...typography.captionMedium, color: colors.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
modeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
},
modeBtnActive: {
borderColor: colors.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: colors.text },
});
+234
View File
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import {
Upload, Trash2, Eye, Lock, Unlock, Download,
ShieldCheck, ShieldAlert, Users,
} from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface KeyRecord {
id: string;
email: string;
subject: string;
issuer: string;
notAfter: string;
algorithm: string;
unlocked?: boolean;
}
interface PublicCert {
id: string;
email: string;
subject: string;
issuer: string;
notAfter: string;
source: string;
}
const MOCK_KEYS: KeyRecord[] = [];
const MOCK_CERTS: PublicCert[] = [];
export function SmimeSettings() {
const [keys, setKeys] = useState<KeyRecord[]>(MOCK_KEYS);
const [certs, setCerts] = useState<PublicCert[]>(MOCK_CERTS);
const [bindings, setBindings] = useState<Record<string, string>>({});
const [defaultEncrypt, setDefaultEncrypt] = useState(false);
const [rememberUnlocked, setRememberUnlocked] = useState(false);
const [autoImport, setAutoImport] = useState(true);
const isExpired = (d: string) => new Date(d) < new Date();
const formatDate = (d: string) => {
try { return new Date(d).toLocaleDateString(); } catch { return d; }
};
const toggleUnlock = (id: string) => {
setKeys((ks) => ks.map((k) => k.id === id ? { ...k, unlocked: !k.unlocked } : k));
};
const deleteKey = (id: string) => setKeys((ks) => ks.filter((k) => k.id !== id));
const deleteCert = (id: string) => setCerts((cs) => cs.filter((c) => c.id !== id));
return (
<View style={styles.container}>
<SettingsSection
title="Your Certificates"
description="Private keys used to sign and decrypt mail."
>
<View style={{ gap: spacing.sm }}>
{keys.map((k) => {
const expired = isExpired(k.notAfter);
return (
<View key={k.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, expired ? styles.certIconError : styles.certIconOk]}>
{expired ? (
<ShieldAlert size={16} color={colors.error} />
) : (
<ShieldCheck size={16} color={colors.primary} />
)}
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{k.email || k.subject}</Text>
<Text style={styles.certMeta}>
{k.issuer} · Expires {formatDate(k.notAfter)}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn} onPress={() => toggleUnlock(k.id)}>
{k.unlocked ? (
<Unlock size={16} color={colors.success} />
) : (
<Lock size={16} color={colors.text} />
)}
</Pressable>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={colors.text} />
</Pressable>
<Pressable style={styles.iconBtn}>
<Download size={16} color={colors.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKey(k.id)}>
<Trash2 size={16} color={colors.error} />
</Pressable>
</View>
</View>
);
})}
{keys.length === 0 && (
<Text style={styles.empty}>No certificates imported.</Text>
)}
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Import PKCS#12
</Button>
</View>
</SettingsSection>
<SettingsSection
title="Recipient Certificates"
description="Public certificates used to encrypt outgoing mail."
>
<View style={{ gap: spacing.sm }}>
{certs.map((c) => {
const expired = isExpired(c.notAfter);
return (
<View key={c.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, styles.certIconMuted]}>
<Users size={16} color={colors.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
<Text style={styles.certMeta}>
{c.issuer} · {c.source}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
</View>
<View style={styles.certActions}>
<Pressable style={styles.iconBtn}>
<Eye size={16} color={colors.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
<Trash2 size={16} color={colors.error} />
</Pressable>
</View>
</View>
);
})}
{certs.length === 0 && (
<Text style={styles.empty}>No recipient certificates.</Text>
)}
</View>
<View style={{ alignItems: 'flex-start', marginTop: spacing.sm }}>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Import Certificate
</Button>
</View>
</SettingsSection>
<SettingsSection
title="Defaults"
description="Defaults for new outgoing messages."
>
<SettingItem
label="Encrypt by default"
description="Encrypt new messages when recipient certificates are available."
>
<ToggleSwitch checked={defaultEncrypt} onChange={setDefaultEncrypt} />
</SettingItem>
<SettingItem
label="Remember unlocked keys"
description="Keep private keys unlocked across the session."
>
<ToggleSwitch checked={rememberUnlocked} onChange={setRememberUnlocked} />
</SettingItem>
<SettingItem
label="Auto-import signer certs"
description="Save public certificates from signed messages."
>
<ToggleSwitch checked={autoImport} onChange={setAutoImport} />
</SettingItem>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
certRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
certLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
flex: 1,
minWidth: 0,
},
certIcon: {
width: 32,
height: 32,
borderRadius: radius.full,
alignItems: 'center',
justifyContent: 'center',
},
certIconOk: { backgroundColor: colors.primaryBg },
certIconError: { backgroundColor: colors.errorBg },
certIconMuted: { backgroundColor: colors.muted },
certName: { ...typography.bodyMedium, color: colors.text },
certMeta: { ...typography.caption, color: colors.mutedForeground, marginTop: 2 },
expiredText: { color: colors.error },
certActions: { flexDirection: 'row', gap: 2 },
iconBtn: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.sm,
},
empty: {
...typography.body,
color: colors.mutedForeground,
textAlign: 'center',
paddingVertical: spacing.lg,
},
});
@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { FileText, Download, Upload } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { colors, spacing, typography } from '../../theme/tokens';
export function TemplateSettings() {
const [templates] = useState<{ id: string; name: string }[]>([]);
return (
<View style={styles.container}>
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
<View style={styles.row}>
<View style={styles.countRow}>
<FileText size={16} color={colors.mutedForeground} />
<Text style={styles.count}>
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
</Text>
</View>
<Button variant="outline" size="sm">Manage</Button>
</View>
</SettingsSection>
<SettingsSection title="Export / Import" description="Back up or restore your templates.">
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={templates.length === 0}
icon={<Download size={14} color={colors.text} />}
>
Export
</Button>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Import
</Button>
</View>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xxxl },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: colors.mutedForeground },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
});
+121
View File
@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Upload, Check, Palette, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
interface Theme {
id: string | null;
name: string;
author: string;
builtIn?: boolean;
variants?: ('light' | 'dark')[];
}
const BUILT_IN: Theme[] = [
{ id: null, name: 'Default', author: 'Bulwark', builtIn: true, variants: ['light', 'dark'] },
{ id: 'qui', name: 'Qui', author: 'Bulwark', builtIn: true, variants: ['dark'] },
{ id: 'sepia', name: 'Sepia', author: 'Bulwark', builtIn: true, variants: ['light'] },
];
export function ThemesSettings() {
const [themes] = useState<Theme[]>(BUILT_IN);
const [active, setActive] = useState<string | null>(null);
return (
<SettingsSection
title="Themes"
description="Customize the appearance with color themes."
experimental
experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates."
>
<View style={styles.grid}>
{themes.map((theme) => {
const isActive = active === theme.id;
return (
<Pressable
key={theme.id ?? 'default'}
onPress={() => setActive(theme.id)}
style={[styles.card, isActive && styles.cardActive]}
>
<View style={styles.preview}>
<Palette size={32} color={colors.mutedForeground} style={{ opacity: 0.4 }} />
</View>
<View style={{ width: '100%' }}>
<View style={styles.cardHeader}>
<Text style={styles.cardName} numberOfLines={1}>{theme.name}</Text>
{isActive && <Check size={16} color={colors.primary} />}
</View>
<Text style={styles.cardAuthor} numberOfLines={1}>{theme.author}</Text>
{theme.variants && (
<View style={styles.variants}>
{theme.variants.map((v) => (
<View key={v} style={styles.variantPill}>
<Text style={styles.variantText}>{v}</Text>
</View>
))}
</View>
)}
</View>
</Pressable>
);
})}
</View>
<SettingItem
label="Upload Theme"
description="Install a custom theme from a .zip file."
>
<Button variant="outline" size="sm" icon={<Upload size={14} color={colors.text} />}>
Upload .zip
</Button>
</SettingItem>
</SettingsSection>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
card: {
width: '47%',
alignItems: 'center',
padding: spacing.md,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
backgroundColor: colors.card,
gap: spacing.sm,
},
cardActive: {
borderColor: colors.primary,
backgroundColor: 'rgba(59, 130, 246, 0.05)',
},
preview: {
width: '100%',
aspectRatio: 16 / 10,
borderRadius: radius.md,
backgroundColor: colors.muted,
alignItems: 'center',
justifyContent: 'center',
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cardName: { ...typography.bodyMedium, color: colors.text, flex: 1 },
cardAuthor: { ...typography.caption, color: colors.mutedForeground },
variants: { flexDirection: 'row', gap: 4, marginTop: 4 },
variantPill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.xs,
backgroundColor: colors.muted,
},
variantText: { fontSize: 10, color: colors.mutedForeground },
});
@@ -0,0 +1,289 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert } from 'react-native';
import { AlertTriangle, Eye, EyeOff } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useVacationStore } from '../../stores/vacation-store';
function toJmapDate(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Accept "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" — normalize to "YYYY-MM-DDTHH:MM:SS".
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(trimmed);
const dateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(trimmed);
if (dateOnly) return `${trimmed}T00:00:00`;
if (dateTime) return trimmed.replace(' ', 'T').length === 16 ? `${trimmed.replace(' ', 'T')}:00` : trimmed.replace(' ', 'T');
return null;
}
function fromJmapDate(value: string | null): string {
if (!value) return '';
return value.replace('T', ' ').slice(0, 16);
}
export function VacationSettings() {
const store = useVacationStore();
const [enabled, setEnabled] = useState(store.isEnabled);
const [fromDate, setFromDate] = useState(fromJmapDate(store.fromDate));
const [toDate, setToDate] = useState(fromJmapDate(store.toDate));
const [subject, setSubject] = useState(store.subject);
const [body, setBody] = useState(store.textBody);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
void store.fetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!store.hasLoaded) return;
setEnabled(store.isEnabled);
setFromDate(fromJmapDate(store.fromDate));
setToDate(fromJmapDate(store.toDate));
setSubject(store.subject);
setBody(store.textBody);
}, [store.hasLoaded, store.isEnabled, store.fromDate, store.toDate, store.subject, store.textBody]);
const dateErrorMsg = fromDate && toDate && toJmapDate(fromDate) && toJmapDate(toDate) && new Date(toJmapDate(toDate)!) <= new Date(toJmapDate(fromDate)!);
const formatError = (fromDate && !toJmapDate(fromDate)) || (toDate && !toJmapDate(toDate));
const emptyBody = enabled && !body.trim();
const warnings: string[] = [];
if (dateErrorMsg) warnings.push('End date must be after start date.');
if (formatError) warnings.push('Dates must be "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".');
if (emptyBody) warnings.push('Auto-response body cannot be empty.');
const canSave = !dateErrorMsg && !formatError && !emptyBody && !store.isSaving;
const handleSave = async () => {
try {
await store.save({
isEnabled: enabled,
fromDate: toJmapDate(fromDate),
toDate: toJmapDate(toDate),
subject: subject.trim(),
textBody: body,
});
Alert.alert('Saved', 'Vacation responder updated.');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to save');
}
};
if (store.isLoading && !store.hasLoaded) {
return (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
if (!store.isSupported && store.hasLoaded) {
return (
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
<View style={styles.unsupported}>
<AlertTriangle size={16} color={colors.warning} />
<Text style={styles.unsupportedText}>
Your server does not advertise support for the JMAP vacation responder.
</Text>
</View>
</SettingsSection>
);
}
return (
<View style={styles.container}>
<SettingsSection title="Vacation Responder" description="Automatically reply while you are away.">
<SettingItem label="Status" description="Enable or disable the responder.">
<View style={styles.statusRow}>
<View style={[styles.pill, enabled ? styles.pillActive : styles.pillInactive]}>
<Text style={[styles.pillText, enabled ? styles.pillTextActive : styles.pillTextInactive]}>
{enabled ? 'Active' : 'Inactive'}
</Text>
</View>
<ToggleSwitch checked={enabled} onChange={setEnabled} />
</View>
</SettingItem>
</SettingsSection>
<SettingsSection title="Date Range" description="Optional window for the responder.">
<SettingItem label="Start" description="When the responder becomes active.">
<TextInput
value={fromDate}
onChangeText={setFromDate}
placeholder="YYYY-MM-DD HH:MM"
placeholderTextColor={colors.mutedForeground}
style={styles.dateInput}
autoCapitalize="none"
/>
</SettingItem>
<SettingItem label="End" description="When the responder is turned off.">
<TextInput
value={toDate}
onChangeText={setToDate}
placeholder="YYYY-MM-DD HH:MM"
placeholderTextColor={colors.mutedForeground}
style={styles.dateInput}
autoCapitalize="none"
/>
</SettingItem>
</SettingsSection>
<SettingsSection title="Message" description="What recipients will see.">
<SettingItem label="Subject" description="Optional subject for the reply.">
<TextInput
value={subject}
onChangeText={setSubject}
placeholder="Out of office"
placeholderTextColor={colors.mutedForeground}
style={styles.subjectInput}
/>
</SettingItem>
<View style={styles.bodyBlock}>
<Text style={styles.bodyLabel}>Body</Text>
<Text style={styles.bodyDesc}>Short explanation for senders.</Text>
<TextInput
value={body}
onChangeText={setBody}
placeholder="I am away from…"
placeholderTextColor={colors.mutedForeground}
style={styles.bodyInput}
multiline
numberOfLines={6}
textAlignVertical="top"
/>
</View>
</SettingsSection>
{body.trim().length > 0 && (
<SettingsSection title="Preview">
<Pressable style={styles.previewToggle} onPress={() => setShowPreview((v) => !v)}>
{showPreview ? (
<EyeOff size={14} color={colors.primary} />
) : (
<Eye size={14} color={colors.primary} />
)}
<Text style={styles.previewToggleText}>
{showPreview ? 'Hide preview' : 'Show preview'}
</Text>
</Pressable>
{showPreview && (
<View style={styles.previewBox}>
{subject.length > 0 && <Text style={styles.previewSubject}>{subject}</Text>}
<Text style={styles.previewBody}>{body}</Text>
</View>
)}
</SettingsSection>
)}
{warnings.length > 0 && (
<View style={styles.warnings}>
{warnings.map((w, i) => (
<View key={i} style={styles.warnRow}>
<AlertTriangle size={14} color={colors.warning} />
<Text style={styles.warnText}>{w}</Text>
</View>
))}
</View>
)}
{store.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{store.error}</Text>
</View>
)}
<View style={styles.saveRow}>
<Button onPress={handleSave} disabled={!canSave} loading={store.isSaving}>
Save
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: spacing.xl },
centered: { paddingVertical: 40, alignItems: 'center' },
unsupported: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.md,
backgroundColor: 'rgba(202,138,4,0.12)',
borderRadius: radius.sm,
},
unsupportedText: { ...typography.body, color: colors.warning, flex: 1 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
pill: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: radius.full,
},
pillActive: { backgroundColor: 'rgba(22,163,74,0.2)' },
pillInactive: { backgroundColor: colors.muted },
pillText: { fontSize: 11, fontWeight: '500' },
pillTextActive: { color: '#4ade80' },
pillTextInactive: { color: colors.mutedForeground },
dateInput: {
minWidth: 180,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
subjectInput: {
width: 240,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
bodyBlock: { paddingVertical: spacing.md },
bodyLabel: { ...typography.bodyMedium, color: colors.text },
bodyDesc: { ...typography.caption, color: colors.mutedForeground, marginTop: 2, marginBottom: spacing.sm },
bodyInput: {
width: '100%',
minHeight: 96,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
...typography.body,
},
previewToggle: { flexDirection: 'row', alignItems: 'center', gap: 6 },
previewToggleText: { ...typography.body, color: colors.primary },
previewBox: {
marginTop: spacing.sm,
padding: spacing.lg,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
},
previewSubject: { ...typography.bodyMedium, color: colors.text, marginBottom: spacing.sm },
previewBody: { ...typography.body, color: colors.mutedForeground },
warnings: { gap: spacing.sm },
warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
warnText: { ...typography.body, color: colors.warning, flex: 1 },
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.errorBg,
},
errorText: { ...typography.body, color: colors.error },
saveRow: { alignItems: 'flex-end' },
});
@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Pressable,
Modal,
ScrollView,
ViewStyle,
} from 'react-native';
import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import ToggleSwitchComponent from '../ToggleSwitch';
/**
* Mirrors webmail settings-section.tsx:
* - SettingsSection: space-y-4, with experimental banner, h3 title + description
* - SettingItem: flex items-start justify-between py-3 border-b border-border
* - ToggleSwitch: h-6 w-11
* - Select: rounded-md bg-muted border text-sm px-3 py-1.5
* - RadioGroup: flex gap-1.5, pill buttons
*/
interface SettingsSectionProps {
title: string;
description?: string;
children: React.ReactNode;
experimental?: boolean;
experimentalDescription?: string;
}
export function SettingsSection({
title,
description,
children,
experimental,
experimentalDescription,
}: SettingsSectionProps) {
return (
<View style={styles.section}>
{experimental && (
<View style={styles.experimentalBanner}>
<FlaskConical size={20} color="#fbbf24" />
<View style={{ flex: 1 }}>
<Text style={styles.experimentalTitle}>Experimental Feature</Text>
{experimentalDescription && (
<Text style={styles.experimentalDesc}>{experimentalDescription}</Text>
)}
</View>
</View>
)}
<View>
<Text style={styles.sectionTitle}>{title}</Text>
{description && <Text style={styles.sectionDescription}>{description}</Text>}
</View>
<View style={styles.sectionBody}>{children}</View>
</View>
);
}
interface SettingItemProps {
label: string;
description?: string;
children?: React.ReactNode;
locked?: boolean;
noBorder?: boolean;
}
export function SettingItem({
label,
description,
children,
locked,
noBorder,
}: SettingItemProps) {
return (
<View style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked]}>
<View style={styles.settingContent}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
{locked && <Lock size={12} color={colors.mutedForeground} />}
</View>
{description && <Text style={styles.settingDescription}>{description}</Text>}
</View>
{children && <View style={styles.settingRight}>{children}</View>}
</View>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
return (
<ToggleSwitchComponent
value={checked}
onValueChange={onChange}
disabled={disabled}
/>
);
}
interface RadioGroupProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
style?: ViewStyle;
}
export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) {
return (
<View style={[styles.radioGroup, style]}>
{options.map((opt) => {
const selected = opt.value === value;
return (
<Pressable
key={opt.value}
onPress={() => onChange(opt.value)}
style={[
styles.radioOption,
selected ? styles.radioSelected : styles.radioUnselected,
]}
>
<Text style={[
styles.radioText,
selected ? styles.radioTextSelected : styles.radioTextUnselected,
]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
);
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
style?: ViewStyle;
}
export function Select({ value, onChange, options, style }: SelectProps) {
const [open, setOpen] = useState(false);
const current = options.find((o) => o.value === value);
return (
<>
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={colors.mutedForeground} />
</Pressable>
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={() => setOpen(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
<View style={styles.modalContent}>
<ScrollView>
{options.map((opt) => {
const selected = opt.value === value;
return (
<Pressable
key={opt.value}
style={({ pressed }) => [
styles.selectItem,
pressed && styles.selectItemPressed,
]}
onPress={() => {
onChange(opt.value);
setOpen(false);
}}
>
<Text style={styles.selectItemText}>{opt.label}</Text>
{selected && <Check size={16} color={colors.primary} />}
</Pressable>
);
})}
</ScrollView>
</View>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
section: {
gap: spacing.lg,
},
sectionBody: {
gap: spacing.lg,
},
sectionTitle: {
...typography.h3,
color: colors.text,
},
sectionDescription: {
...typography.body,
color: colors.mutedForeground,
marginTop: 4,
},
experimentalBanner: {
flexDirection: 'row',
gap: spacing.md,
borderWidth: 1,
borderColor: 'rgba(252, 211, 77, 0.4)',
backgroundColor: 'rgba(120, 53, 15, 0.25)',
padding: spacing.md,
borderRadius: radius.md,
},
experimentalTitle: {
...typography.bodyMedium,
color: '#fcd34d',
},
experimentalDesc: {
...typography.caption,
color: '#fde68a',
marginTop: 2,
},
settingItem: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
paddingVertical: spacing.md,
gap: spacing.md,
},
settingItemBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
locked: {
opacity: 0.6,
},
settingContent: {
flex: 1,
paddingRight: spacing.lg,
},
settingLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
settingLabel: {
...typography.bodyMedium,
color: colors.text,
},
settingDescription: {
...typography.caption,
color: colors.mutedForeground,
marginTop: 2,
},
settingRight: {
flexShrink: 0,
},
radioGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
radioOption: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
},
radioSelected: {
backgroundColor: colors.primary,
},
radioUnselected: {
backgroundColor: colors.muted,
},
radioText: {
...typography.caption,
},
radioTextSelected: {
color: colors.primaryForeground,
fontWeight: '500',
},
radioTextUnselected: {
color: colors.text,
},
select: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: colors.muted,
borderWidth: 1,
borderColor: colors.border,
minWidth: 120,
},
selectText: {
...typography.body,
color: colors.text,
flex: 1,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
modalContent: {
width: '100%',
maxWidth: 320,
maxHeight: '70%',
backgroundColor: colors.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.sm,
},
selectItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
selectItemPressed: {
backgroundColor: colors.accent,
},
selectItemText: {
...typography.body,
color: colors.text,
},
});
+146 -7
View File
@@ -190,7 +190,132 @@ export function getTimedEventBoundsForDay(
};
}
// ─── Overlapping event layout (column packing) ───────────
// ─── Timed events that fill the day ──────────────────────
// Events shown in the timed grid that effectively span 00:00–24:00 on the
// given day get promoted to the all-day strip in the week/day views (matches
// webmail behavior — keeps the timed grid usable when an event covers the day).
export function isTimedEventFullDayOnDate(event: CalendarEvent, day: Date): boolean {
const bounds = getTimedEventBoundsForDay(event, day);
return bounds?.startMinutes === 0 && bounds?.endMinutes === 1440;
}
// ─── Multi-day all-day segment packing (week strip) ──────
// Webmail draws multi-day all-day events as a single bar across the days they
// span. RN was previously rendering one chip per day, which broke continuity.
export interface CalendarWeekSegment {
event: CalendarEvent;
startIndex: number; // first day index in weekDays this segment covers
span: number; // number of days
row: number; // assigned row after packing
continuesBefore: boolean;
continuesAfter: boolean;
}
export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] {
rawSegments.sort((left, right) => {
if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex;
if (left.span !== right.span) return right.span - left.span;
if (left.event.showWithoutTime !== right.event.showWithoutTime) {
return left.event.showWithoutTime ? -1 : 1;
}
const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime();
if (timeDiff !== 0) return timeDiff;
return (left.event.title || '').localeCompare(right.event.title || '');
});
const rowEndIndices: number[] = [];
return rawSegments.map((segment) => {
const segmentEndIndex = segment.startIndex + segment.span - 1;
let row = rowEndIndices.findIndex((endIndex) => endIndex < segment.startIndex);
if (row === -1) {
row = rowEndIndices.length;
rowEndIndices.push(segmentEndIndex);
} else {
rowEndIndices[row] = segmentEndIndex;
}
return { ...segment, row };
});
}
export function buildWeekSegmentsRaw(
events: CalendarEvent[],
weekDays: Date[],
): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const weekStart = startOfDay(weekDays[0]);
const weekEnd = startOfDay(weekDays[weekDays.length - 1]);
return events.flatMap((event) => {
const { startDay, endDay } = getEventDayBounds(event);
if (endDay < weekStart || startDay > weekEnd) return [];
const segmentStart = startDay < weekStart ? weekStart : startDay;
const segmentEnd = endDay > weekEnd ? weekEnd : endDay;
const startIndex = differenceInCalendarDays(segmentStart, weekStart);
const span = differenceInCalendarDays(segmentEnd, segmentStart) + 1;
return [{
event,
startIndex,
span,
row: -1,
continuesBefore: startDay < weekStart,
continuesAfter: endDay > weekEnd,
} satisfies CalendarWeekSegment];
});
}
export function buildTimedFullDayWeekSegments(
events: CalendarEvent[],
weekDays: Date[],
): CalendarWeekSegment[] {
if (weekDays.length === 0) return [];
const rawSegments = events.flatMap((event) => {
const fullDayIndices = weekDays
.map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1))
.filter((index) => index >= 0);
if (fullDayIndices.length === 0) return [];
const segments: CalendarWeekSegment[] = [];
let rangeStart = fullDayIndices[0];
let previousIndex = fullDayIndices[0];
const pushSegment = (startIndex: number, endIndex: number) => {
const startDay = weekDays[startIndex];
const endDay = weekDays[endIndex];
segments.push({
event,
startIndex,
span: endIndex - startIndex + 1,
row: -1,
continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)),
continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)),
});
};
for (let index = 1; index < fullDayIndices.length; index++) {
const currentIndex = fullDayIndices[index];
if (currentIndex !== previousIndex + 1) {
pushSegment(rangeStart, previousIndex);
rangeStart = currentIndex;
}
previousIndex = currentIndex;
}
pushSegment(rangeStart, previousIndex);
return segments;
});
return rawSegments;
}
// ─── Overlapping timed-event layout (cluster-based packing) ──
// Webmail rewrote this to track clusters: a new cluster starts when an event
// begins after every prior event has ended. Within a cluster every event gets
// the same `totalColumns`; across clusters, events get to use the full width
// they actually need. The previous global packing forced an event with no
// neighbors at 17:00 to share columns with a 9am cluster — visible squish.
export interface TimedEventLayout {
event: CalendarEvent;
column: number;
@@ -213,13 +338,29 @@ export function layoutOverlappingEvents(
const sorted = layoutInputs.sort((a, b) => {
const diff = a.startMinutes - b.startMinutes;
if (diff !== 0) return diff;
return b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes);
return (b.endMinutes - b.startMinutes) - (a.endMinutes - a.startMinutes);
});
const columns: { end: number }[][] = [];
const result: TimedEventLayout[] = [];
let columns: { end: number }[][] = [];
let clusterStart = 0;
let clusterMaxEnd = 0;
const flushCluster = () => {
const total = columns.length;
for (let i = clusterStart; i < result.length; i++) {
result[i].totalColumns = total;
}
};
for (const item of sorted) {
if (columns.length > 0 && item.startMinutes >= clusterMaxEnd) {
flushCluster();
clusterStart = result.length;
columns = [];
clusterMaxEnd = 0;
}
let placed = false;
for (let col = 0; col < columns.length; col++) {
if (columns[col].every((e) => e.end <= item.startMinutes)) {
@@ -233,12 +374,10 @@ export function layoutOverlappingEvents(
columns.push([{ end: item.endMinutes }]);
result.push({ ...item, column: columns.length - 1, totalColumns: 0 });
}
clusterMaxEnd = Math.max(clusterMaxEnd, item.endMinutes);
}
const total = columns.length || 1;
result.forEach((r) => {
r.totalColumns = total;
});
flushCluster();
return result;
}
+106
View File
@@ -0,0 +1,106 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules } from 'react-native';
import { jmapClient } from '../api/jmap-client';
import { getEmails, getMailboxes, queryEmails } from '../api/email';
import {
generateEmailAvatarColor,
getEmailInitials,
getFaviconDomain,
getFaviconUrl,
} from './avatar-utils';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
interface ShowNotificationOptions {
notificationId: string;
title: string;
body: string;
initials: string;
bgColorHex: string;
iconUrl?: string;
emailId: string;
threadId: string;
subject?: string;
}
interface BulwarkFcmNative {
showNotification(opts: ShowNotificationOptions): Promise<void>;
}
// Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a
// short-lived headless JS runtime — keep it fast, catch all errors, and always
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
try {
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
const loaded = await jmapClient.loadAccount(accountId);
if (!loaded) return;
const mailboxes = await getMailboxes();
const inbox = mailboxes.find((m) => m.role === 'inbox');
if (!inbox) return;
const { ids } = await queryEmails(inbox.id, {
filter: { notKeyword: '$seen' },
limit: 1,
});
if (ids.length === 0) return;
const emailId = ids[0];
const previouslyNotified = await AsyncStorage.getItem(LAST_NOTIFIED_EMAIL_ID_KEY);
if (previouslyNotified === emailId) return;
const [email] = await getEmails([emailId]);
if (!email) return;
const from = email.from?.[0];
const name = from?.name ?? '';
const address = from?.email ?? '';
const title = name || address || 'New mail';
const body = email.subject || '(no subject)';
const initials = getEmailInitials(name, address);
const bgColorHex = hslToHex(generateEmailAvatarColor(name, address));
const faviconDomain = getFaviconDomain(address);
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
await native.showNotification({
notificationId: `mail:${emailId}`,
title,
body,
initials,
bgColorHex,
iconUrl,
emailId,
threadId: email.threadId,
subject: email.subject ?? undefined,
});
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
} catch (error) {
console.warn(
'[push] background task failed:',
error instanceof Error ? error.message : error,
);
}
}
function hslToHex(hsl: string): string {
const match = hsl.match(/hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)/);
if (!match) return '#2563eb';
const h = Number(match[1]);
const s = Number(match[2]) / 100;
const l = Number(match[3]) / 100;
const a = s * Math.min(l, 1 - l);
const component = (n: number): string => {
const k = (n + h / 30) % 12;
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
return Math.round(255 * c).toString(16).padStart(2, '0');
};
return `#${component(0)}${component(8)}${component(4)}`;
}
+292
View File
@@ -0,0 +1,292 @@
import { NativeEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
createPushSubscription,
destroyPushSubscription,
listPushSubscriptions,
verifyPushSubscription,
} from '../api/push';
import { jmapClient } from '../api/jmap-client';
import { generateAccountId } from './account-utils';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. deviceClientId also routes to the relay slot.
const DEVICE_CLIENT_ID_KEY = 'push:deviceClientId:v1';
const SUBSCRIPTION_ID_KEY = 'push:subscriptionId:v1';
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Hosted relay so users don't need to run their own Firebase project. The
// relay only ever sees FCM tokens + JMAP state-id hashes — no mail content.
// Power users can override this from the settings screen.
export const DEFAULT_RELAY_BASE_URL = 'https://notifications.relay.bulwarkmail.org';
// Types the mobile app wants StateChange pings for. Submission is excluded —
// outgoing mail state changes don't belong in a user-visible push.
const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const;
type BulwarkFcmNative = {
getToken(): Promise<string>;
deleteToken(): Promise<void>;
};
function getNative(): BulwarkFcmNative | null {
if (Platform.OS !== 'android') return null;
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
}
export interface PushSetupParams {
// Optional — falls back to the hosted relay if omitted.
relayBaseUrl?: string;
accountLabel?: string;
}
export interface PushSetupResult {
subscriptionId: string;
verified: boolean;
}
function randomClientId(): string {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function getOrCreateDeviceClientId(): Promise<string> {
const existing = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (existing) return existing;
const next = randomClientId();
await AsyncStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
return next;
}
export async function getStoredRelayBaseUrl(): Promise<string | null> {
return AsyncStorage.getItem(RELAY_BASE_URL_KEY);
}
export async function getEffectiveRelayBaseUrl(): Promise<string> {
const stored = await AsyncStorage.getItem(RELAY_BASE_URL_KEY);
return stored ?? DEFAULT_RELAY_BASE_URL;
}
export async function setStoredRelayBaseUrl(url: string | null): Promise<void> {
if (!url) {
await AsyncStorage.removeItem(RELAY_BASE_URL_KEY);
} else {
await AsyncStorage.setItem(RELAY_BASE_URL_KEY, url.replace(/\/+$/, ''));
}
}
export async function requestNotificationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version < 33) return true;
const status = await PermissionsAndroid.request(
'android.permission.POST_NOTIFICATIONS' as Parameters<typeof PermissionsAndroid.request>[0],
);
return status === PermissionsAndroid.RESULTS.GRANTED;
}
export async function getFcmToken(): Promise<string | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getToken();
} catch {
return null;
}
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
async function registerWithRelay(params: {
relayBaseUrl: string;
subscriptionId: string;
fcmToken: string;
accountLabel?: string;
}): Promise<void> {
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
fcmToken: params.fcmToken,
accountLabel: params.accountLabel,
}),
});
if (!res.ok) {
throw new Error(`Relay register failed: ${res.status}`);
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
const timeoutAt = Date.now() + 20_000;
let delay = 400;
while (Date.now() < timeoutAt) {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 2000);
}
throw new Error('Timed out waiting for PushVerification from JMAP server');
}
/**
* Full setup flow: ask permission, fetch the device's FCM token, register
* with the relay, create a JMAP PushSubscription, poll for the verification
* code, and finalise the subscription.
*/
export async function setupPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult> {
const native = getNative();
if (!native) throw new Error('Push notifications require Android');
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
const granted = await requestNotificationPermission();
if (!granted) throw new Error('Notification permission denied');
const fcmToken = await native.getToken();
if (!fcmToken) throw new Error('FCM token unavailable');
const deviceClientId = await getOrCreateDeviceClientId();
await setStoredRelayBaseUrl(relayBaseUrl);
// Persist the account id so the background push task can reload credentials
// from SecureStore without the main app being running.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (username && serverUrl) {
await AsyncStorage.setItem(
PUSH_ACCOUNT_ID_KEY,
generateAccountId(username, serverUrl),
);
}
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken,
accountLabel: params.accountLabel,
});
// Reuse the previous JMAP subscription when the server still has it.
const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
const existing = await listPushSubscriptions().catch(() => []);
if (existing.some((s) => s.id === storedServerId)) {
return { subscriptionId: storedServerId, verified: true };
}
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
const serverAssignedId = await createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
});
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await verifyPushSubscription(serverAssignedId, verificationCode);
await AsyncStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
return { subscriptionId: serverAssignedId, verified: true };
}
/**
* Tear down push on logout / disable. Removes the server-side JMAP
* subscription, tells the relay to drop the mapping, and clears the local
* FCM token. Never throws — callers treat teardown as best effort.
*/
export async function teardownPushNotifications(): Promise<void> {
const storedId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY);
const relayBaseUrl = await getStoredRelayBaseUrl();
const deviceClientId = await AsyncStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (storedId) {
await destroyPushSubscription(storedId).catch(() => undefined);
await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
if (relayBaseUrl && deviceClientId) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
await AsyncStorage.removeItem(PUSH_ACCOUNT_ID_KEY);
await AsyncStorage.removeItem(LAST_NOTIFIED_EMAIL_ID_KEY);
const native = getNative();
if (native) {
await native.deleteToken().catch(() => undefined);
}
}
export type FcmMessageListener = (payload: {
title: string;
body: string;
data: Record<string, string>;
}) => void;
export function addMessageListener(listener: FcmMessageListener): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:message', listener);
return () => sub.remove();
}
export type FcmTokenListener = (payload: { token: string }) => void;
export function addTokenRefreshListener(listener: FcmTokenListener): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:newToken', listener);
return () => sub.remove();
}
export interface NotificationTapPayload {
emailId: string;
threadId: string;
subject?: string;
}
// Returns — and clears — any pending "notification tap" that launched the app
// before JS was ready to handle it. Subsequent taps while running are delivered
// via addNotificationTapListener.
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
if (Platform.OS !== 'android') return null;
const native = (NativeModules as Record<string, unknown>).BulwarkFcm as
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
| undefined;
if (!native?.getInitialNotification) return null;
try {
return (await native.getInitialNotification()) ?? null;
} catch {
return null;
}
}
export function addNotificationTapListener(
listener: (payload: NotificationTapPayload) => void,
): () => void {
if (Platform.OS !== 'android') return () => undefined;
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
const sub = emitter.addListener('fcm:notificationTap', listener);
return () => sub.remove();
}
+8
View File
@@ -0,0 +1,8 @@
export function toWildcardQuery(query: string): string {
return query
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => (word.endsWith('*') || word.endsWith('"') ? word : word + '*'))
.join(' ');
}
+43
View File
@@ -10,6 +10,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
ArrowLeft, Edit2, Trash2, Mail, Phone, MessageSquare, Share2, MapPin,
Building, Briefcase, Cake, Heart, Globe, Tag, Users, FileText, BookUser,
Copy,
} from 'lucide-react-native';
import type { RootStackParamList } from '../navigation/types';
import type { ContactCard } from '../api/types';
@@ -22,6 +23,7 @@ import {
import { contactToVCard } from '../lib/vcard';
import SenderAvatar from '../components/SenderAvatar';
import Dialog from '../components/Dialog';
import { ContactActivity } from '../components/contacts/ContactActivity';
import { colors, spacing, radius, typography } from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
@@ -79,6 +81,7 @@ export default function ContactDetailScreen() {
const contact = useContactsStore((s) => s.contacts.find((c) => c.id === contactId));
const addressBooks = useContactsStore((s) => s.addressBooks);
const deleteContact = useContactsStore((s) => s.deleteContact);
const createContact = useContactsStore((s) => s.createContact);
const [confirmDelete, setConfirmDelete] = React.useState(false);
@@ -147,6 +150,35 @@ export default function ContactDetailScreen() {
}
};
const doDuplicate = async () => {
const targetBookId = bookIds[0] || addressBooks[0]?.id;
if (!targetBookId) {
Alert.alert('No address book', 'Cannot duplicate without an address book.');
return;
}
// Strip server-managed identity fields and re-create as a new card.
const {
id: _id,
addressBookIds: _abIds,
created: _created,
updated: _updated,
...rest
} = contact;
const baseName =
(rest.name?.full ? `${rest.name.full} (Copy)` : null) ??
`${name} (Copy)`;
const draft: Partial<ContactCard> = {
...rest,
name: rest.name ? { ...rest.name, full: baseName } : { full: baseName },
};
try {
const created = await createContact(draft, targetBookId);
navigation.replace('ContactDetail', { contactId: created.id });
} catch (err) {
Alert.alert('Duplicate failed', err instanceof Error ? err.message : 'Unknown error');
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
@@ -155,6 +187,15 @@ export default function ContactDetailScreen() {
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>{name}</Text>
<View style={styles.headerActions}>
{!isGroup(contact) && (
<Pressable
onPress={() => { void doDuplicate(); }}
style={styles.headerBtn}
hitSlop={8}
>
<Copy size={18} color={colors.text} />
</Pressable>
)}
<Pressable
onPress={() => navigation.navigate('ContactForm', { contactId: contact.id })}
style={styles.headerBtn}
@@ -362,6 +403,8 @@ export default function ContactDetailScreen() {
))}
</Section>
)}
{!isGroup(contact) && <ContactActivity contact={contact} />}
</ScrollView>
<Dialog
+14 -6
View File
@@ -28,6 +28,7 @@ import { getContactDisplayName, isGroup, matchesContactSearch } from '../lib/con
import { ContactListRow } from '../components/contacts';
import Dialog from '../components/Dialog';
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
import { useSettingsStore } from '../stores/settings-store';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -88,6 +89,7 @@ export default function ContactsScreen() {
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const setSelectedCategory = useContactsStore((s) => s.setSelectedCategory);
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
const [searchQuery, setSearchQuery] = React.useState('');
const [searchActive, setSearchActive] = React.useState(false);
@@ -137,7 +139,11 @@ export default function ContactsScreen() {
return filtered.filter((c) => matchesContactSearch(c, searchQuery));
}, [contacts, individuals, selectedCategory, searchQuery]);
const sections = React.useMemo(() => groupContacts(visible), [visible]);
const sections = React.useMemo<Section[]>(() => {
if (groupByLetter) return groupContacts(visible);
// Flat list — single unnamed section keeps SectionList rendering simple.
return [{ title: '', data: sortContactsByDisplayName(visible) }];
}, [visible, groupByLetter]);
const selectionMode = selection.size > 0;
const toggleSelect = (id: string) => {
@@ -323,11 +329,13 @@ export default function ContactsScreen() {
selectionMode={selectionMode}
/>
)}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
)}
renderSectionHeader={({ section }) =>
section.title ? (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{section.title}</Text>
</View>
) : null
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
stickySectionHeadersEnabled
contentContainerStyle={styles.listContent}
+588 -106
View File
@@ -1,9 +1,10 @@
import React from 'react';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, TouchableOpacity, TouchableWithoutFeedback } from 'react-native';
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
Search, SquarePen, Menu, Filter, Square, X, Check,
Star, Paperclip, Mail as MailIcon,
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import SidebarDrawer from '../components/SidebarDrawer';
@@ -42,7 +43,19 @@ function formatRelativeTime(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function EmailRow({ item, onPress }: { item: Email; onPress: () => void }) {
function EmailRow({
item,
onPress,
onLongPress,
selected,
selectionMode,
}: {
item: Email;
onPress: () => void;
onLongPress: () => void;
selected: boolean;
selectionMode: boolean;
}) {
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -50,9 +63,24 @@ function EmailRow({ item, onPress }: { item: Email; onPress: () => void }) {
return (
<Pressable
style={({ pressed }) => [styles.emailRow, pressed && styles.emailRowPressed]}
style={({ pressed }) => [
styles.emailRow,
pressed && styles.emailRowPressed,
selected && styles.emailRowSelected,
]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={300}
>
{selectionMode && (
<View style={styles.rowCheckboxWrap}>
{selected ? (
<SquareCheck size={16} color={colors.primary} />
) : (
<Square size={16} color={colors.textMuted} />
)}
</View>
)}
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
{/* Content */}
@@ -113,6 +141,77 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const setSearchQuery = useEmailStore((s) => s.setSearchQuery);
const setFilters = useEmailStore((s) => s.setFilters);
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
const markRead = useEmailStore((s) => s.markRead);
const markUnread = useEmailStore((s) => s.markUnread);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
const allSelected =
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
const toggleSelect = React.useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = React.useCallback(() => {
setSelectedIds(new Set());
}, []);
const toggleSelectAllVisible = React.useCallback(() => {
setSelectedIds((prev) => {
const allCurrent = emails.length > 0 && emails.every((e) => prev.has(e.id));
if (allCurrent) return new Set();
return new Set(emails.map((e) => e.id));
});
}, [emails]);
// Clear selection when mailbox changes
React.useEffect(() => {
setSelectedIds(new Set());
}, [currentMailboxId]);
const selectedEmails = React.useMemo(
() => emails.filter((e) => selectedIds.has(e.id)),
[emails, selectedIds],
);
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
const handleBulkMarkReadToggle = async () => {
const ids = Array.from(selectedIds);
if (allSelectedAreRead) {
await Promise.all(ids.map((id) => markUnread(id)));
} else {
await Promise.all(ids.map((id) => markRead(id)));
}
clearSelection();
};
const handleBulkStar = async () => {
const ids = Array.from(selectedIds);
const next = !allSelectedAreStarred;
await Promise.all(ids.map((id) => toggleStar(id, next)));
clearSelection();
};
const handleBulkDelete = async () => {
const trash = mailboxes.find((m) => m.role === 'trash');
if (!trash || !currentMailboxId) {
clearSelection();
return;
}
const ids = Array.from(selectedIds);
await Promise.all(ids.map((id) => deleteEmailAction(id, trash.id, currentMailboxId)));
clearSelection();
};
// Local input state for uninterrupted typing; debounce into the store.
const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
@@ -131,13 +230,35 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [searchInput, storeSearchQuery, setSearchQuery]);
const activeFilterCount =
(filters.unread ? 1 : 0) + (filters.starred ? 1 : 0) + (filters.hasAttachment ? 1 : 0);
(filters.from ? 1 : 0) +
(filters.to ? 1 : 0) +
(filters.subject ? 1 : 0) +
(filters.dateAfter ? 1 : 0) +
(filters.dateBefore ? 1 : 0) +
(filters.hasAttachment !== undefined ? 1 : 0) +
(filters.isStarred !== undefined ? 1 : 0) +
(filters.isUnread !== undefined ? 1 : 0);
const hasActiveSearchOrFilter = Boolean(storeSearchQuery) || activeFilterCount > 0;
const toggleFilter = (key: keyof EmailFilters) => {
setFilters({ ...filters, [key]: !filters[key] });
const cycleTriState = (key: 'hasAttachment' | 'isStarred' | 'isUnread') => {
const current = filters[key];
// unset → true → false → unset
const next = current === undefined ? true : current === true ? false : undefined;
const updated: EmailFilters = { ...filters };
if (next === undefined) delete updated[key];
else updated[key] = next;
setFilters(updated);
};
const setFilterField = (key: keyof EmailFilters, value: string | undefined) => {
const updated: EmailFilters = { ...filters };
if (!value) delete updated[key];
else (updated as Record<string, unknown>)[key] = value;
setFilters(updated);
};
const [datePickerField, setDatePickerField] = React.useState<'dateAfter' | 'dateBefore' | null>(null);
const currentMailbox = React.useMemo(
() => mailboxes.find((m) => m.id === currentMailboxId),
[mailboxes, currentMailboxId],
@@ -160,23 +281,69 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={colors.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
resizeMode="contain"
/>
</View>
{selectionMode ? (
<View style={styles.header}>
<Pressable onPress={clearSelection} style={styles.headerButton}>
<X size={20} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle}>{selectedIds.size} selected</Text>
<Pressable
onPress={() => { void handleBulkStar(); }}
style={styles.headerButton}
hitSlop={6}
>
<Star
size={20}
color={allSelectedAreStarred ? colors.starred : colors.text}
fill={allSelectedAreStarred ? colors.starred : 'transparent'}
/>
</Pressable>
<Pressable
onPress={() => { void handleBulkMarkReadToggle(); }}
style={styles.headerButton}
hitSlop={6}
>
{allSelectedAreRead ? (
<MailIcon size={20} color={colors.text} />
) : (
<MailOpen size={20} color={colors.text} />
)}
</Pressable>
<Pressable
onPress={() => { void handleBulkDelete(); }}
style={styles.headerButton}
hitSlop={6}
>
<Trash2 size={20} color={colors.text} />
</Pressable>
</View>
) : (
<View style={styles.header}>
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
<Menu size={20} color={colors.textMuted} />
</Pressable>
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
<View style={{ flex: 1 }} />
<Image
source={require('../../assets/logos/Bulwark Logo White.png')}
style={styles.headerLogo}
resizeMode="contain"
/>
</View>
)}
{/* Search bar (always visible) */}
<View style={styles.searchBar}>
<Pressable style={styles.checkboxButton}>
<Square size={18} color={colors.textMuted} />
<Pressable style={styles.checkboxButton} onPress={toggleSelectAllVisible} hitSlop={6}>
{allSelected ? (
<SquareCheck size={18} color={colors.primary} />
) : selectionMode ? (
<View style={styles.checkboxIndeterminate}>
<Minus size={14} color={colors.background} />
</View>
) : (
<Square size={18} color={colors.textMuted} />
)}
</Pressable>
<View style={styles.searchInputArea}>
<Search size={16} color={colors.textMuted} />
@@ -223,22 +390,55 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</Text>
</View>
) : null}
{filters.unread && (
{filters.from ? (
<View style={styles.chip}>
<MailIcon size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>Unread</Text>
<Text style={styles.chipText} numberOfLines={1}>From: {filters.from}</Text>
</View>
) : null}
{filters.to ? (
<View style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>To: {filters.to}</Text>
</View>
) : null}
{filters.subject ? (
<View style={styles.chip}>
<Text style={styles.chipText} numberOfLines={1}>Subject: {filters.subject}</Text>
</View>
) : null}
{filters.dateAfter ? (
<View style={styles.chip}>
<CalendarDays size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>After {filters.dateAfter}</Text>
</View>
) : null}
{filters.dateBefore ? (
<View style={styles.chip}>
<CalendarDays size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>Before {filters.dateBefore}</Text>
</View>
) : null}
{filters.isUnread !== undefined && (
<View style={styles.chip}>
{filters.isUnread ? (
<MailIcon size={12} color={colors.textSecondary} />
) : (
<MailOpen size={12} color={colors.textSecondary} />
)}
<Text style={styles.chipText}>{filters.isUnread ? 'Unread' : 'Read'}</Text>
</View>
)}
{filters.starred && (
{filters.isStarred !== undefined && (
<View style={styles.chip}>
<Star size={12} color={colors.starred} fill={colors.starred} />
<Text style={styles.chipText}>Starred</Text>
<Star size={12} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />
<Text style={styles.chipText}>{filters.isStarred ? 'Starred' : 'Not starred'}</Text>
</View>
)}
{filters.hasAttachment && (
{filters.hasAttachment !== undefined && (
<View style={styles.chip}>
<Paperclip size={12} color={colors.textSecondary} />
<Text style={styles.chipText}>Has attachment</Text>
<Text style={styles.chipText}>
{filters.hasAttachment ? 'Has attachment' : 'No attachment'}
</Text>
</View>
)}
<Pressable
@@ -290,7 +490,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
data={emails}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<EmailRow item={item} onPress={() => onEmailPress?.(item)} />
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={() => {
if (selectionMode) toggleSelect(item.id);
else onEmailPress?.(item);
}}
onLongPress={() => toggleSelect(item.id)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.listContent}
@@ -321,57 +530,218 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<View style={styles.filterBackdrop}>
<TouchableWithoutFeedback>
<View style={styles.filterMenu}>
<Text style={styles.filterMenuTitle}>Filter emails</Text>
<FilterToggle
label="Unread"
icon={<MailIcon size={16} color={colors.textSecondary} />}
active={!!filters.unread}
onPress={() => toggleFilter('unread')}
/>
<FilterToggle
label="Starred"
icon={<Star size={16} color={colors.starred} fill={filters.starred ? colors.starred : 'transparent'} />}
active={!!filters.starred}
onPress={() => toggleFilter('starred')}
/>
<FilterToggle
label="Has attachment"
icon={<Paperclip size={16} color={colors.textSecondary} />}
active={!!filters.hasAttachment}
onPress={() => toggleFilter('hasAttachment')}
/>
{activeFilterCount > 0 && (
<TouchableOpacity
style={styles.filterClearRow}
onPress={() => setFilters({})}
>
<Text style={styles.filterClearText}>Clear filters</Text>
</TouchableOpacity>
)}
<View style={styles.filterMenuHeader}>
<Text style={styles.filterMenuTitle}>Filter emails</Text>
<View style={styles.filterMenuHeaderActions}>
<Pressable
onPress={() => setFilters({})}
style={styles.filterMenuHeaderBtn}
hitSlop={6}
>
<RotateCcw size={12} color={colors.textSecondary} />
<Text style={styles.filterMenuHeaderBtnText}>Clear</Text>
</Pressable>
<Pressable
onPress={() => setFilterMenuOpen(false)}
style={styles.filterMenuClose}
hitSlop={6}
>
<X size={16} color={colors.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView contentContainerStyle={styles.filterMenuBody} keyboardShouldPersistTaps="handled">
<View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>From</Text>
<TextInput
value={filters.from ?? ''}
onChangeText={(v) => setFilterField('from', v)}
placeholder="sender@example.com"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.filterFieldInput}
/>
</View>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>To</Text>
<TextInput
value={filters.to ?? ''}
onChangeText={(v) => setFilterField('to', v)}
placeholder="recipient@example.com"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.filterFieldInput}
/>
</View>
</View>
<View>
<Text style={styles.filterFieldLabel}>Subject</Text>
<TextInput
value={filters.subject ?? ''}
onChangeText={(v) => setFilterField('subject', v)}
placeholder="Subject contains..."
placeholderTextColor={colors.textMuted}
style={styles.filterFieldInput}
/>
</View>
<View style={styles.filterFieldRow}>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>After</Text>
<Pressable
style={styles.filterDateButton}
onPress={() => setDatePickerField('dateAfter')}
>
<CalendarDays size={14} color={colors.textMuted} />
<Text style={[styles.filterDateText, !filters.dateAfter && styles.filterDateTextEmpty]}>
{filters.dateAfter || 'YYYY-MM-DD'}
</Text>
{filters.dateAfter ? (
<Pressable
onPress={() => setFilterField('dateAfter', undefined)}
hitSlop={6}
>
<X size={14} color={colors.textMuted} />
</Pressable>
) : null}
</Pressable>
</View>
<View style={styles.filterFieldHalf}>
<Text style={styles.filterFieldLabel}>Before</Text>
<Pressable
style={styles.filterDateButton}
onPress={() => setDatePickerField('dateBefore')}
>
<CalendarDays size={14} color={colors.textMuted} />
<Text style={[styles.filterDateText, !filters.dateBefore && styles.filterDateTextEmpty]}>
{filters.dateBefore || 'YYYY-MM-DD'}
</Text>
{filters.dateBefore ? (
<Pressable
onPress={() => setFilterField('dateBefore', undefined)}
hitSlop={6}
>
<X size={14} color={colors.textMuted} />
</Pressable>
) : null}
</Pressable>
</View>
</View>
<View style={styles.filterToggleGroup}>
<TriToggle
icon={<Paperclip size={14} color={colors.textSecondary} />}
label="Has attachment"
value={filters.hasAttachment}
onPress={() => cycleTriState('hasAttachment')}
/>
<TriToggle
icon={<Star size={14} color={colors.starred} fill={filters.isStarred ? colors.starred : 'transparent'} />}
label="Starred"
value={filters.isStarred}
onPress={() => cycleTriState('isStarred')}
/>
<TriToggle
icon={
filters.isUnread === false ? (
<MailOpen size={14} color={colors.textSecondary} />
) : (
<MailIcon size={14} color={colors.textSecondary} />
)
}
label={filters.isUnread === false ? 'Read' : 'Unread'}
value={filters.isUnread}
onPress={() => cycleTriState('isUnread')}
/>
</View>
</ScrollView>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{datePickerField !== null && (() => {
const current = filters[datePickerField];
const initial = current ? new Date(current) : new Date();
const onChange = (event: DateTimePickerEvent, selected?: Date) => {
if (Platform.OS === 'android') {
setDatePickerField(null);
}
if (event.type === 'dismissed' || !selected) return;
const iso = selected.toISOString().slice(0, 10);
setFilterField(datePickerField, iso);
};
if (Platform.OS === 'ios') {
return (
<Modal transparent animationType="fade" onRequestClose={() => setDatePickerField(null)}>
<Pressable style={styles.pickerOverlay} onPress={() => setDatePickerField(null)} />
<View style={styles.pickerSheet}>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setDatePickerField(null)} hitSlop={8}>
<Text style={styles.pickerDone}>Done</Text>
</Pressable>
</View>
<DateTimePicker
value={initial}
mode="date"
display="spinner"
onChange={onChange}
/>
</View>
</Modal>
);
}
return (
<DateTimePicker
value={initial}
mode="date"
display="default"
onChange={onChange}
/>
);
})()}
</SafeAreaView>
);
}
function FilterToggle({
label, icon, active, onPress,
}: { label: string; icon: React.ReactNode; active: boolean; onPress: () => void }) {
function TriToggle({
icon,
label,
value,
onPress,
}: {
icon: React.ReactNode;
label: string;
value: boolean | undefined;
onPress: () => void;
}) {
const state =
value === true ? 'on' : value === false ? 'off' : 'unset';
return (
<Pressable
style={({ pressed }) => [styles.filterToggle, pressed && styles.filterTogglePressed]}
onPress={onPress}
style={[
styles.triToggle,
state === 'on' && styles.triToggleOn,
state === 'off' && styles.triToggleOff,
]}
>
<View style={styles.filterToggleLeft}>
{icon}
<Text style={styles.filterToggleLabel}>{label}</Text>
</View>
<View style={[styles.filterCheck, active && styles.filterCheckActive]}>
{active && <Check size={14} color={colors.background} />}
</View>
{icon}
<Text
style={[
styles.triToggleText,
state === 'on' && styles.triToggleTextOn,
state === 'off' && styles.triToggleTextOff,
]}
>
{label}
</Text>
</Pressable>
);
}
@@ -501,63 +871,159 @@ const styles = StyleSheet.create({
},
filterMenu: {
width: '100%',
maxWidth: 360,
maxWidth: 380,
maxHeight: '85%',
backgroundColor: colors.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.sm,
},
filterMenuTitle: {
...typography.bodySemibold,
color: colors.text,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
filterToggle: {
filterMenuHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
filterTogglePressed: {
backgroundColor: colors.surfaceHover,
},
filterToggleLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
flex: 1,
},
filterToggleLabel: {
...typography.body,
filterMenuTitle: {
...typography.bodySemibold,
color: colors.text,
},
filterCheck: {
width: 20,
height: 20,
filterMenuHeaderActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
filterMenuHeaderBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.xs,
borderWidth: 1.5,
borderColor: colors.border,
},
filterMenuHeaderBtnText: {
...typography.caption,
color: colors.textSecondary,
},
filterMenuClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
filterCheckActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
filterMenuBody: {
padding: spacing.lg,
gap: spacing.md,
},
filterClearRow: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
filterFieldRow: {
flexDirection: 'row',
gap: spacing.sm,
},
filterFieldHalf: { flex: 1 },
filterFieldLabel: {
...typography.caption,
color: colors.textMuted,
marginBottom: 4,
},
filterFieldInput: {
...typography.body,
color: colors.text,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
height: 32,
},
filterDateButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
height: 32,
},
filterDateText: {
...typography.body,
color: colors.text,
flex: 1,
},
filterDateTextEmpty: {
color: colors.textMuted,
},
filterToggleGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.sm,
marginTop: 4,
},
triToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
},
triToggleOn: {
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderColor: 'rgba(59, 130, 246, 0.3)',
},
triToggleOff: {
backgroundColor: colors.muted,
borderColor: colors.border,
},
triToggleText: {
...typography.caption,
color: colors.textMuted,
},
triToggleTextOn: {
color: colors.primary,
},
triToggleTextOff: {
color: colors.textMuted,
textDecorationLine: 'line-through',
},
pickerOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
},
pickerSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: colors.popover,
paddingBottom: spacing.xl,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderTopColor: colors.border,
marginTop: spacing.xs,
borderColor: colors.border,
},
filterClearText: {
pickerHeader: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
pickerDone: {
...typography.bodyMedium,
color: colors.error,
color: colors.primary,
},
// Email list — matches web email-list-item
@@ -571,6 +1037,22 @@ const styles = StyleSheet.create({
borderBottomColor: colors.border, // border-b border-border
},
emailRowPressed: { backgroundColor: colors.surface },
emailRowSelected: { backgroundColor: colors.selection },
rowCheckboxWrap: {
width: 16,
height: componentSizes.avatarMd,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
},
checkboxIndeterminate: {
width: 18,
height: 18,
borderRadius: radius.xs,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
emailContent: { flex: 1, minWidth: 0 },
emailHeaderRow: {
flexDirection: 'row',
+107 -27
View File
@@ -1,14 +1,21 @@
import React from 'react';
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
ArrowLeft, LogOut, Settings,
ArrowLeft, LogOut, Settings, ChevronRight,
Palette, Mail, User, Shield, UserPen, Palmtree, Calendar,
Filter, FileText, FolderOpen, Tags, HardDrive, Wrench,
BookUser, KeyRound, PanelLeftClose, Bell, Puzzle,
type LucideIcon,
} from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import { EmailSettings } from '../components/settings/EmailSettings';
import { NotificationSettings } from '../components/settings/NotificationSettings';
import { AccountSettings } from '../components/settings/AccountSettings';
import { IdentitySettings } from '../components/settings/IdentitySettings';
import { VacationSettings } from '../components/settings/VacationSettings';
import { FolderSettings } from '../components/settings/FolderSettings';
import { AdvancedSettings } from '../components/settings/AdvancedSettings';
type Tab =
| 'appearance' | 'email' | 'notifications'
@@ -25,6 +32,7 @@ interface TabDef {
icon: LucideIcon;
group: TabGroup;
experimental?: boolean;
implemented: boolean;
}
const GROUP_LABELS: Record<TabGroup, string> = {
@@ -38,27 +46,37 @@ const GROUP_LABELS: Record<TabGroup, string> = {
const GROUP_ORDER: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
const TABS: TabDef[] = [
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general' },
{ id: 'email', label: 'Email', icon: Mail, group: 'general' },
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general' },
{ id: 'account', label: 'Account', icon: User, group: 'account' },
{ id: 'security', label: 'Security', icon: Shield, group: 'account' },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account' },
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account' },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account' },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization' },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization' },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization' },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization' },
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps' },
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps' },
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps' },
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps' },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true },
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system' },
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'general', implemented: false },
{ id: 'email', label: 'Email', icon: Mail, group: 'general', implemented: true },
{ id: 'notifications',label: 'Notifications', icon: Bell, group: 'general', implemented: true },
{ id: 'account', label: 'Account', icon: User, group: 'account', implemented: true },
{ id: 'security', label: 'Security', icon: Shield, group: 'account', implemented: false },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'account', implemented: true },
{ id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'account', implemented: false },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'account', implemented: true },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'organization', implemented: false },
{ id: 'templates', label: 'Templates', icon: FileText, group: 'organization', implemented: false },
{ id: 'folders', label: 'Folders', icon: FolderOpen, group: 'organization', implemented: true },
{ id: 'keywords', label: 'Keywords & Labels', icon: Tags, group: 'organization', implemented: false },
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: false },
{ id: 'contacts', label: 'Contacts', icon: BookUser, group: 'apps', implemented: false },
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: false },
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false },
{ id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false },
{ id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false },
{ id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true },
];
const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
email: EmailSettings,
notifications: NotificationSettings,
account: AccountSettings,
identities: IdentitySettings,
vacation: VacationSettings,
folders: FolderSettings,
advanced: AdvancedSettings,
};
function groupTabs() {
return GROUP_ORDER.map(group => ({
group,
@@ -74,8 +92,40 @@ interface SettingsScreenProps {
}
export default function SettingsScreen({ onLogout, onBack, onTabSelect }: SettingsScreenProps) {
const [selectedTab, setSelectedTab] = useState<Tab | null>(null);
const groupedTabs = groupTabs();
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
if (selectedTab) {
const tabDef = TABS.find(t => t.id === selectedTab)!;
const Component = TAB_COMPONENTS[selectedTab];
const TabIcon = tabDef.icon;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable
onPress={() => setSelectedTab(null)}
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
>
<ArrowLeft size={20} color={colors.text} />
</Pressable>
<TabIcon size={20} color={colors.mutedForeground} />
<Text style={styles.headerTitle}>{tabDef.label}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{Component ? <Component /> : null}
</ScrollView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header — matches webmail mobile: h-14, border-b, back button + icon + title */}
@@ -103,16 +153,42 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
{group.items.map((tab) => {
const Icon = tab.icon;
const disabled = !tab.implemented;
return (
<View key={tab.id} style={[styles.tabItem, styles.tabItemDisabled]}>
<Pressable
key={tab.id}
onPress={() => handleTabPress(tab)}
disabled={disabled}
style={({ pressed }) => [
styles.tabItem,
disabled && styles.tabItemDisabled,
!disabled && pressed && styles.tabItemPressed,
]}
>
<View style={styles.tabItemLeft}>
<Icon size={16} color={colors.mutedForeground} />
<Text style={[styles.tabItemLabel, styles.tabItemLabelDisabled]}>{tab.label}</Text>
<Text
style={[
styles.tabItemLabel,
disabled && styles.tabItemLabelDisabled,
]}
>
{tab.label}
</Text>
{tab.experimental && !disabled && (
<View style={styles.experimentalBadge}>
<Text style={styles.experimentalText}>Experimental</Text>
</View>
)}
</View>
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not working yet</Text>
</View>
</View>
{disabled ? (
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not implemented</Text>
</View>
) : (
<ChevronRight size={16} color={colors.mutedForeground} />
)}
</Pressable>
);
})}
</View>
@@ -171,6 +247,10 @@ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: 40,
},
detailContent: {
padding: spacing.lg,
paddingBottom: 40,
},
tabList: {
paddingVertical: spacing.sm,
+50 -2
View File
@@ -6,6 +6,21 @@ import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { generateAccountId } from '../lib/account-utils';
import { teardownPushNotifications } from '../lib/push-notifications';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
// loaded the previous active account, then short-circuit to LoginScreen even
// though the user is actually signed in.
async function waitForHydration(store: { persist: { hasHydrated: () => boolean; onFinishHydration: (cb: () => void) => () => void } }): Promise<void> {
if (store.persist.hasHydrated()) return;
await new Promise<void>((resolve) => {
const unsubscribe = store.persist.onFinishHydration(() => {
unsubscribe();
resolve();
});
});
}
export interface AuthState {
isAuthenticated: boolean;
@@ -35,9 +50,22 @@ function resetFeatureStores(): void {
function refetchFeatureStores(): void {
// Fire-and-forget: each store handles its own errors.
void useEmailStore.getState().fetchMailboxes();
const emailStore = useEmailStore.getState();
void emailStore.fetchMailboxes();
// If a mailbox was selected before this restore (cached from last session),
// refresh its contents so the user sees up-to-date mail without manually
// pulling to refresh.
if (emailStore.currentMailboxId) {
void emailStore.refreshEmails();
}
void useContactsStore.getState().fetchContacts();
void useCalendarStore.getState().fetchCalendars();
const calendarStore = useCalendarStore.getState();
void calendarStore.fetchCalendars();
// Refresh the event range cached from last session (if any) so recurring
// events reflect new invitations / cancellations without the user swiping.
if (calendarStore.loadedRange) {
void calendarStore.refresh();
}
}
function applyConnectedState(
@@ -114,6 +142,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
// Best-effort: revoke the JMAP PushSubscription and drop the relay
// mapping before we lose credentials. Do not abort logout on failure.
await teardownPushNotifications().catch(() => undefined);
// Clear credentials for this account first
if (currentId) {
await jmapClient.clearAccountCredentials(currentId);
@@ -154,6 +186,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logoutAll: async () => {
const accountStore = useAccountStore.getState();
const ids = accountStore.accounts.map((a) => a.id);
await teardownPushNotifications().catch(() => undefined);
await jmapClient.clearAllCredentials(ids);
jmapClient.reset();
resetFeatureStores();
@@ -215,6 +248,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
restoreSession: async () => {
set({ isLoading: true });
try {
// Wait for persisted caches to finish hydrating from AsyncStorage.
// Otherwise we read empty defaults and bounce the user back to the
// login screen — and the feature stores don't have their cached data
// yet when refetchFeatureStores() checks currentMailboxId / loadedRange
// at the end of this function.
await Promise.all([
waitForHydration(useAccountStore),
waitForHydration(useEmailStore),
waitForHydration(useCalendarStore),
waitForHydration(useContactsStore),
]);
const accountStore = useAccountStore.getState();
// Legacy migration: if there are no registered accounts but the old
@@ -254,6 +298,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const session = jmapClient.currentSession!;
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
// Refresh the cached mailbox list + current folder now that the session
// is live. Feature stores show persisted data immediately; this swaps
// in fresh data once the network round-trip completes.
refetchFeatureStores();
return true;
} catch {
set({ isLoading: false, hasRestoredSession: true });
+19 -2
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Calendar, CalendarEvent, StateChange } from '../api/types';
import {
@@ -60,7 +61,9 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR
};
}
export const useCalendarStore = create<CalendarState>((set, get) => ({
export const useCalendarStore = create<CalendarState>()(
persist(
(set, get) => ({
calendars: [],
events: [],
hiddenCalendarIds: [],
@@ -205,7 +208,21 @@ export const useCalendarStore = create<CalendarState>((set, get) => ({
loading: false,
error: null,
}),
}));
}),
{
// Persist calendars + expanded events + the range they cover so the
// calendar renders instantly on re-open. A refresh happens in the
// background and replaces the cached data with fresh copies.
name: 'calendar-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
calendars: state.calendars,
events: state.events,
loadedRange: state.loadedRange,
}),
},
),
);
// ─── Selectors ───────────────────────────────────────────
export function selectVisibleCalendars(state: CalendarState): Calendar[] {
+18 -2
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { ContactCard, AddressBook, StateChange } from '../api/types';
import {
@@ -59,7 +60,9 @@ function persistCategory(category: ContactCategory): void {
);
}
export const useContactsStore = create<ContactsState>((set, get) => ({
export const useContactsStore = create<ContactsState>()(
persist(
(set, get) => ({
addressBooks: [],
contacts: [],
selectedCategory: { type: 'all' },
@@ -185,7 +188,20 @@ export const useContactsStore = create<ContactsState>((set, get) => ({
loading: false,
error: null,
}),
}));
}),
{
// Persist address books and contact cards so the list renders instantly
// on re-open. A refresh runs in the background once the JMAP session is
// ready and replaces the cached data.
name: 'contacts-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
addressBooks: state.addressBooks,
contacts: state.contacts,
}),
},
),
);
// ─── Selectors ───────────────────────────────────────────
+64 -12
View File
@@ -1,4 +1,6 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email, Mailbox } from '../api/types';
import {
getMailboxes as fetchMailboxes,
@@ -10,11 +12,17 @@ import {
deleteEmail as apiDeleteEmail,
searchEmails as apiSearchEmails,
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
export interface EmailFilters {
unread?: boolean;
starred?: boolean;
hasAttachment?: boolean;
from?: string;
to?: string;
subject?: string;
dateAfter?: string; // YYYY-MM-DD
dateBefore?: string; // YYYY-MM-DD
hasAttachment?: boolean; // undefined = unset, true = with, false = without
isStarred?: boolean;
isUnread?: boolean;
}
export interface EmailState {
@@ -49,16 +57,43 @@ function buildJmapFilter(
searchQuery: string,
filters: EmailFilters,
): Record<string, unknown> {
const f: Record<string, unknown> = { inMailbox: mailboxId };
const conditions: Record<string, unknown>[] = [{ inMailbox: mailboxId }];
const trimmed = searchQuery.trim();
if (trimmed) f.text = trimmed;
if (filters.unread) f.notKeyword = '$seen';
if (filters.starred) f.hasKeyword = '$flagged';
if (filters.hasAttachment) f.hasAttachment = true;
return f;
if (trimmed) conditions.push({ text: toWildcardQuery(trimmed) });
if (filters.from) conditions.push({ from: filters.from });
if (filters.to) conditions.push({ to: filters.to });
if (filters.subject) conditions.push({ subject: filters.subject });
if (filters.dateAfter) {
const d = new Date(filters.dateAfter);
if (!isNaN(d.getTime())) conditions.push({ after: d.toISOString() });
}
if (filters.dateBefore) {
const d = new Date(filters.dateBefore);
if (!isNaN(d.getTime())) {
d.setHours(23, 59, 59, 999);
conditions.push({ before: d.toISOString() });
}
}
if (filters.hasAttachment === true) conditions.push({ hasAttachment: true });
else if (filters.hasAttachment === false) conditions.push({ hasAttachment: false });
if (filters.isUnread === true) conditions.push({ notKeyword: '$seen' });
else if (filters.isUnread === false) conditions.push({ hasKeyword: '$seen' });
if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' });
else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' });
if (conditions.length === 1) return conditions[0];
return { operator: 'AND', conditions };
}
export const useEmailStore = create<EmailState>((set, get) => ({
export const useEmailStore = create<EmailState>()(
persist(
(set, get) => ({
mailboxes: [],
currentMailboxId: null,
emails: [],
@@ -122,7 +157,9 @@ export const useEmailStore = create<EmailState>((set, get) => ({
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters } = get();
if (!currentMailboxId) return;
set({ loading: true, error: null, emails: [], totalEmails: 0 });
// Keep existing emails visible while refreshing — swap atomically once
// the new list is ready so pull-to-refresh doesn't flash an empty list.
set({ loading: true, error: null });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
@@ -221,4 +258,19 @@ export const useEmailStore = create<EmailState>((set, get) => ({
searchQuery: '',
filters: {},
}),
}));
}),
{
// Persist the mail list so the UI can render instantly on re-open,
// before the JMAP session has finished restoring. auth-store triggers
// a background refresh once the session is ready.
name: 'email-cache',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
mailboxes: state.mailboxes,
currentMailboxId: state.currentMailboxId,
emails: state.emails,
totalEmails: state.totalEmails,
}),
},
),
);

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