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).
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
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>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "bulwark-mobile",
|
||||
"name": "Bulwark Mobile",
|
||||
"slug": "bulwark-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
|
||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 30 KiB |
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||