mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82b97fada4 | ||
|
|
428a1bf03d | ||
|
|
09350dd1f0 | ||
|
|
d2d13a37c9 | ||
|
|
be82bfe814 | ||
|
|
5c775ad82e | ||
|
|
941e5ede12 | ||
|
|
2577f4e64b | ||
|
|
ee21c15fec | ||
|
|
e574fd9e11 | ||
|
|
dfa278f7a8 | ||
|
|
4eeda34d95 | ||
|
|
22ad291202 | ||
|
|
f040a48e2f | ||
|
|
c29cbcd549 | ||
|
|
bf67b97176 | ||
|
|
a2484f3508 | ||
|
|
82997e27e4 | ||
|
|
b7995fafde | ||
|
|
cde461999f | ||
|
|
802e1e1c71 | ||
|
|
2427bdbd79 | ||
|
|
05e2af23dd | ||
|
|
42f18a78b2 | ||
|
|
406d2c208a | ||
|
|
6f712bf43f | ||
|
|
844cb703c8 | ||
|
|
11e5963149 | ||
|
|
07438e010e | ||
|
|
3e33efb71f | ||
|
|
376ee3d678 | ||
|
|
6f19cdbd17 | ||
|
|
6985a7d5f6 | ||
|
|
8231a55acb | ||
|
|
1b0740eab4 | ||
|
|
c5e509274d | ||
|
|
1bf5920b04 | ||
|
|
2547c6f199 | ||
|
|
69e27a386c | ||
|
|
7390096567 | ||
|
|
58c642f27b | ||
|
|
b1d1e84666 | ||
|
|
55484453a1 | ||
|
|
b525c52870 | ||
|
|
b64afb462f | ||
|
|
b92f85a236 | ||
|
|
643d704b74 | ||
|
|
9e383c3616 | ||
|
|
1543db49d1 | ||
|
|
910e5418e9 | ||
|
|
a019d9e775 | ||
|
|
e6d47b6a16 | ||
|
|
b10f61a642 | ||
|
|
752aa719d8 | ||
|
|
5884a18979 | ||
|
|
3ccc83e7ce | ||
|
|
5dc4ea1a45 | ||
|
|
1bd476e052 | ||
|
|
41793455f7 | ||
|
|
d99cbf1dbb | ||
|
|
1afd2185d5 | ||
|
|
5aa1c88b1e | ||
|
|
ac95a2bc39 | ||
|
|
e75aefeb4f | ||
|
|
70863e0923 | ||
|
|
b6ecf07369 | ||
|
|
bf5d75d5e9 | ||
|
|
53520a4236 | ||
|
|
c55f78b5a7 | ||
|
|
5d47f47c8e |
@@ -0,0 +1,274 @@
|
||||
name: Release iOS (TestFlight)
|
||||
|
||||
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
|
||||
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
|
||||
# any native iOS change in app.config.js (or a config plugin), never in ios/.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_to_testflight:
|
||||
description: "Upload the build to TestFlight (off = build the IPA only)"
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-ios-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
# marketing version, so every run needs a fresh, increasing number.
|
||||
IOS_BUILD_NUMBER: ${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Show toolchain versions
|
||||
run: |
|
||||
xcodebuild -version
|
||||
pod --version
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/CocoaPods
|
||||
~/.cocoapods/repos
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
|
||||
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
|
||||
echo "Using workspace $WORKSPACE, scheme $SCHEME"
|
||||
|
||||
# ── Unsigned path: a compile check only, so the workflow is still useful
|
||||
# on forks and before the Apple secrets exist.
|
||||
- name: Build without signing
|
||||
if: steps.creds.outputs.signed != 'true'
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY=""
|
||||
|
||||
# ── Signed path ────────────────────────────────────────────
|
||||
- name: Import the distribution certificate
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
|
||||
# Ephemeral runner, ephemeral keychain - this password protects nothing
|
||||
# beyond the life of the job.
|
||||
KEYCHAIN_PASSWORD=$(uuidgen)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
|
||||
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
|
||||
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
|
||||
rm -f "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
# Without this, codesign triggers an interactive keychain prompt that
|
||||
# nothing can answer on a headless runner.
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
|
||||
|
||||
# Put our keychain first in the search list, keeping the login one so
|
||||
# Xcode can still reach Apple's intermediate certificates.
|
||||
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
|
||||
security find-identity -v -p codesigning "$KEYCHAIN"
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
# altool looks the key up by ID in this directory.
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Install the provisioning profile
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Manual signing, deliberately. Automatic signing always signs the
|
||||
# archive with a *development* identity and relies on -exportArchive to
|
||||
# re-sign it for distribution afterwards - which needs a development
|
||||
# certificate and a registered device that a CI-only TestFlight
|
||||
# pipeline has no reason to own. Naming the App Store profile up front
|
||||
# skips that dance entirely.
|
||||
xcodebuild archive \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
# resolved values instead of guessing twice.
|
||||
- name: Dump signing settings
|
||||
if: failure() && steps.creds.outputs.signed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "── identities in the keychain ──"
|
||||
security find-identity -v -p codesigning || true
|
||||
echo "── resolved build settings ──"
|
||||
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release -showBuildSettings 2>/dev/null \
|
||||
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
|
||||
|
||||
- name: Export the IPA
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store</string>
|
||||
<key>teamID</key><string>${TEAM_ID}</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
-exportPath "$RUNNER_TEMP/export" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
|
||||
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
|
||||
cp "$SRC" "$DEST"
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
with:
|
||||
name: ${{ steps.export.outputs.path }}
|
||||
path: ${{ steps.export.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
if: >-
|
||||
steps.creds.outputs.signed == 'true' &&
|
||||
(github.event_name == 'release' || inputs.upload_to_testflight)
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: |
|
||||
IPA="${{ steps.export.outputs.path }}"
|
||||
# Validate first - it catches missing Info.plist keys and entitlement
|
||||
# mistakes in seconds, instead of via a rejection email 20 minutes later.
|
||||
xcrun altool --validate-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
@@ -46,6 +46,7 @@ import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { useOutboxStore } from './src/stores/outbox-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { useColors } from './src/theme/colors';
|
||||
@@ -268,6 +269,7 @@ export default function App() {
|
||||
// feature enabled. Re-runs whenever the user changes the days window.
|
||||
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
@@ -276,10 +278,21 @@ export default function App() {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
// Slight delay so cold start doesn't compete with the inbox load.
|
||||
const t = setTimeout(() => {
|
||||
void runOfflineSync({ days: offlineCacheDays });
|
||||
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
|
||||
|
||||
// Drain the offline action queue (outbox) as soon as we have a live session,
|
||||
// and again whenever the network comes back. The flush itself no-ops when
|
||||
// there's nothing queued or the client isn't ready.
|
||||
React.useEffect(() => {
|
||||
if (!haveLiveSession) return;
|
||||
void useOutboxStore.getState().flush();
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online) void useOutboxStore.getState().flush();
|
||||
});
|
||||
}, [haveLiveSession]);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
@@ -26,10 +26,10 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- iOS builds, but push notifications and client certificates are Android-only so far
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -41,6 +41,7 @@ npx expo start
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
@@ -21,6 +22,12 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
|
||||
+39
-1
@@ -16,6 +16,12 @@ if (!COMMIT) {
|
||||
}
|
||||
COMMIT = COMMIT.slice(0, 7);
|
||||
|
||||
// App Store Connect rejects a build whose CFBundleVersion it has already seen
|
||||
// for this CFBundleShortVersionString, so this has to advance on every upload
|
||||
// even when VERSION does not. CI passes the workflow run number; local builds
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -33,6 +39,14 @@ module.exports = {
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: 'org.bulwarkmail.mobile',
|
||||
buildNumber: IOS_BUILD_NUMBER,
|
||||
config: {
|
||||
// The app only speaks HTTPS/TLS and uses platform crypto, which is
|
||||
// exempt. Declaring it here skips the manual export-compliance
|
||||
// questionnaire that otherwise blocks every TestFlight build.
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -46,7 +60,31 @@ module.exports = {
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
},
|
||||
plugins: ['expo-secure-store', '@react-native-community/datetimepicker', 'expo-localization'],
|
||||
plugins: [
|
||||
'expo-secure-store',
|
||||
'@react-native-community/datetimepicker',
|
||||
'expo-localization',
|
||||
[
|
||||
'expo-camera',
|
||||
{
|
||||
cameraPermission:
|
||||
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
|
||||
// QR scanning never records audio; leaving the mic entry in would be
|
||||
// an unexplained permission in App Review.
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-image-picker',
|
||||
{
|
||||
photosPermission:
|
||||
'Bulwark Mail needs access to your photos so you can attach them to emails and set contact photos.',
|
||||
// Only launchImageLibraryAsync is used - no in-app capture.
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# iOS builds and TestFlight
|
||||
|
||||
The iOS app is built by [.github/workflows/release-ios.yml](../.github/workflows/release-ios.yml)
|
||||
on a GitHub-hosted macOS runner and uploaded straight to TestFlight. macOS runner
|
||||
minutes are free for this repository because it is public.
|
||||
|
||||
Unlike `android/`, which is committed and hand-maintained, **`ios/` is gitignored
|
||||
and regenerated by `expo prebuild` on every run.** Never commit it, and never fix
|
||||
anything by editing it - native iOS changes belong in `app.config.js` or a config
|
||||
plugin, or they will be silently thrown away on the next build.
|
||||
|
||||
Without the secrets below the workflow still runs, but only as an unsigned
|
||||
compile check; no IPA is produced.
|
||||
|
||||
## One-time Apple setup
|
||||
|
||||
You need an active Apple Developer Program membership ($99/yr). Everything here
|
||||
can be done from Windows - no Mac required.
|
||||
|
||||
### 1. Team ID
|
||||
|
||||
developer.apple.com → Account → Membership details. A 10-character string like
|
||||
`A1B2C3D4E5`.
|
||||
|
||||
### 2. Distribution certificate
|
||||
|
||||
Apple issues certificates against a certificate signing request. Keychain Access
|
||||
is the usual way to make one, but OpenSSL works and does not need a Mac:
|
||||
|
||||
```bash
|
||||
openssl genrsa -out dist.key 2048
|
||||
openssl req -new -key dist.key -out dist.csr \
|
||||
-subj "/emailAddress=you@bulwarkmail.org/CN=Bulwark Mail/C=DE"
|
||||
```
|
||||
|
||||
Upload `dist.csr` at developer.apple.com → Certificates → **+** → **Apple
|
||||
Distribution**, download the resulting `distribution.cer`, then bundle the cert
|
||||
and its private key into a `.p12`:
|
||||
|
||||
```bash
|
||||
openssl x509 -inform DER -in distribution.cer -out dist.pem
|
||||
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 \
|
||||
-passout pass:CHOOSE_A_PASSWORD
|
||||
```
|
||||
|
||||
**On OpenSSL 3.x, add `-legacy` to that last command.** OpenSSL 3 defaults to
|
||||
AES-256-CBC, which macOS `security import` refuses to read, and the workflow then
|
||||
fails at the import step. OpenSSL 1.x has no `-legacy` flag and already writes
|
||||
the format macOS wants - passing it there is an error. Check with `openssl
|
||||
version` first; Git Bash on Windows currently ships 1.1.1.
|
||||
|
||||
Keep `dist.key` somewhere safe - you cannot re-download it, and losing it means
|
||||
revoking the certificate and starting over.
|
||||
|
||||
### 3. App Store Connect API key
|
||||
|
||||
App Store Connect → Users and Access → Integrations → App Store Connect API →
|
||||
**+**, with the **App Manager** role. Download the `.p8` (**one download only**)
|
||||
and note the **Key ID** and the **Issuer ID** shown above the key list.
|
||||
|
||||
The key authenticates the TestFlight upload.
|
||||
|
||||
### 4. App ID, then app record
|
||||
|
||||
These live on two different Apple sites and have to be done in this order.
|
||||
|
||||
**developer.apple.com** → Certificates, Identifiers & Profiles → Identifiers →
|
||||
**+** → App IDs → App. Explicit bundle ID `org.bulwarkmail.mobile`, matching
|
||||
`ios.bundleIdentifier` in [app.config.js](../app.config.js). Leave every
|
||||
capability off: push is not implemented on iOS, and sign-in uses the custom
|
||||
`bulwarkmobile://` scheme rather than universal links, so no Associated Domains.
|
||||
|
||||
**appstoreconnect.apple.com** → Apps → **+** → New App. Platform iOS, any SKU.
|
||||
The bundle ID here is a dropdown populated from the identifiers above, which is
|
||||
why the App ID has to be registered first. The app name must be unique across
|
||||
the entire App Store.
|
||||
|
||||
The TestFlight upload is rejected if the app record does not exist - and that
|
||||
failure lands at the very end of a ~30 minute build.
|
||||
|
||||
### 5. App Store provisioning profile
|
||||
|
||||
developer.apple.com → Profiles → **+** → Distribution → **App Store Connect**.
|
||||
Pick the App ID from step 4 and the distribution certificate from step 2, name it
|
||||
`Bulwark Mobile App Store`, and download the `.mobileprovision`.
|
||||
|
||||
The build signs **manually** and this profile is not optional. Automatic signing
|
||||
would be less to maintain, but `xcodebuild archive` under automatic signing always
|
||||
signs with a *development* identity and leans on `-exportArchive` to re-sign for
|
||||
distribution afterwards. That needs a development certificate and at least one
|
||||
registered device - neither of which a CI-only TestFlight pipeline has any reason
|
||||
to own, and Apple will not issue a development profile without a device. Naming
|
||||
the App Store profile up front avoids the whole problem.
|
||||
|
||||
The profile expires when the certificate does (2027-08-01). Regenerate it here and
|
||||
update the secret when that happens.
|
||||
|
||||
## Repository secrets
|
||||
|
||||
Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_TEAM_ID` | The 10-character team ID from step 1 |
|
||||
| `IOS_DIST_CERT_BASE64` | `dist.p12` from step 2, base64-encoded |
|
||||
| `IOS_DIST_CERT_PASSWORD` | The password you chose for `dist.p12` |
|
||||
| `APP_STORE_CONNECT_KEY_ID` | Key ID from step 3 |
|
||||
| `APP_STORE_CONNECT_ISSUER_ID` | Issuer ID from step 3 |
|
||||
| `APP_STORE_CONNECT_PRIVATE_KEY` | The `.p8` from step 3, base64-encoded |
|
||||
| `IOS_PROVISIONING_PROFILE_BASE64` | The `.mobileprovision` from step 5, base64-encoded |
|
||||
|
||||
To base64-encode a file:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash
|
||||
base64 -w0 dist.p12
|
||||
```
|
||||
|
||||
## Running a build
|
||||
|
||||
Publishing a GitHub release triggers the iOS workflow alongside the Android one.
|
||||
You can also run it by hand from the Actions tab, with an
|
||||
`upload_to_testflight` toggle if you only want the IPA as a workflow artifact.
|
||||
|
||||
The IPA is deliberately not attached to the GitHub release: unlike the APK, it is
|
||||
not installable outside TestFlight.
|
||||
|
||||
`CFBundleVersion` comes from the workflow run number via the `IOS_BUILD_NUMBER`
|
||||
env var. App Store Connect rejects a build number it has already seen for a given
|
||||
version, so never re-run a workflow expecting a re-upload to succeed - the run
|
||||
number advances on its own, so just start a new run.
|
||||
|
||||
## After the upload
|
||||
|
||||
Processing takes roughly 5-30 minutes before the build shows up in TestFlight.
|
||||
Internal testers (up to 100, must be on your App Store Connect team) get it
|
||||
immediately. External testers (up to 10,000) need a Beta App Review, which takes
|
||||
about a day the first time and is usually waved through afterwards.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
|
||||
and an equivalent native module; until then `push-notifications.ts` short-
|
||||
circuits and the app polls like a device with push disabled.
|
||||
- **Client certificates.** `BulwarkClientCertModule` is Android-only.
|
||||
`secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
|
||||
not connect.
|
||||
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
|
||||
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
|
||||
Downloading and installing a binary is forbidden by App Store Review guideline
|
||||
2.5.2, so iOS updates come through TestFlight instead. The Updates settings
|
||||
pane is removed from the list on iOS; the version and commit are still shown
|
||||
under About & Data.
|
||||
@@ -497,6 +497,7 @@
|
||||
"drop_files": "Drop files to attach",
|
||||
"show_less": "Show less",
|
||||
"send_failed": "Failed to send email",
|
||||
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
|
||||
"continue_draft": "Continue draft",
|
||||
"close_draft_title": "Save or discard draft?",
|
||||
"close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?",
|
||||
@@ -821,10 +822,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Date Format",
|
||||
"description": "How dates should be displayed",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Custom"
|
||||
"description": "How dates are shown in the email list",
|
||||
"smart": "Smart (locale-aware)",
|
||||
"relative": "Relative (1h ago, 2d ago)",
|
||||
"full": "Always full date",
|
||||
"preview_today": "Today:",
|
||||
"preview_this_week": "This week:",
|
||||
"preview_older": "Older:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Time Format",
|
||||
@@ -1424,6 +1428,7 @@
|
||||
"size_bytes": "Size in bytes",
|
||||
"size_placeholder": "e.g., 1000000",
|
||||
"system_managed": "System-managed rule",
|
||||
"origin_external": "External",
|
||||
"opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.",
|
||||
"open_sieve_editor": "Open raw Sieve editor",
|
||||
"fetch_error": "Failed to load filters",
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
<title>Bulwark Mobile — Sign-in redesign</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><path d="M14 14h3v3h-3z" /><path d="M21 14v3M21 21h-4" />
|
||||
</symbol>
|
||||
<symbol id="i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m2 7 10 6 10-6" />
|
||||
</symbol>
|
||||
<symbol id="i-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</symbol>
|
||||
<symbol id="i-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m4 12 5 5L20 6" />
|
||||
</symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" /><path d="M12 7v6M12 16.5v.5" />
|
||||
</symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 6 18 18M18 6 6 18" />
|
||||
</symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z" /><circle cx="12" cy="12" r="2.6" />
|
||||
</symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3 5 6v6c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5" /><path d="m10.8 12.2 8-8M17 6l2.5 2.5M14.5 8.5 17 11" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--page-bg: #fbfbfc;
|
||||
--page-panel: #f0f3f8;
|
||||
--page-ink: #14171f;
|
||||
--page-ink-2: #545c6b;
|
||||
--page-ink-3: #8a919f;
|
||||
--page-rule: #e1e6ef;
|
||||
--accent: #2f6fed;
|
||||
--accent-soft: rgba(47, 111, 237, 0.09);
|
||||
|
||||
--app-bg: #ffffff;
|
||||
--app-surface: #f8fafc;
|
||||
--app-surface-2: #f1f5f9;
|
||||
--app-border: #e2e8f0;
|
||||
--app-text: #0f172a;
|
||||
--app-text-2: #64748b;
|
||||
--app-muted: #94a3b8;
|
||||
--app-primary: #3b82f6;
|
||||
--app-primary-bg: #dbeafe;
|
||||
--app-error: #ef4444;
|
||||
--app-error-bg: #fee2e2;
|
||||
--app-success: #16a34a;
|
||||
|
||||
--frame: #d5dae5;
|
||||
--frame-shadow: rgba(20, 26, 40, 0.14);
|
||||
|
||||
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
|
||||
--sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--page-bg: #0a0b0d;
|
||||
--page-panel: #111319;
|
||||
--page-ink: #eef1f6;
|
||||
--page-ink-2: #a4adbb;
|
||||
--page-ink-3: #6e7684;
|
||||
--page-rule: #22262f;
|
||||
--accent: #74aaff;
|
||||
--accent-soft: rgba(116, 170, 255, 0.12);
|
||||
|
||||
--app-bg: #09090b;
|
||||
--app-surface: #18181b;
|
||||
--app-surface-2: #1f1f23;
|
||||
--app-border: #27272a;
|
||||
--app-text: #fafafa;
|
||||
--app-text-2: #a1a1aa;
|
||||
--app-muted: #71717a;
|
||||
--app-primary: #3b82f6;
|
||||
--app-primary-bg: #172554;
|
||||
--app-error: #ef4444;
|
||||
--app-error-bg: #450a0a;
|
||||
--app-success: #22c55e;
|
||||
|
||||
--frame: #2b303a;
|
||||
--frame-shadow: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--page-bg: #fbfbfc; --page-panel: #f0f3f8; --page-ink: #14171f; --page-ink-2: #545c6b;
|
||||
--page-ink-3: #8a919f; --page-rule: #e1e6ef; --accent: #2f6fed; --accent-soft: rgba(47,111,237,0.09);
|
||||
--app-bg: #ffffff; --app-surface: #f8fafc; --app-surface-2: #f1f5f9; --app-border: #e2e8f0;
|
||||
--app-text: #0f172a; --app-text-2: #64748b; --app-muted: #94a3b8; --app-primary: #3b82f6;
|
||||
--app-primary-bg: #dbeafe; --app-error: #ef4444; --app-error-bg: #fee2e2; --app-success: #16a34a;
|
||||
--frame: #d5dae5; --frame-shadow: rgba(20,26,40,0.14);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--page-bg: #0a0b0d; --page-panel: #111319; --page-ink: #eef1f6; --page-ink-2: #a4adbb;
|
||||
--page-ink-3: #6e7684; --page-rule: #22262f; --accent: #74aaff; --accent-soft: rgba(116,170,255,0.12);
|
||||
--app-bg: #09090b; --app-surface: #18181b; --app-surface-2: #1f1f23; --app-border: #27272a;
|
||||
--app-text: #fafafa; --app-text-2: #a1a1aa; --app-muted: #71717a; --app-primary: #3b82f6;
|
||||
--app-primary-bg: #172554; --app-error: #ef4444; --app-error-bg: #450a0a; --app-success: #22c55e;
|
||||
--frame: #2b303a; --frame-shadow: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--page-bg);
|
||||
color: var(--page-ink);
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.d-wrap { max-width: 1180px; margin: 0 auto; padding: 64px 28px 96px; }
|
||||
|
||||
/* ---------- document typography ---------- */
|
||||
.d-eyebrow {
|
||||
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--page-ink-3); margin: 0 0 18px;
|
||||
}
|
||||
.d-title {
|
||||
font-family: var(--sans); font-weight: 700; font-size: clamp(34px, 5vw, 52px);
|
||||
line-height: 1.04; letter-spacing: -0.028em; margin: 0; text-wrap: balance;
|
||||
}
|
||||
.d-deck {
|
||||
max-width: 62ch; margin: 22px 0 0; font-size: 19px; line-height: 1.62; color: var(--page-ink-2);
|
||||
}
|
||||
.d-deck strong { color: var(--page-ink); font-weight: 600; }
|
||||
|
||||
.d-section { margin-top: 84px; }
|
||||
.d-h2 {
|
||||
font-family: var(--sans); font-weight: 700; font-size: 25px; letter-spacing: -0.018em;
|
||||
margin: 0; text-wrap: balance;
|
||||
}
|
||||
.d-lede { max-width: 62ch; margin: 12px 0 0; color: var(--page-ink-2); }
|
||||
.d-rule { height: 1px; background: var(--page-rule); border: 0; margin: 26px 0 34px; }
|
||||
|
||||
.d-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 10px; margin-top: 30px;
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-2);
|
||||
}
|
||||
.d-meta span {
|
||||
border: 1px solid var(--page-rule); border-radius: 999px; padding: 4px 11px; background: var(--page-panel);
|
||||
}
|
||||
|
||||
/* ---------- comparison ---------- */
|
||||
.d-compare {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px;
|
||||
background: var(--page-rule); border: 1px solid var(--page-rule); border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.d-cell { background: var(--page-bg); padding: 20px 22px; }
|
||||
.d-cell dt {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--page-ink-3); margin: 0 0 10px;
|
||||
}
|
||||
.d-cell dd { margin: 0; font-family: var(--sans); font-size: 15px; line-height: 1.45; }
|
||||
.d-was { color: var(--page-ink-3); text-decoration: line-through; text-decoration-thickness: 1px; }
|
||||
.d-now { color: var(--page-ink); font-weight: 600; display: block; margin-top: 3px; }
|
||||
|
||||
/* ---------- filmstrip ---------- */
|
||||
.d-strip {
|
||||
display: flex; gap: 30px; overflow-x: auto; padding: 30px 26px 26px;
|
||||
background: var(--page-panel); border-radius: 16px; scroll-snap-type: x proximity;
|
||||
}
|
||||
.d-strip figure { margin: 0; scroll-snap-align: center; flex: 0 0 auto; width: 288px; }
|
||||
.d-strip figcaption { margin-top: 18px; }
|
||||
.d-step {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--accent); display: block; margin-bottom: 5px;
|
||||
}
|
||||
.d-step.is-alt { color: var(--page-ink-3); }
|
||||
.d-cap-title { font-family: var(--sans); font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
|
||||
.d-cap-body { font-size: 14.5px; line-height: 1.5; color: var(--page-ink-2); margin: 5px 0 0; }
|
||||
|
||||
/* ---------- notes ---------- */
|
||||
.d-notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 22px; max-width: 74ch; }
|
||||
.d-notes li { display: grid; grid-template-columns: 26px 1fr; gap: 14px; align-items: start; }
|
||||
.d-notes svg { width: 18px; height: 18px; color: var(--accent); margin-top: 4px; }
|
||||
.d-note-h { font-family: var(--sans); font-weight: 650; font-size: 15.5px; }
|
||||
.d-note-b { color: var(--page-ink-2); font-size: 16px; }
|
||||
code {
|
||||
font-family: var(--mono); font-size: 0.85em; background: var(--accent-soft);
|
||||
color: var(--accent); padding: 1.5px 5px; border-radius: 4px;
|
||||
}
|
||||
|
||||
.d-foot {
|
||||
margin-top: 80px; padding-top: 22px; border-top: 1px solid var(--page-rule);
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--page-ink-3);
|
||||
}
|
||||
|
||||
/* ================= phone chrome ================= */
|
||||
.p {
|
||||
width: 288px; height: 600px; border-radius: 36px; background: var(--frame);
|
||||
padding: 8px; box-shadow: 0 18px 44px -18px var(--frame-shadow); position: relative;
|
||||
}
|
||||
.p-screen {
|
||||
width: 100%; height: 100%; border-radius: 29px; background: var(--app-bg); overflow: hidden;
|
||||
position: relative; font-family: var(--sans); color: var(--app-text); font-size: 14px; line-height: 1.4;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.p-notch {
|
||||
position: absolute; top: 15px; left: 50%; transform: translateX(-50%); width: 74px; height: 5px;
|
||||
border-radius: 99px; background: var(--app-border); z-index: 6;
|
||||
}
|
||||
.p-pad { padding: 42px 24px 22px; flex: 1; display: flex; flex-direction: column; }
|
||||
.p-pad.is-centered { justify-content: center; }
|
||||
|
||||
/* ---- in-app primitives ---- */
|
||||
.s-logo {
|
||||
width: 56px; height: 56px; border-radius: 17px; background: var(--app-primary);
|
||||
display: grid; place-items: center; margin: 0 auto 16px; color: #fff;
|
||||
}
|
||||
.s-logo svg { width: 30px; height: 30px; }
|
||||
.s-brand { text-align: center; margin-bottom: 30px; }
|
||||
.s-brand h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
|
||||
.s-brand p { font-size: 12.5px; color: var(--app-muted); margin: 5px 0 0; }
|
||||
|
||||
.s-h { font-size: 21px; font-weight: 700; letter-spacing: -0.022em; margin: 0; line-height: 1.22; }
|
||||
.s-sub { font-size: 13px; color: var(--app-text-2); margin: 8px 0 0; line-height: 1.45; }
|
||||
|
||||
.s-tile {
|
||||
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
|
||||
border: 1px solid var(--app-border); background: var(--app-surface);
|
||||
border-radius: 14px; padding: 14px 15px; color: inherit;
|
||||
}
|
||||
.s-tile.is-primary { background: var(--app-primary); border-color: var(--app-primary); color: #fff; }
|
||||
.s-tile-ico { width: 22px; height: 22px; flex: 0 0 22px; opacity: 0.95; }
|
||||
.s-tile-txt { flex: 1; min-width: 0; }
|
||||
.s-tile-t { font-size: 14.5px; font-weight: 600; letter-spacing: -0.005em; }
|
||||
.s-tile-d { font-size: 11.5px; opacity: 0.72; margin-top: 2px; }
|
||||
.s-tile-chev { width: 16px; height: 16px; opacity: 0.5; }
|
||||
|
||||
.s-stack { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.s-field { margin-top: 20px; }
|
||||
.s-label { font-size: 12px; font-weight: 600; color: var(--app-text-2); margin-bottom: 7px; }
|
||||
.s-input {
|
||||
border: 1px solid var(--app-border); background: var(--app-bg); border-radius: 10px;
|
||||
height: 42px; padding: 0 13px; display: flex; align-items: center; gap: 8px; font-size: 14px;
|
||||
}
|
||||
.s-input.is-focus { border-color: var(--app-primary); box-shadow: 0 0 0 3px var(--app-primary-bg); }
|
||||
.s-input.is-error { border-color: var(--app-error); }
|
||||
.s-input .s-ph { color: var(--app-muted); }
|
||||
.s-input svg { width: 17px; height: 17px; color: var(--app-muted); flex: 0 0 17px; }
|
||||
.s-caret {
|
||||
width: 1.5px; height: 17px; background: var(--app-primary); display: inline-block;
|
||||
animation: blink 1.05s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .s-caret { animation: none; } }
|
||||
|
||||
.s-hint { font-size: 11.5px; color: var(--app-muted); margin-top: 8px; line-height: 1.45; }
|
||||
.s-hint.is-error { color: var(--app-error); }
|
||||
|
||||
.s-btn {
|
||||
height: 46px; border-radius: 12px; background: var(--app-primary); color: #fff;
|
||||
display: grid; place-items: center; font-size: 15px; font-weight: 600; margin-top: 20px;
|
||||
}
|
||||
.s-btn.is-off { background: var(--app-surface-2); color: var(--app-muted); }
|
||||
.s-btn.is-quiet {
|
||||
background: transparent; color: var(--app-text); border: 1px solid var(--app-border); font-weight: 500;
|
||||
}
|
||||
.s-link { text-align: center; font-size: 13px; color: var(--app-primary); margin-top: 16px; font-weight: 500; }
|
||||
.s-link.is-muted { color: var(--app-muted); font-weight: 400; }
|
||||
|
||||
.s-back { width: 22px; height: 22px; color: var(--app-text-2); margin-bottom: 22px; }
|
||||
.s-foot {
|
||||
text-align: center; font-size: 11px; color: var(--app-muted); padding: 0 0 8px; line-height: 1.6;
|
||||
}
|
||||
|
||||
.s-card {
|
||||
border: 1px solid var(--app-border); background: var(--app-surface); border-radius: 14px;
|
||||
padding: 15px; display: flex; align-items: center; gap: 12px; margin-top: 22px;
|
||||
}
|
||||
.s-card-ico {
|
||||
width: 38px; height: 38px; border-radius: 11px; background: var(--app-primary-bg);
|
||||
color: var(--app-primary); display: grid; place-items: center; flex: 0 0 38px;
|
||||
}
|
||||
.s-card-ico svg { width: 19px; height: 19px; }
|
||||
.s-card-t { font-size: 14.5px; font-weight: 600; }
|
||||
.s-card-d { font-size: 11.5px; color: var(--app-text-2); margin-top: 2px; display: flex; align-items: center; gap: 5px; }
|
||||
.s-dot { width: 5px; height: 5px; border-radius: 99px; background: var(--app-success); display: inline-block; }
|
||||
|
||||
.s-note {
|
||||
display: flex; gap: 9px; align-items: flex-start; margin-top: 16px;
|
||||
font-size: 11.5px; color: var(--app-text-2); line-height: 1.5;
|
||||
}
|
||||
.s-note svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-muted); }
|
||||
|
||||
.s-banner {
|
||||
display: flex; gap: 9px; align-items: flex-start; border-radius: 11px; padding: 11px 12px;
|
||||
background: var(--app-error-bg); font-size: 12px; line-height: 1.45; margin-top: 18px;
|
||||
}
|
||||
.s-banner svg { width: 15px; height: 15px; flex: 0 0 15px; margin-top: 1px; color: var(--app-error); }
|
||||
|
||||
.s-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
|
||||
.s-divider::before, .s-divider::after { content: ""; height: 1px; flex: 1; background: var(--app-border); }
|
||||
.s-divider span { font-size: 11.5px; color: var(--app-muted); }
|
||||
|
||||
/* progress rows (setting up) */
|
||||
.s-prog { display: flex; align-items: center; gap: 11px; padding: 11px 0; font-size: 13.5px; }
|
||||
.s-prog .s-tick {
|
||||
width: 20px; height: 20px; border-radius: 99px; background: var(--app-success);
|
||||
color: #fff; display: grid; place-items: center; flex: 0 0 20px;
|
||||
}
|
||||
.s-prog .s-tick svg { width: 11px; height: 11px; }
|
||||
.s-prog .s-spin {
|
||||
width: 20px; height: 20px; border-radius: 99px; flex: 0 0 20px;
|
||||
border: 2px solid var(--app-border); border-top-color: var(--app-primary); animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .s-prog .s-spin { animation: none; } }
|
||||
.s-prog.is-pending { color: var(--app-muted); }
|
||||
|
||||
/* account rows (add mode) */
|
||||
.s-acct { display: flex; align-items: center; gap: 11px; padding: 11px 0; }
|
||||
.s-av {
|
||||
width: 34px; height: 34px; border-radius: 99px; display: grid; place-items: center;
|
||||
font-size: 13px; font-weight: 600; color: #fff; flex: 0 0 34px;
|
||||
}
|
||||
.s-acct-t { font-size: 14px; font-weight: 550; }
|
||||
.s-acct-d { font-size: 11.5px; color: var(--app-muted); }
|
||||
|
||||
/* scanner — deliberately always dark, it's a camera view */
|
||||
.p-screen.is-camera { background: #0b0d10; color: #fff; }
|
||||
.s-cam { position: absolute; inset: 0; background:
|
||||
radial-gradient(120% 80% at 50% 30%, #2a3038 0%, #14171c 55%, #0b0d10 100%); }
|
||||
.s-cam-grid { position: absolute; inset: 0; opacity: 0.18; background-image:
|
||||
linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 34px 34px; }
|
||||
.s-reticle { position: absolute; top: 168px; left: 50%; transform: translateX(-50%); width: 176px; height: 176px; }
|
||||
.s-reticle i {
|
||||
position: absolute; width: 34px; height: 34px; border: 3px solid #fff; border-radius: 4px;
|
||||
}
|
||||
.s-reticle i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
|
||||
.s-reticle i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
|
||||
.s-reticle i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
|
||||
.s-reticle i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }
|
||||
.s-qr {
|
||||
position: absolute; inset: 22px; border-radius: 6px; background: #fff;
|
||||
display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(9, 1fr); padding: 8px; gap: 2px;
|
||||
}
|
||||
.s-qr b { background: #0b0d10; border-radius: 1px; }
|
||||
.s-cam-top {
|
||||
position: absolute; top: 40px; left: 0; right: 0; display: flex; align-items: center;
|
||||
justify-content: space-between; padding: 0 20px; z-index: 3;
|
||||
}
|
||||
.s-cam-top svg { width: 22px; height: 22px; color: #fff; }
|
||||
.s-cam-copy { position: absolute; left: 0; right: 0; bottom: 46px; text-align: center; padding: 0 30px; z-index: 3; }
|
||||
.s-cam-copy p { margin: 0; font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.82); }
|
||||
.s-cam-copy .s-cam-h { font-size: 15.5px; font-weight: 600; color: #fff; margin-bottom: 6px; }
|
||||
.s-cam-copy .s-cam-link { margin-top: 18px; font-size: 13px; font-weight: 500; color: #8ab4ff; }
|
||||
|
||||
/* browser handoff sheet */
|
||||
.s-sheet {
|
||||
position: absolute; inset: 26px 0 0; background: var(--app-bg); border-radius: 18px 18px 0 0;
|
||||
box-shadow: 0 -8px 40px rgba(0,0,0,0.35); display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.s-sheet-bar {
|
||||
display: flex; align-items: center; gap: 9px; padding: 13px 15px;
|
||||
border-bottom: 1px solid var(--app-border); font-size: 12.5px; color: var(--app-text-2);
|
||||
}
|
||||
.s-sheet-bar svg { width: 13px; height: 13px; color: var(--app-success); }
|
||||
.s-sheet-bar .s-url { flex: 1; color: var(--app-text); font-weight: 550; }
|
||||
.s-sheet-bar .s-done { color: var(--app-primary); font-weight: 600; }
|
||||
.s-sheet-body { padding: 30px 24px; flex: 1; }
|
||||
.s-scrim { position: absolute; inset: 0; background: rgba(4,6,10,0.5); }
|
||||
.s-web-brand { display: flex; align-items: center; gap: 9px; margin-bottom: 24px; }
|
||||
.s-web-brand .s-web-mark {
|
||||
width: 28px; height: 28px; border-radius: 9px; background: var(--app-primary);
|
||||
color: #fff; display: grid; place-items: center;
|
||||
}
|
||||
.s-web-brand .s-web-mark svg { width: 16px; height: 16px; }
|
||||
.s-web-brand b { font-size: 14.5px; letter-spacing: -0.01em; }
|
||||
|
||||
/* the "before" screen is intentionally cramped */
|
||||
.s-old-field { margin-bottom: 13px; }
|
||||
</style>
|
||||
|
||||
<div class="d-wrap">
|
||||
|
||||
<p class="d-eyebrow">Bulwark Mobile · sign-in redesign</p>
|
||||
<h1 class="d-title">Three fields nobody knows the answer to</h1>
|
||||
<p class="d-deck">
|
||||
The current sign-in screen opens with a <strong>Server URL</strong> field. Most people don't know their
|
||||
JMAP endpoint, and the two paths that don't require knowing it — the webmail handoff and the QR code — sit
|
||||
below a divider, greyed out until the URL is filled in anyway. This proposes an entry screen that leads with
|
||||
the easy paths and asks for a server only when we genuinely can't work it out.
|
||||
</p>
|
||||
|
||||
<div class="d-meta">
|
||||
<span>src/screens/LoginScreen.tsx</span>
|
||||
<span>expo · react-native</span>
|
||||
<span>light + dark, both shown</span>
|
||||
</div>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">What changes</h2>
|
||||
<hr class="d-rule" />
|
||||
<dl class="d-compare">
|
||||
<div class="d-cell">
|
||||
<dt>To reach the inbox</dt>
|
||||
<dd><span class="d-was">3 fields typed</span><span class="d-now">1 field, or none with a code</span></dd>
|
||||
</div>
|
||||
<div class="d-cell">
|
||||
<dt>Server URL</dt>
|
||||
<dd><span class="d-was">Asked first, always</span><span class="d-now">Looked up from the address</span></dd>
|
||||
</div>
|
||||
<div class="d-cell">
|
||||
<dt>Password in the app</dt>
|
||||
<dd><span class="d-was">Default path</span><span class="d-now">Fallback only</span></dd>
|
||||
</div>
|
||||
<div class="d-cell">
|
||||
<dt>QR sign-in</dt>
|
||||
<dd><span class="d-was">Ghost button, disabled</span><span class="d-now">First option offered</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">Today</h2>
|
||||
<p class="d-lede">
|
||||
Everything at once, in the least helpful order. The webmail handoff is the safer route — it never puts a
|
||||
password into the app — but it's disabled until you've supplied the server, so it can't rescue anyone who's stuck.
|
||||
</p>
|
||||
<hr class="d-rule" />
|
||||
|
||||
<div class="d-strip">
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered">
|
||||
<div class="s-brand">
|
||||
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
|
||||
<h1>Bulwark Mail</h1>
|
||||
<p>Secure. Private. Yours.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-old-field">
|
||||
<div class="s-label">Server URL</div>
|
||||
<div class="s-input"><span class="s-ph">https://mail.example.com</span></div>
|
||||
</div>
|
||||
<div class="s-old-field">
|
||||
<div class="s-label">Email or Username</div>
|
||||
<div class="s-input"><span class="s-ph">you@example.com</span></div>
|
||||
</div>
|
||||
<div class="s-old-field">
|
||||
<div class="s-label">Password</div>
|
||||
<div class="s-input">
|
||||
<span class="s-ph" style="flex:1">Enter your password</span>
|
||||
<svg><use href="#i-eye" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-btn is-off" style="margin-top:12px">Sign In</div>
|
||||
|
||||
<div class="s-divider"><span>or continue with</span></div>
|
||||
<div class="s-stack">
|
||||
<div class="s-btn is-quiet" style="margin-top:0;opacity:0.45">Sign in via webmail</div>
|
||||
<div class="s-link is-muted" style="margin-top:2px;opacity:0.45">Scan QR code</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Current build</span>
|
||||
<span class="d-cap-title">One screen, six controls</span>
|
||||
<p class="d-cap-body">
|
||||
The two disabled controls at the bottom are the ones that would have helped. Nothing tells you where
|
||||
to find a server URL, and nothing happens if you don't have one.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">Proposed flow</h2>
|
||||
<p class="d-lede">
|
||||
One question per screen, cheapest question first. The screens below are the happy path, left to right.
|
||||
</p>
|
||||
<hr class="d-rule" />
|
||||
|
||||
<div class="d-strip">
|
||||
|
||||
<!-- 1 · welcome -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered">
|
||||
<div class="s-brand">
|
||||
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
|
||||
<h1>Bulwark Mail</h1>
|
||||
<p>Secure. Private. Yours.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-stack">
|
||||
<div class="s-tile is-primary">
|
||||
<svg class="s-tile-ico"><use href="#i-qr" /></svg>
|
||||
<span class="s-tile-txt">
|
||||
<span class="s-tile-t">Scan a sign-in code</span>
|
||||
<span class="s-tile-d">Already signed in on the web? Fastest way in.</span>
|
||||
</span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
<div class="s-tile">
|
||||
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
|
||||
<span class="s-tile-txt">
|
||||
<span class="s-tile-t">Use my email address</span>
|
||||
<span class="s-tile-d">We'll find your mail server</span>
|
||||
</span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-link is-muted">Enter server details manually</div>
|
||||
</div>
|
||||
<div class="s-foot">Bulwark Mobile v1.4.0<br /><span style="color:var(--app-primary)">Privacy Policy</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 1</span>
|
||||
<span class="d-cap-title">Pick a way in</span>
|
||||
<p class="d-cap-body">
|
||||
Two live options instead of six mixed ones. Each tile says what it costs you before you tap it.
|
||||
Manual setup stays reachable in one tap — it just isn't the greeting.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 2 · email -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">What's your email address?</h2>
|
||||
<p class="s-sub">That's usually all we need — we'll look up the mail server for you.</p>
|
||||
|
||||
<div class="s-field">
|
||||
<div class="s-input is-focus">
|
||||
<svg><use href="#i-mail" /></svg>
|
||||
<span>ada@example.com</span><span class="s-caret"></span>
|
||||
</div>
|
||||
<p class="s-hint">Works with Bulwark, Stalwart, and any JMAP mail server.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Continue</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="s-link is-muted">I know my server address</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 2</span>
|
||||
<span class="d-cap-title">One field, keyboard already up</span>
|
||||
<p class="d-cap-body">
|
||||
Autofocus with the email keyboard, autocorrect off. The button stays enabled and validates on tap,
|
||||
so nobody is left staring at a dead grey button wondering which field is wrong.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 3 · found server -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">Found your mail server</h2>
|
||||
<p class="s-sub">Next you'll sign in on your provider's own page.</p>
|
||||
|
||||
<div class="s-card">
|
||||
<span class="s-card-ico"><svg><use href="#i-globe" /></svg></span>
|
||||
<span>
|
||||
<span class="s-card-t">mail.example.com</span>
|
||||
<span class="s-card-d"><i class="s-dot"></i> JMAP · secure connection</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="s-note">
|
||||
<svg><use href="#i-lock" /></svg>
|
||||
<span>Your password is typed on your provider's page, never into this app. Bulwark stores a token it can revoke.</span>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Continue</div>
|
||||
<div class="s-link">Use a different server</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 3</span>
|
||||
<span class="d-cap-title">Confirm before the browser opens</span>
|
||||
<p class="d-cap-body">
|
||||
A browser jump with no warning feels like a bug. Naming the server first — and saying why the
|
||||
password isn't asked for here — turns the detour into a security feature.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 4 · handoff -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered" style="opacity:0.5">
|
||||
<div class="s-brand">
|
||||
<div class="s-logo"><svg><use href="#i-shield" /></svg></div>
|
||||
<h1>Bulwark Mail</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-scrim"></div>
|
||||
<div class="s-sheet">
|
||||
<div class="s-sheet-bar">
|
||||
<svg><use href="#i-lock" /></svg>
|
||||
<span class="s-url">mail.example.com</span>
|
||||
<span class="s-done">Done</span>
|
||||
</div>
|
||||
<div class="s-sheet-body">
|
||||
<div class="s-web-brand">
|
||||
<span class="s-web-mark"><svg><use href="#i-shield" /></svg></span>
|
||||
<b>Sign in to Bulwark</b>
|
||||
</div>
|
||||
<div class="s-label">Email</div>
|
||||
<div class="s-input"><span>ada@example.com</span></div>
|
||||
<div class="s-label" style="margin-top:14px">Password</div>
|
||||
<div class="s-input is-focus">
|
||||
<span style="flex:1">••••••••••</span><svg><use href="#i-eye" /></svg>
|
||||
</div>
|
||||
<div class="s-btn">Sign in</div>
|
||||
<div class="s-divider"><span>or</span></div>
|
||||
<div class="s-tile" style="padding:11px 13px">
|
||||
<svg class="s-tile-ico"><use href="#i-key" /></svg>
|
||||
<span class="s-tile-txt"><span class="s-tile-t">Continue with passkey</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 4</span>
|
||||
<span class="d-cap-title">The provider's page, in a sheet</span>
|
||||
<p class="d-cap-body">
|
||||
This is the existing <code>runWebmailHandoff</code> flow, unchanged. It already supports whatever
|
||||
the server offers — password, OAuth, passkeys, SSO — which the in-app form never will.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 5 · setting up -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad is-centered">
|
||||
<div style="text-align:center;margin-bottom:26px">
|
||||
<span class="s-av" style="width:60px;height:60px;font-size:22px;background:var(--app-primary);margin:0 auto 14px">AL</span>
|
||||
<h2 class="s-h" style="font-size:20px">Welcome back, Ada</h2>
|
||||
<p class="s-sub">ada@example.com</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="s-prog">
|
||||
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Mailboxes
|
||||
</div>
|
||||
<div class="s-prog">
|
||||
<span class="s-tick"><svg><use href="#i-check" /></svg></span> Recent mail
|
||||
</div>
|
||||
<div class="s-prog is-pending">
|
||||
<span class="s-spin"></span> Contacts and calendars
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Step 5</span>
|
||||
<span class="d-cap-title">Land on progress, not a spinner</span>
|
||||
<p class="d-cap-body">
|
||||
The first sync is the slowest moment in the app. Naming what's arriving makes the wait legible, and
|
||||
the inbox opens as soon as mailboxes land — the rest fills in behind it.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">When it doesn't go smoothly</h2>
|
||||
<p class="d-lede">
|
||||
Self-hosted servers, typo'd passwords, and second accounts are the normal case for this audience, not the
|
||||
exception. These four screens carry most of the redesign's weight.
|
||||
</p>
|
||||
<hr class="d-rule" />
|
||||
|
||||
<div class="d-strip">
|
||||
|
||||
<!-- scanner -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen is-camera">
|
||||
<div class="s-cam"><div class="s-cam-grid"></div></div>
|
||||
<div class="s-cam-top">
|
||||
<svg><use href="#i-x" /></svg>
|
||||
<span style="font-size:12.5px;opacity:0.8">Sign-in code</span>
|
||||
<span style="width:22px"></span>
|
||||
</div>
|
||||
<div class="s-reticle">
|
||||
<i></i><i></i><i></i><i></i>
|
||||
<div class="s-qr">
|
||||
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
|
||||
<b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b>
|
||||
<b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b><b></b>
|
||||
<b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b>
|
||||
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b>
|
||||
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b>
|
||||
<b></b><b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b>
|
||||
<b></b><b style="background:transparent"></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b>
|
||||
<b></b><b></b><b></b><b></b><b style="background:transparent"></b><b></b><b></b><b></b><b></b>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-cam-copy">
|
||||
<p class="s-cam-h">Open Bulwark on your computer</p>
|
||||
<p>Settings → Devices → Add phone shows a code. Point the camera at it.</p>
|
||||
<p class="s-cam-link">Type the code instead</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step">Path A</span>
|
||||
<span class="d-cap-title">The scanner tells you where the code is</span>
|
||||
<p class="d-cap-body">
|
||||
A scanner with no instructions is a dead end for anyone who hasn't seen the webmail's Devices page.
|
||||
Redeeming a paired code skips the browser entirely — see <code>redeemPairingCode</code>.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- discovery failed -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">Where does your mail live?</h2>
|
||||
<p class="s-sub">We couldn't find a server for <b>example.org</b>. If you self-host, the address is the one you use for webmail.</p>
|
||||
|
||||
<div class="s-field">
|
||||
<div class="s-label">Server address</div>
|
||||
<div class="s-input is-focus">
|
||||
<svg><use href="#i-globe" /></svg>
|
||||
<span>mail.example.org</span><span class="s-caret"></span>
|
||||
</div>
|
||||
<p class="s-hint">Same address you type in the browser. We'll add <b>https://</b>.</p>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Continue</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="s-note">
|
||||
<svg><use href="#i-qr" /></svg>
|
||||
<span>Not sure? Scan a sign-in code from the webmail instead — it carries the address with it.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Path B</span>
|
||||
<span class="d-cap-title">Ask for the server people know</span>
|
||||
<p class="d-cap-body">
|
||||
Not the JMAP session URL — the webmail address they type every day. The scheme is added for them, and
|
||||
a trailing slash or full <code>/jmap</code> path is accepted and trimmed.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- password fallback + error -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<svg class="s-back"><use href="#i-back" /></svg>
|
||||
<h2 class="s-h">Sign in to mail.example.org</h2>
|
||||
<p class="s-sub">This server doesn't support the browser sign-in, so we'll ask here.</p>
|
||||
|
||||
<div class="s-field" style="margin-top:18px">
|
||||
<div class="s-label">Email or username</div>
|
||||
<div class="s-input"><span>ada@example.org</span></div>
|
||||
</div>
|
||||
|
||||
<div class="s-field" style="margin-top:14px">
|
||||
<div class="s-label">Password</div>
|
||||
<div class="s-input is-error">
|
||||
<span style="flex:1">••••••••</span><svg><use href="#i-eye" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-banner">
|
||||
<svg><use href="#i-alert" /></svg>
|
||||
<span><b>That password didn't work.</b> If your account has two-factor sign-in, you'll need an app password from the webmail.</span>
|
||||
</div>
|
||||
|
||||
<div class="s-btn">Sign in</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Path C</span>
|
||||
<span class="d-cap-title">Errors that name the likely cause</span>
|
||||
<p class="d-cap-body">
|
||||
The store's raw error string is replaced with mapped copy per failure: wrong credentials, unreachable
|
||||
host, bad certificate, 2FA-needs-an-app-password. Each says what to do next.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- add account -->
|
||||
<figure>
|
||||
<div class="p">
|
||||
<div class="p-notch"></div>
|
||||
<div class="p-screen">
|
||||
<div class="p-pad">
|
||||
<div style="display:flex;justify-content:flex-end"><svg class="s-back" style="margin-bottom:14px"><use href="#i-x" /></svg></div>
|
||||
<h2 class="s-h">Add another account</h2>
|
||||
<p class="s-sub">You're signed in to these already.</p>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<div class="s-acct">
|
||||
<span class="s-av" style="background:#3b82f6">AL</span>
|
||||
<span><span class="s-acct-t">Ada Lovelace</span><span class="s-acct-d">ada@example.com</span></span>
|
||||
</div>
|
||||
<div class="s-acct">
|
||||
<span class="s-av" style="background:#a855f7">AL</span>
|
||||
<span><span class="s-acct-t">Work</span><span class="s-acct-d">a.lovelace@analytical.co</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-divider"><span>add</span></div>
|
||||
|
||||
<div class="s-stack">
|
||||
<div class="s-tile">
|
||||
<svg class="s-tile-ico"><use href="#i-plus" /></svg>
|
||||
<span class="s-tile-txt">
|
||||
<span class="s-tile-t">Another account on example.com</span>
|
||||
<span class="s-tile-d">Server already known</span>
|
||||
</span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
<div class="s-tile">
|
||||
<svg class="s-tile-ico"><use href="#i-mail" /></svg>
|
||||
<span class="s-tile-txt"><span class="s-tile-t">A different provider</span></span>
|
||||
<svg class="s-tile-chev"><use href="#i-chev" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="d-step is-alt">Add mode</span>
|
||||
<span class="d-cap-title">Reuse what we already know</span>
|
||||
<p class="d-cap-body">
|
||||
<code>isAddMode</code> currently shows the same blank three-field form. Most second accounts live on
|
||||
the same server as the first, so offer that first and skip discovery entirely.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="d-section">
|
||||
<h2 class="d-h2">Notes for the build</h2>
|
||||
<hr class="d-rule" />
|
||||
<ul class="d-notes">
|
||||
<li>
|
||||
<svg><use href="#i-globe" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Discovery is three cheap probes, in order</div>
|
||||
<p class="d-note-b">
|
||||
From <code>ada@example.com</code>: try <code>https://example.com/.well-known/jmap</code>, then
|
||||
<code>https://mail.example.com/.well-known/jmap</code>, then the same host the last account used.
|
||||
First 200 wins. Budget the whole thing at two seconds and fall through to Path B on timeout — a
|
||||
slow lookup must never be worse than the manual form.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<svg><use href="#i-shield" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Nothing new on the wire</div>
|
||||
<p class="d-note-b">
|
||||
Every screen drives an existing store action — <code>loginViaWebmail</code>,
|
||||
<code>loginViaPairing</code>, <code>login</code>. The redesign is routing and copy; the auth
|
||||
surface, the state machine, and <code>parseQrLoginPayload</code> stay as they are.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<svg><use href="#i-alert" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Back must always work</div>
|
||||
<p class="d-note-b">
|
||||
A multi-step flow earns a hardware-back handler on Android. Every step goes back one, and step 1
|
||||
leaves the flow — never a trapped screen with a filled-in field you can't clear.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<svg><use href="#i-key" /></svg>
|
||||
<div>
|
||||
<div class="d-note-h">Let the OS help</div>
|
||||
<p class="d-note-b">
|
||||
<code>autoComplete="email"</code> and <code>textContentType="username"</code> on the address field,
|
||||
<code>"password"</code> on the fallback field, so the platform password manager offers to fill and
|
||||
save. The current screen sets neither.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p class="d-foot">Mockup · repos/react-native/mockups/login.html · static HTML, no interaction wired up</p>
|
||||
</div>
|
||||
Generated
+123
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.58",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.58",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
@@ -26,6 +27,7 @@
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -4757,6 +4759,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5204,6 +5212,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
"integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
@@ -6202,6 +6230,12 @@
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -6318,6 +6352,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -6819,6 +6859,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -6846,6 +6898,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lighthouse-logger": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
|
||||
@@ -8006,6 +8067,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-png": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz",
|
||||
@@ -8217,6 +8284,12 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -8585,6 +8658,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||
@@ -8989,6 +9083,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -9226,6 +9326,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -9762,6 +9877,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.58",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
@@ -32,6 +33,7 @@
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -42,10 +42,10 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
describe('queryEvents', () => {
|
||||
it('should send a CalendarEvent/query without server-side filter', async () => {
|
||||
// Stalwart rejects inCalendars/after/before, so we fetch unfiltered and
|
||||
// filter client-side. Asserting on the absence of `filter` keeps that
|
||||
// contract pinned.
|
||||
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
|
||||
// Stalwart rejects the draft's plural `inCalendars` filter (and
|
||||
// after/before); calendars are restricted via singular `inCalendar`
|
||||
// conditions and date filtering stays client-side.
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||
});
|
||||
@@ -55,13 +55,42 @@ describe('calendar operations', () => {
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[0]).toBe('CalendarEvent/query');
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
expect(call[1].filter).toEqual({ inCalendar: 'cal-1' });
|
||||
expect(call[1].accountId).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('should OR multiple calendars and respect the target account', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryEvents(['cal-1', 'cal-2'], '', '', 'acc-shared');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({
|
||||
operator: 'OR',
|
||||
conditions: [{ inCalendar: 'cal-1' }, { inCalendar: 'cal-2' }],
|
||||
});
|
||||
expect(call[1].accountId).toBe('acc-shared');
|
||||
});
|
||||
|
||||
it('should omit the filter when no calendars are given', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
|
||||
});
|
||||
|
||||
await queryEvents([], '', '');
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEvents', () => {
|
||||
it('should fetch events by id with expected properties', async () => {
|
||||
it('should fetch events by id with singular JSCalendar 2.0 recurrence properties', async () => {
|
||||
// Stalwart (calcard) names the property `recurrenceRule` (singular,
|
||||
// single object) — requesting the RFC 8984 plural form returns no
|
||||
// recurrence data and repeating events vanish from the grid (#13).
|
||||
const events = [{ id: 'ev1', title: 'Meeting', start: '2026-03-29T10:00:00' }];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
@@ -73,7 +102,48 @@ describe('calendar operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].properties).toContain('title');
|
||||
expect(call[1].properties).toContain('start');
|
||||
expect(call[1].properties).toContain('recurrenceRules');
|
||||
expect(call[1].properties).toContain('recurrenceRule');
|
||||
expect(call[1].properties).toContain('excludedRecurrenceRule');
|
||||
expect(call[1].properties).not.toContain('recurrenceRules');
|
||||
expect(call[1].properties).not.toContain('excludedRecurrenceRules');
|
||||
});
|
||||
|
||||
it('should normalize a singular recurrenceRule object to the plural array form', async () => {
|
||||
const events = [{
|
||||
id: 'ev1',
|
||||
title: 'Every 5 weeks',
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
excludedRecurrenceRule: { frequency: 'weekly', interval: 10 },
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([
|
||||
{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 5 },
|
||||
]);
|
||||
expect(event.excludedRecurrenceRules).toEqual([
|
||||
{ frequency: 'weekly', interval: 10 },
|
||||
]);
|
||||
expect(event).not.toHaveProperty('recurrenceRule');
|
||||
expect(event).not.toHaveProperty('excludedRecurrenceRule');
|
||||
});
|
||||
|
||||
it('should keep an array-valued recurrenceRule as-is (JMAP-created events)', async () => {
|
||||
const events = [{
|
||||
id: 'ev1',
|
||||
title: 'Weekly',
|
||||
start: '2026-03-02T10:00:00',
|
||||
recurrenceRule: [{ frequency: 'weekly', interval: 2 }],
|
||||
}];
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/get', { list: events }, '0']],
|
||||
});
|
||||
|
||||
const [event] = await getEvents(['ev1']);
|
||||
expect(event.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,10 +159,39 @@ describe('calendar operations', () => {
|
||||
'cal-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(created);
|
||||
// The /set response echoes only server-set properties; the return value
|
||||
// merges them over the submitted payload.
|
||||
expect(result).toEqual({
|
||||
id: 'ev-new',
|
||||
title: 'Lunch',
|
||||
start: '2026-03-30T12:00:00',
|
||||
duration: 'PT1H',
|
||||
calendarIds: { 'cal-1': true },
|
||||
});
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].create['new-event'].calendarIds).toEqual({ 'cal-1': true });
|
||||
});
|
||||
|
||||
it('should send recurrence as a singular cleaned object', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', { created: { 'new-event': { id: 'ev-new' } } }, '0']],
|
||||
});
|
||||
|
||||
const result = await createEvent(
|
||||
{
|
||||
title: 'Series',
|
||||
start: '2026-03-30T12:00:00',
|
||||
recurrenceRules: [{ frequency: 'weekly', interval: 5, until: null as unknown as string }],
|
||||
},
|
||||
'cal-1',
|
||||
);
|
||||
|
||||
const sent = mockRequest.mock.calls[0][0][0][1].create['new-event'];
|
||||
expect(sent.recurrenceRule).toEqual({ frequency: 'weekly', interval: 5 });
|
||||
expect(sent).not.toHaveProperty('recurrenceRules');
|
||||
// The returned event exposes the internal plural form again.
|
||||
expect(result.recurrenceRules).toEqual([{ frequency: 'weekly', interval: 5 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
@@ -106,6 +205,23 @@ describe('calendar operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].update).toEqual({ ev1: { title: 'Updated Meeting' } });
|
||||
});
|
||||
|
||||
it('should convert recurrenceRules updates to the singular property', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['CalendarEvent/set', { updated: {} }, '0']],
|
||||
});
|
||||
|
||||
await updateEvent('ev1', { recurrenceRules: [{ frequency: 'monthly' }] });
|
||||
expect(mockRequest.mock.calls[0][0][0][1].update).toEqual({
|
||||
ev1: { recurrenceRule: { frequency: 'monthly' } },
|
||||
});
|
||||
|
||||
// Empty array means "remove recurrence" — sent as an explicit null.
|
||||
await updateEvent('ev1', { recurrenceRules: [] });
|
||||
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({
|
||||
ev1: { recurrenceRule: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvents', () => {
|
||||
|
||||
@@ -5,12 +5,16 @@ vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getAccountName: vi.fn(() => 'me@example.com'),
|
||||
getSharedMailAccounts: vi.fn(() => []),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import {
|
||||
getMailboxes,
|
||||
getSharedMailboxes,
|
||||
queryEmails,
|
||||
getEmails,
|
||||
getFullEmail,
|
||||
@@ -41,13 +45,107 @@ describe('email operations', () => {
|
||||
|
||||
const result = await getMailboxes();
|
||||
|
||||
expect(result).toEqual(mailboxes);
|
||||
// Own folders keep their raw ids and pick up ownership metadata.
|
||||
expect(result).toEqual(mailboxes.map((m) => ({
|
||||
...m,
|
||||
accountId: 'acc-1',
|
||||
accountName: 'me@example.com',
|
||||
isShared: false,
|
||||
})));
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
[['Mailbox/get', { accountId: 'acc-1' }, '0']],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedMailboxes', () => {
|
||||
const mockShared = jmapClient.getSharedMailAccounts as ReturnType<typeof vi.fn>;
|
||||
|
||||
it('returns nothing when the session has no shared accounts', async () => {
|
||||
mockShared.mockReturnValue([]);
|
||||
|
||||
expect(await getSharedMailboxes()).toEqual([]);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefixes ids and parent links per owning account', async () => {
|
||||
mockShared.mockReturnValue([{ id: 'grp-1', name: 'Support' }]);
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Mailbox/get', {
|
||||
list: [
|
||||
{ id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 4, unreadEmails: 2 },
|
||||
{ id: 'mb-2', name: 'Escalations', parentId: 'mb-1', totalEmails: 1, unreadEmails: 0 },
|
||||
],
|
||||
}, '0']],
|
||||
});
|
||||
|
||||
const result = await getSharedMailboxes();
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith(
|
||||
[['Mailbox/get', { accountId: 'grp-1' }, '0']],
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'grp-1:mb-1',
|
||||
originalId: 'mb-1',
|
||||
name: 'Inbox',
|
||||
role: 'inbox',
|
||||
parentId: undefined,
|
||||
totalEmails: 4,
|
||||
unreadEmails: 2,
|
||||
accountId: 'grp-1',
|
||||
accountName: 'Support',
|
||||
isShared: true,
|
||||
},
|
||||
{
|
||||
id: 'grp-1:mb-2',
|
||||
originalId: 'mb-2',
|
||||
name: 'Escalations',
|
||||
parentId: 'grp-1:mb-1',
|
||||
totalEmails: 1,
|
||||
unreadEmails: 0,
|
||||
accountId: 'grp-1',
|
||||
accountName: 'Support',
|
||||
isShared: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps each response back to its account and skips the ones that errored', async () => {
|
||||
mockShared.mockReturnValue([
|
||||
{ id: 'grp-1', name: 'Support' },
|
||||
{ id: 'grp-2', name: 'Sales' },
|
||||
]);
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['error', { type: 'accountNotFound' }, '0'],
|
||||
['Mailbox/get', { list: [{ id: 'mb-9', name: 'Inbox', role: 'inbox' }] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSharedMailboxes();
|
||||
|
||||
expect(result.map((m) => m.id)).toEqual(['grp-2:mb-9']);
|
||||
expect(result[0].accountName).toBe('Sales');
|
||||
});
|
||||
|
||||
it('chunks requests to maxCallsInRequest', async () => {
|
||||
mockShared.mockReturnValue([
|
||||
{ id: 'grp-1', name: 'Support' },
|
||||
{ id: 'grp-2', name: 'Sales' },
|
||||
{ id: 'grp-3', name: 'Billing' },
|
||||
]);
|
||||
(jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>).mockReturnValue(2);
|
||||
mockRequest.mockResolvedValue({ methodResponses: [] });
|
||||
|
||||
await getSharedMailboxes();
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequest.mock.calls[0][0]).toHaveLength(2);
|
||||
expect(mockRequest.mock.calls[1][0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEmails', () => {
|
||||
it('should query emails with default options', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
hasCapability: vi.fn(),
|
||||
currentSession: null as unknown,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../blob', () => ({
|
||||
getDownloadUrl: vi.fn(
|
||||
(blobId: string, _name?: string, _type?: string, accountId?: string) =>
|
||||
`https://dl/${accountId ?? 'own'}/${blobId}`,
|
||||
),
|
||||
uploadBlob: vi.fn(),
|
||||
uploadBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import {
|
||||
createFolder,
|
||||
deleteFileNodes,
|
||||
getAllFileNodesAcrossAccounts,
|
||||
getFileNodeDownloadUrl,
|
||||
getPrincipals,
|
||||
isCrossAccountId,
|
||||
isFolder,
|
||||
setFileNodeShare,
|
||||
} from '../files';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
||||
|
||||
function setSession(session: unknown) {
|
||||
(jmapClient as { currentSession: unknown }).currentSession = session;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasCapability.mockImplementation(
|
||||
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
|
||||
);
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': { name: 'me@example.com', isPersonal: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFolder', () => {
|
||||
it('treats blob-less nodes as folders and anything with a blob as a file', () => {
|
||||
// #379: a blob-marked node — even the legacy type:'d' dir markers — is a
|
||||
// 0-byte file that nothing can be parented under.
|
||||
expect(isFolder({ blobId: null })).toBe(true);
|
||||
expect(isFolder({ blobId: undefined })).toBe(true);
|
||||
expect(isFolder({ blobId: 'blob-1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('creates a real container node: no blobId/type/size, parentId set', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
|
||||
});
|
||||
|
||||
await createFolder('Docs', 'parent-1');
|
||||
|
||||
const [method, args] = mockRequest.mock.calls[0][0][0];
|
||||
expect(method).toBe('FileNode/set');
|
||||
expect(args.create['new-dir']).toEqual({ name: 'Docs', parentId: 'parent-1' });
|
||||
});
|
||||
|
||||
it('omits parentId at the root', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { created: { 'new-dir': { id: 'f1' } } }, '0']],
|
||||
});
|
||||
|
||||
await createFolder('Docs', null);
|
||||
|
||||
const args = mockRequest.mock.calls[0][0][0][1];
|
||||
expect(args.create['new-dir']).toEqual({ name: 'Docs' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFileNodes', () => {
|
||||
it('lets the server cascade into folder children', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { destroyed: ['f1'] }, '0']],
|
||||
});
|
||||
|
||||
await deleteFileNodes(['f1']);
|
||||
|
||||
const args = mockRequest.mock.calls[0][0][0][1];
|
||||
expect(args.destroy).toEqual(['f1']);
|
||||
expect(args.onDestroyRemoveChildren).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the server refuses a destroy', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['FileNode/set', { notDestroyed: { f1: { description: 'forbidden' } } }, '0'],
|
||||
],
|
||||
});
|
||||
|
||||
await expect(deleteFileNodes(['f1'])).rejects.toThrow('forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFileNodeShare', () => {
|
||||
it('patches shareWith/{principalId} and requests the principals:owner capability', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
|
||||
});
|
||||
const rights = {
|
||||
mayRead: true, mayAddChildren: false, mayRename: false,
|
||||
mayDelete: false, mayModifyContent: false, mayShare: false,
|
||||
};
|
||||
|
||||
await setFileNodeShare('node-1', 'principal-2', rights);
|
||||
|
||||
const [calls, using] = mockRequest.mock.calls[0];
|
||||
expect(calls[0][1].update['node-1']).toEqual({ 'shareWith/principal-2': rights });
|
||||
expect(using).toContain(CAPABILITIES.PRINCIPALS_OWNER);
|
||||
});
|
||||
|
||||
it('sends null rights to revoke access', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/set', { updated: { 'node-1': null } }, '0']],
|
||||
});
|
||||
|
||||
await setFileNodeShare('node-1', 'principal-2', null);
|
||||
|
||||
const args = mockRequest.mock.calls[0][0][0][1];
|
||||
expect(args.update['node-1']).toEqual({ 'shareWith/principal-2': null });
|
||||
});
|
||||
|
||||
it('throws when the server rejects or does not confirm the update', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['FileNode/set', { notUpdated: { 'node-1': { description: 'no mayShare right' } } }, '0'],
|
||||
],
|
||||
});
|
||||
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow('no mayShare right');
|
||||
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', {}, '0']] });
|
||||
await expect(setFileNodeShare('node-1', 'p2', null)).rejects.toThrow(
|
||||
'Server did not confirm the share update',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFileNodesAcrossAccounts', () => {
|
||||
it('namespaces ids of nodes from other principals and tags them isShared', async () => {
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': { name: 'me@example.com', isPersonal: true },
|
||||
'acc-2': { name: 'Team', isPersonal: false },
|
||||
},
|
||||
});
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['FileNode/get', { list: [{ id: 'own-1', name: 'mine.txt', parentId: null, blobId: 'b1' }] }, '0'],
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['FileNode/get', {
|
||||
list: [
|
||||
{ id: 's1', name: 'Shared Folder', parentId: null, blobId: null },
|
||||
{ id: 's2', name: 'inside.txt', parentId: 's1', blobId: 'b2' },
|
||||
],
|
||||
}, '0'],
|
||||
],
|
||||
});
|
||||
|
||||
const nodes = await getAllFileNodesAcrossAccounts();
|
||||
|
||||
expect(nodes).toHaveLength(3);
|
||||
const own = nodes.find((n) => n.id === 'own-1')!;
|
||||
expect(own.isShared).toBe(false);
|
||||
expect(isCrossAccountId(own.id)).toBe(false);
|
||||
|
||||
const folder = nodes.find((n) => n.name === 'Shared Folder')!;
|
||||
expect(folder.id).toBe('acc-2:s1');
|
||||
expect(folder.isShared).toBe(true);
|
||||
expect(folder.accountId).toBe('acc-2');
|
||||
expect(folder.accountName).toBe('Team');
|
||||
|
||||
// parentId is namespaced the same way so child lookups keep working.
|
||||
const child = nodes.find((n) => n.name === 'inside.txt')!;
|
||||
expect(child.parentId).toBe('acc-2:s1');
|
||||
});
|
||||
|
||||
it('skips personal accounts without the filenode capability', async () => {
|
||||
setSession({
|
||||
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
|
||||
accounts: {
|
||||
'acc-1': { name: 'me@example.com', isPersonal: true },
|
||||
'acc-3': { name: 'other personal', isPersonal: true },
|
||||
},
|
||||
});
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['FileNode/get', { list: [] }, '0']],
|
||||
});
|
||||
|
||||
await getAllFileNodesAcrossAccounts();
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileNodeDownloadUrl', () => {
|
||||
it('routes shared blobs to the owning account', () => {
|
||||
const url = getFileNodeDownloadUrl({
|
||||
id: 'acc-2:s2', name: 'inside.txt', type: 'text/plain',
|
||||
blobId: 'b2', accountId: 'acc-2',
|
||||
});
|
||||
expect(url).toBe('https://dl/acc-2/b2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrincipals', () => {
|
||||
it('queries then gets via back-reference', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [
|
||||
['Principal/query', { ids: ['p1'] }, '0'],
|
||||
['Principal/get', { list: [{ id: 'p1', name: 'other', type: 'individual' }] }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
const principals = await getPrincipals();
|
||||
|
||||
expect(principals).toEqual([{ id: 'p1', name: 'other', type: 'individual' }]);
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls[0][0]).toBe('Principal/query');
|
||||
expect(calls[1][0]).toBe('Principal/get');
|
||||
expect(calls[1][1]['#ids']).toEqual({ resultOf: '0', name: 'Principal/query', path: '/ids' });
|
||||
});
|
||||
|
||||
it('returns empty when the server lacks the principals capability', async () => {
|
||||
mockHasCapability.mockReturnValue(false);
|
||||
expect(await getPrincipals()).toEqual([]);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// Stalwart account-security management. Mirrors the webmail's
|
||||
// stores/account-security-store.ts, talking to Stalwart's JMAP extension
|
||||
// methods (x:AccountPassword, x:AppPassword, x:ApiKey, x:AccountSettings,
|
||||
// x:Account). The webmail proxies these through a server-side passthrough so
|
||||
// the basic-auth header stays in an httpOnly cookie; the mobile client already
|
||||
// holds its own credentials, so it calls the JMAP endpoint directly with the
|
||||
// `urn:stalwart:jmap` capability in `using`.
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
|
||||
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
|
||||
const STALWART_USING = [CAPABILITIES.CORE, STALWART_CAPABILITY];
|
||||
|
||||
export type EncryptionType = 'Disabled' | 'Aes128' | 'Aes256';
|
||||
|
||||
export interface AppCredentialInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
allowedIps: string[];
|
||||
}
|
||||
|
||||
export type AppPasswordInfo = AppCredentialInfo;
|
||||
export type ApiKeyInfo = AppCredentialInfo;
|
||||
|
||||
export interface AppCredentialInput {
|
||||
description: string;
|
||||
expiresAt?: string | null;
|
||||
allowedIps?: string[];
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordInfo[];
|
||||
apiKeys: ApiKeyInfo[];
|
||||
}
|
||||
|
||||
export interface PrincipalInfo {
|
||||
displayName: string;
|
||||
emails: string[];
|
||||
quota: number;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
type MethodResponse = [string, Record<string, any>, string];
|
||||
|
||||
// True once a live session advertises Stalwart's JMAP extension. Without it
|
||||
// none of the x:* methods exist, so the screen shows a "not available" notice.
|
||||
export function isStalwartSupported(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
return STALWART_CAPABILITY in (session.capabilities ?? {});
|
||||
}
|
||||
|
||||
// Pull a single method response out by its call id and throw on a JMAP method
|
||||
// error (the `['error', {type, description}, id]` envelope).
|
||||
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
|
||||
const match = responses.find((r) => r[2] === callId);
|
||||
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
|
||||
if (match[0] === 'error') {
|
||||
const err = match[1] as { type?: string; description?: string };
|
||||
throw new Error(err.description || err.type || 'JMAP error');
|
||||
}
|
||||
return match[1] as T;
|
||||
}
|
||||
|
||||
async function send(methodCalls: [string, Record<string, unknown>, string][]): Promise<MethodResponse[]> {
|
||||
const res = await jmapClient.request(methodCalls, STALWART_USING);
|
||||
return res.methodResponses as MethodResponse[];
|
||||
}
|
||||
|
||||
function credentialFromResult(raw: Record<string, unknown>): AppCredentialInfo {
|
||||
const allowedIps =
|
||||
raw.allowedIps && typeof raw.allowedIps === 'object'
|
||||
? Object.keys(raw.allowedIps as Record<string, unknown>)
|
||||
: [];
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
description: typeof raw.description === 'string' ? raw.description : '',
|
||||
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : null,
|
||||
expiresAt: typeof raw.expiresAt === 'string' ? raw.expiresAt : null,
|
||||
allowedIps,
|
||||
};
|
||||
}
|
||||
|
||||
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
|
||||
if (!ips || ips.length === 0) return undefined;
|
||||
return Object.fromEntries(ips.map((ip) => [ip, true]));
|
||||
}
|
||||
|
||||
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = { description: input.description };
|
||||
if (input.expiresAt) body.expiresAt = input.expiresAt;
|
||||
const allowed = ipsToMap(input.allowedIps);
|
||||
if (allowed) body.allowedIps = allowed;
|
||||
return body;
|
||||
}
|
||||
|
||||
function extractEncryptionType(raw: unknown): EncryptionType {
|
||||
if (!raw || typeof raw !== 'object') return 'Disabled';
|
||||
const type = (raw as { ['@type']?: string })['@type'];
|
||||
if (type === 'Aes128' || type === 'Aes256') return type;
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAuthInfo(): Promise<AuthInfo> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'p'],
|
||||
['x:AppPassword/query', { accountId }, 'aq'],
|
||||
['x:ApiKey/query', { accountId }, 'kq'],
|
||||
]);
|
||||
|
||||
const passwordResult = resultFor<{ list?: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(responses, 'p');
|
||||
const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
|
||||
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
|
||||
|
||||
const otpAuth = passwordResult.list?.[0]?.otpAuth;
|
||||
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
|
||||
|
||||
const followUps: [string, Record<string, unknown>, string][] = [];
|
||||
if (appPwQuery.ids?.length) {
|
||||
followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
|
||||
}
|
||||
|
||||
let appPasswords: AppPasswordInfo[] = [];
|
||||
let apiKeys: ApiKeyInfo[] = [];
|
||||
if (followUps.length) {
|
||||
const followUpResponses = await send(followUps);
|
||||
if (appPwQuery.ids?.length) {
|
||||
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'app');
|
||||
appPasswords = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
const r = resultFor<{ list?: Array<Record<string, unknown>> }>(followUpResponses, 'key');
|
||||
apiKeys = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
}
|
||||
|
||||
return { otpEnabled, appPasswords, apiKeys };
|
||||
}
|
||||
|
||||
export async function fetchEncryptionType(): Promise<EncryptionType> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([['x:AccountSettings/get', { accountId, ids: ['singleton'] }, '0']]);
|
||||
const result = resultFor<{ list?: Array<{ encryptionAtRest?: unknown }> }>(responses, '0');
|
||||
return extractEncryptionType(result.list?.[0]?.encryptionAtRest);
|
||||
}
|
||||
|
||||
export async function fetchPrincipal(): Promise<PrincipalInfo> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([['x:Account/get', { accountId, ids: [accountId] }, '0']]);
|
||||
const result = resultFor<{
|
||||
list?: Array<{
|
||||
description?: string | null;
|
||||
aliases?: Record<string, { name?: string; enabled?: boolean }>;
|
||||
quotas?: { maxDiskQuota?: number };
|
||||
roles?: { ['@type']?: string };
|
||||
name?: string;
|
||||
}>;
|
||||
}>(responses, '0');
|
||||
|
||||
const acc = result.list?.[0];
|
||||
const aliasAddresses = acc?.aliases
|
||||
? Object.values(acc.aliases).flatMap((a) => (a && a.enabled !== false && a.name ? [a.name] : []))
|
||||
: [];
|
||||
const primaryEmail = acc?.name ? [acc.name] : [];
|
||||
return {
|
||||
displayName: acc?.description ?? '',
|
||||
emails: [...primaryEmail, ...aliasAddresses],
|
||||
quota: acc?.quotas?.maxDiskQuota ?? 0,
|
||||
roles: acc?.roles?.['@type'] ? [acc.roles['@type']] : [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{ accountId, update: { singleton: { currentSecret: currentPassword, secret: newPassword } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to change password');
|
||||
}
|
||||
|
||||
export async function updateDisplayName(displayName: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
['x:AccountSettings/set', { accountId, update: { singleton: { description: displayName } } }, '0'],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to update display name');
|
||||
}
|
||||
|
||||
export async function enableTotp(currentPassword: string, otpUrl: string, otpCode: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl, otpCode } } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to enable two-factor authentication');
|
||||
}
|
||||
|
||||
export async function disableTotp(currentPassword: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{ accountId, update: { singleton: { currentSecret: currentPassword, otpAuth: { otpUrl: null } } } },
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
const result = resultFor<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notUpdated?.singleton;
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to disable two-factor authentication');
|
||||
}
|
||||
|
||||
type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
|
||||
|
||||
async function createCredential(method: SetMethod, input: AppCredentialInput): Promise<{ id: string; secret: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const tmpId = 'new';
|
||||
const responses = await send([[method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0']]);
|
||||
const result = resultFor<{
|
||||
created?: Record<string, { id: string; secret: string }>;
|
||||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||||
}>(responses, '0');
|
||||
|
||||
const notCreated = result.notCreated?.[tmpId];
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to create credential');
|
||||
const created = result.created?.[tmpId];
|
||||
if (!created?.id || !created.secret) throw new Error('Server did not return the created credential');
|
||||
return { id: created.id, secret: created.secret };
|
||||
}
|
||||
|
||||
async function removeCredential(method: SetMethod, id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const responses = await send([[method, { accountId, destroy: [id] }, '0']]);
|
||||
const result = resultFor<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(responses, '0');
|
||||
const failure = result.notDestroyed?.[id];
|
||||
if (failure) throw new Error(failure.description || failure.type || 'Failed to remove credential');
|
||||
}
|
||||
|
||||
export function createAppPassword(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
|
||||
return createCredential('x:AppPassword/set', input);
|
||||
}
|
||||
|
||||
export function removeAppPassword(id: string): Promise<void> {
|
||||
return removeCredential('x:AppPassword/set', id);
|
||||
}
|
||||
|
||||
export function createApiKey(input: AppCredentialInput): Promise<{ id: string; secret: string }> {
|
||||
return createCredential('x:ApiKey/set', input);
|
||||
}
|
||||
|
||||
export function removeApiKey(id: string): Promise<void> {
|
||||
return removeCredential('x:ApiKey/set', id);
|
||||
}
|
||||
+10
-3
@@ -67,13 +67,17 @@ export async function uploadBlob(
|
||||
export async function uploadBytes(
|
||||
bytes: Uint8Array,
|
||||
type: string,
|
||||
// Blobs are account-scoped: a message imported into a shared/group account's
|
||||
// folder has to be uploaded to that account, not the user's own.
|
||||
accountId?: string,
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const targetAccountId = accountId ?? jmapClient.accountId;
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
encodeURIComponent(targetAccountId),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
@@ -99,7 +103,7 @@ export async function uploadBytes(
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
const nested = raw[targetAccountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
@@ -116,12 +120,15 @@ export function getDownloadUrl(
|
||||
blobId: string,
|
||||
name?: string,
|
||||
type?: string,
|
||||
// Blobs of nodes shared by another principal live in the owner's account;
|
||||
// downloading them with our own accountId 404s.
|
||||
accountId?: string,
|
||||
): string {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
return session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
|
||||
.replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name ?? 'download'))
|
||||
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
|
||||
|
||||
+212
-25
@@ -4,18 +4,127 @@ import type { Calendar, CalendarEvent } from './types';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
|
||||
/**
|
||||
* IANA time zone of the device, sent as the `timeZone` argument on
|
||||
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
|
||||
* filter values and computes utcStart/utcEnd for floating events in this zone,
|
||||
* defaulting to UTC when absent — which shifts range boundaries and
|
||||
* floating-event times for any user not in UTC. Stalwart ignores unparseable
|
||||
* values, so sending it is always safe.
|
||||
*/
|
||||
function getUserTimeZone(): string | undefined {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CalendarEvent/query filter restricting results to the given
|
||||
* calendars. Stalwart implements the singular `inCalendar` condition (one
|
||||
* calendar id per condition), not the draft's plural `inCalendars` array —
|
||||
* sending the plural form fails the whole query with `unsupportedFilter`.
|
||||
* Multiple calendars are expressed as an OR of singular conditions.
|
||||
*/
|
||||
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
|
||||
if (calendarIds.length === 1) {
|
||||
return { inCalendar: calendarIds[0] };
|
||||
}
|
||||
return {
|
||||
operator: 'OR',
|
||||
conditions: calendarIds.map((id) => ({ inCalendar: id })),
|
||||
};
|
||||
}
|
||||
|
||||
// Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property
|
||||
// names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single
|
||||
// object, not RFC 8984's plural array forms. Requesting the plural names
|
||||
// returns no recurrence data at all, so recurring events silently render as
|
||||
// one-off events (#13). Request the singular names and normalize below.
|
||||
const CALENDAR_EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description',
|
||||
'start', 'duration', 'timeZone', 'showWithoutTime',
|
||||
'utcStart', 'utcEnd', 'status', 'freeBusyStatus',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRules',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRules',
|
||||
'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule',
|
||||
'recurrenceOverrides', 'excludedRecurrenceRule', 'recurrenceId',
|
||||
'replyTo', 'organizerCalendarAddress', 'sequence',
|
||||
'locations', 'virtualLocations',
|
||||
'progress', 'due', 'priority', 'percentComplete',
|
||||
'links', 'created', 'updated',
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize Stalwart's singular recurrence property names to the RFC 8984
|
||||
* plural array forms the client uses internally. JSCalendar 2.0 defines
|
||||
* recurrenceRule as a single object, but Stalwart may also return an array
|
||||
* (for events created via JMAP), so both forms are handled. Mirrors webmail's
|
||||
* normalizeStalwartPropertyNames.
|
||||
*/
|
||||
export function normalizeRecurrenceProperties<T extends Partial<CalendarEvent>>(event: T): T {
|
||||
const raw = event as Record<string, unknown>;
|
||||
let patched = false;
|
||||
const updates: Partial<CalendarEvent> = {};
|
||||
|
||||
if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) {
|
||||
const val = raw.recurrenceRule;
|
||||
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||
updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules'];
|
||||
} else {
|
||||
updates.recurrenceRules = val as CalendarEvent['recurrenceRules'];
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) {
|
||||
const val = raw.excludedRecurrenceRule;
|
||||
if (val != null && !Array.isArray(val) && typeof val === 'object') {
|
||||
updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules'];
|
||||
} else {
|
||||
updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules'];
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) return event;
|
||||
|
||||
const result = { ...event, ...updates } as T;
|
||||
delete (result as Record<string, unknown>).recurrenceRule;
|
||||
delete (result as Record<string, unknown>).excludedRecurrenceRule;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the client's plural recurrence arrays to the singular JSCalendar 2.0
|
||||
* properties Stalwart expects on write, dropping null rule fields. An empty
|
||||
* array (or null) becomes an explicit null so "remove recurrence" round-trips.
|
||||
* Mirrors webmail's cleanRecurrenceRules.
|
||||
*/
|
||||
function cleanRecurrenceRules(event: Record<string, unknown>): void {
|
||||
const keyMap: Record<string, string> = {
|
||||
recurrenceRules: 'recurrenceRule',
|
||||
excludedRecurrenceRules: 'excludedRecurrenceRule',
|
||||
};
|
||||
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
|
||||
const rules = event[pluralKey];
|
||||
if (rules === undefined) continue;
|
||||
delete event[pluralKey];
|
||||
if (!Array.isArray(rules)) {
|
||||
event[singularKey] = rules;
|
||||
continue;
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
event[singularKey] = null;
|
||||
continue;
|
||||
}
|
||||
const rule = rules[0] as Record<string, unknown>;
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(rule)) {
|
||||
if (v !== null) cleaned[k] = v;
|
||||
}
|
||||
event[singularKey] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
function methodResult<T = any>(res: any, index = 0): T {
|
||||
const entry = res?.methodResponses?.[index];
|
||||
if (!entry) throw new Error('JMAP: empty method response');
|
||||
@@ -26,48 +135,97 @@ function methodResult<T = any>(res: any, index = 0): T {
|
||||
return entry[1] as T;
|
||||
}
|
||||
|
||||
// Other session accounts that expose the calendars capability hold calendars
|
||||
// shared with the user (JMAP surfaces shared data under the sharer's account).
|
||||
function sharedCalendarAccountIds(): string[] {
|
||||
const session = jmapClient.currentSession;
|
||||
const primary = jmapClient.accountId;
|
||||
return Object.entries(session?.accounts ?? {})
|
||||
.filter(([id, info]) =>
|
||||
id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS])
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export async function getCalendars(): Promise<Calendar[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/get', { accountId }, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ list: Calendar[] }>(res).list ?? [];
|
||||
const own = methodResult<{ list: Calendar[] }>(res).list ?? [];
|
||||
|
||||
// Calendars shared with the user live in other session accounts. Failures
|
||||
// there (revoked share, transient error) must not hide the user's own
|
||||
// calendars, so each shared account is fetched best-effort.
|
||||
const shared = await Promise.all(
|
||||
sharedCalendarAccountIds().map(async (sharedAccountId) => {
|
||||
try {
|
||||
const sharedRes = await jmapClient.request(
|
||||
[['Calendar/get', { accountId: sharedAccountId }, '0']],
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
|
||||
// JMAP calendar ids are only unique within an account, so a shared
|
||||
// "default" calendar collides with the user's own "default". Namespace
|
||||
// the id (keeping the real id in originalId) so visibility toggles,
|
||||
// event-query routing, and create/default mutations don't cross wires.
|
||||
// Mirrors webmail's getAllCalendars.
|
||||
return list.map((cal) => ({
|
||||
...cal,
|
||||
id: `${sharedAccountId}:${cal.id}`,
|
||||
originalId: cal.id,
|
||||
accountId: sharedAccountId,
|
||||
isShared: true,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return [...own, ...shared.flat()];
|
||||
}
|
||||
|
||||
export async function queryEvents(
|
||||
_calendarIds: string[],
|
||||
calendarIds: string[],
|
||||
_after: string,
|
||||
_before: string,
|
||||
accountId?: string,
|
||||
): Promise<string[]> {
|
||||
// Stalwart rejects inCalendars/after/before filters on CalendarEvent/query.
|
||||
// Fetch all events in the account; calendar visibility + date filtering is
|
||||
// done client-side.
|
||||
const accountId = jmapClient.accountId;
|
||||
// Stalwart rejects after/before filters on CalendarEvent/query; date
|
||||
// filtering is done client-side. Calendars are restricted via the singular
|
||||
// `inCalendar` condition (the plural `inCalendars` fails the whole query
|
||||
// with unsupportedFilter on Stalwart).
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId: account, limit: 1000 };
|
||||
const timeZone = getUserTimeZone();
|
||||
if (timeZone) args.timeZone = timeZone;
|
||||
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/query', { accountId, limit: 1000 }, '0']],
|
||||
[['CalendarEvent/query', args, '0']],
|
||||
USING,
|
||||
);
|
||||
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
||||
}
|
||||
|
||||
export async function getEvents(ids: string[]): Promise<CalendarEvent[]> {
|
||||
export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const account = accountId || jmapClient.accountId;
|
||||
const timeZone = getUserTimeZone();
|
||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||
const all: CalendarEvent[] = [];
|
||||
for (let i = 0; i < ids.length; i += batchSize) {
|
||||
const batch = ids.slice(i, i + batchSize);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/get', {
|
||||
accountId,
|
||||
accountId: account,
|
||||
ids: batch,
|
||||
properties: CALENDAR_EVENT_PROPERTIES,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
|
||||
all.push(...list);
|
||||
all.push(...list.map(normalizeRecurrenceProperties));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -91,13 +249,14 @@ export async function createEvent(
|
||||
event: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
sendSchedulingMessages?: boolean,
|
||||
targetAccountId?: string,
|
||||
): Promise<CalendarEvent> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const payload: Record<string, unknown> = { ...event, calendarIds: { [calendarId]: true } };
|
||||
cleanRecurrenceRules(payload);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, {
|
||||
create: {
|
||||
'new-event': { ...event, calendarIds: { [calendarId]: true } },
|
||||
},
|
||||
create: { 'new-event': payload },
|
||||
}, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
@@ -110,19 +269,24 @@ export async function createEvent(
|
||||
const err = result.notCreated?.['new-event'];
|
||||
throw new Error(err?.description || err?.type || 'Failed to create event');
|
||||
}
|
||||
return created;
|
||||
// The /set response echoes only server-set properties; merge them over the
|
||||
// submitted payload so the store's optimistic insert keeps recurrence data.
|
||||
return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent);
|
||||
}
|
||||
|
||||
// Batch-create many events in one CalendarEvent/set. Returns the number created.
|
||||
export async function batchCreateEvents(
|
||||
events: Partial<CalendarEvent>[],
|
||||
calendarId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<number> {
|
||||
if (events.length === 0) return 0;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const create: Record<string, Partial<CalendarEvent>> = {};
|
||||
events.forEach((e, i) => {
|
||||
create[`evt-${i}`] = { ...e, calendarIds: { [calendarId]: true } };
|
||||
const payload: Record<string, unknown> = { ...e, calendarIds: { [calendarId]: true } };
|
||||
cleanRecurrenceRules(payload);
|
||||
create[`evt-${i}`] = payload as Partial<CalendarEvent>;
|
||||
});
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', { accountId, create }, '0']],
|
||||
@@ -136,10 +300,13 @@ export async function updateEvent(
|
||||
id: string,
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const patch: Record<string, unknown> = { ...changes };
|
||||
cleanRecurrenceRules(patch);
|
||||
const res = await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: changes } }, sendSchedulingMessages), '0']],
|
||||
[['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
||||
@@ -150,8 +317,9 @@ export async function updateEvent(
|
||||
export async function deleteEvents(
|
||||
ids: string[],
|
||||
sendSchedulingMessages?: boolean,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']],
|
||||
USING,
|
||||
@@ -165,8 +333,9 @@ export async function rsvpEvent(
|
||||
participantId: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
replyTo?: Record<string, string> | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
// Escape per RFC 6901: ~ → ~0, / → ~1.
|
||||
const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patch: Record<string, unknown> = {
|
||||
@@ -198,7 +367,8 @@ export async function parseCalendarBlob(blobId: string): Promise<Partial<Calenda
|
||||
if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found');
|
||||
const parsed = result.parsed?.[blobId];
|
||||
if (!parsed) return [];
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return list.map(normalizeRecurrenceProperties);
|
||||
}
|
||||
|
||||
export async function createCalendar(
|
||||
@@ -225,6 +395,23 @@ export async function createCalendar(
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a calendar as the account default. `isDefault` is read-only in
|
||||
* Stalwart's Calendar/set — the default is changed via the
|
||||
* `onSuccessSetIsDefault` request argument instead.
|
||||
*/
|
||||
export async function setDefaultCalendar(
|
||||
calendarId: string,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']],
|
||||
USING,
|
||||
);
|
||||
methodResult(res);
|
||||
}
|
||||
|
||||
// Destroy a calendar. `onDestroyEvents: 'destroy'` removes its events too —
|
||||
// Stalwart otherwise refuses to delete a non-empty calendar.
|
||||
export async function deleteCalendar(id: string): Promise<void> {
|
||||
|
||||
+196
-43
@@ -14,33 +14,112 @@ const EMAIL_FULL_PROPERTIES = [
|
||||
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
|
||||
];
|
||||
|
||||
export async function getMailboxes(): Promise<Mailbox[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId }, '0']],
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
// Prefix a shared account's folder ids so they can't collide with the user's
|
||||
// own. Matches the webmail's `${accountId}:${mailboxId}` scheme.
|
||||
function sharedMailboxId(accountId: string, rawId: string): string {
|
||||
return `${accountId}:${rawId}`;
|
||||
}
|
||||
|
||||
/** Inverse of {@link sharedMailboxId}; a no-op for the user's own folders. */
|
||||
export function unprefixMailboxId(id: string, accountId?: string): string {
|
||||
if (!accountId) return id;
|
||||
const prefix = `${accountId}:`;
|
||||
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
||||
}
|
||||
|
||||
// Stamp ownership onto a raw Mailbox/get result. Own folders keep their raw
|
||||
// ids; a shared account's are prefixed and remember their `originalId` so
|
||||
// server calls can still address them.
|
||||
function tagMailbox(
|
||||
mailbox: Mailbox,
|
||||
accountId: string,
|
||||
accountName: string | undefined,
|
||||
isShared: boolean,
|
||||
): Mailbox {
|
||||
if (!isShared) {
|
||||
return { ...mailbox, accountId, accountName, isShared: false };
|
||||
}
|
||||
return {
|
||||
...mailbox,
|
||||
id: sharedMailboxId(accountId, mailbox.id),
|
||||
originalId: mailbox.id,
|
||||
parentId: mailbox.parentId ? sharedMailboxId(accountId, mailbox.parentId) : mailbox.parentId,
|
||||
accountId,
|
||||
accountName,
|
||||
isShared: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMailboxes(accountId?: string): Promise<Mailbox[]> {
|
||||
return (await getMailboxesWithState(accountId)).list;
|
||||
}
|
||||
|
||||
// Variant that also returns the JMAP `state` token so callers can later issue
|
||||
// Mailbox/changes(sinceState=…) to fetch only what changed.
|
||||
export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
export async function getMailboxesWithState(
|
||||
accountIdOverride?: string,
|
||||
): Promise<{ list: Mailbox[]; state: string }> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const isShared = accountId !== jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
|
||||
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
|
||||
);
|
||||
return { list, state: body.state as string };
|
||||
}
|
||||
|
||||
export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> {
|
||||
/**
|
||||
* Every folder of every shared/group account in this session, ready to merge
|
||||
* into the sidebar next to the user's own. One request carrying a Mailbox/get
|
||||
* per account, chunked to the server's maxCallsInRequest. An account that
|
||||
* errors (revoked access, server hiccup) is skipped rather than sinking the
|
||||
* whole list.
|
||||
*/
|
||||
export async function getSharedMailboxes(): Promise<Mailbox[]> {
|
||||
const accounts = jmapClient.getSharedMailAccounts();
|
||||
if (accounts.length === 0) return [];
|
||||
|
||||
const perRequest = Math.max(1, jmapClient.getMaxCallsInRequest());
|
||||
const out: Mailbox[] = [];
|
||||
for (let i = 0; i < accounts.length; i += perRequest) {
|
||||
const chunk = accounts.slice(i, i + perRequest);
|
||||
const res = await jmapClient.request(
|
||||
chunk.map((acc, idx): JMAPMethodCall => [
|
||||
'Mailbox/get',
|
||||
{ accountId: acc.id },
|
||||
String(idx),
|
||||
]),
|
||||
);
|
||||
for (const [name, body, callId] of res.methodResponses) {
|
||||
if (name !== 'Mailbox/get') continue;
|
||||
const acc = chunk[Number(callId)];
|
||||
if (!acc) continue;
|
||||
for (const m of (body.list as Mailbox[]) ?? []) {
|
||||
out.push(tagMailbox(m, acc.id, acc.name, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getMailboxesByIds(
|
||||
ids: string[],
|
||||
accountIdOverride?: string,
|
||||
): Promise<{ list: Mailbox[]; state: string }> {
|
||||
if (ids.length === 0) return { list: [], state: '' };
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const isShared = accountId !== jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/get', { accountId, ids }, '0']],
|
||||
);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: body.list as Mailbox[], state: body.state as string };
|
||||
const list = ((body.list as Mailbox[]) ?? []).map((m) =>
|
||||
tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared),
|
||||
);
|
||||
return { list, state: body.state as string };
|
||||
}
|
||||
|
||||
export interface MailboxChangesResult {
|
||||
@@ -54,8 +133,11 @@ export interface MailboxChangesResult {
|
||||
|
||||
// Returns null when the server can't compute the diff (typically
|
||||
// `cannotCalculateChanges`); callers should fall back to a full Mailbox/get.
|
||||
export async function getMailboxChanges(sinceState: string): Promise<MailboxChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
export async function getMailboxChanges(
|
||||
sinceState: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<MailboxChangesResult | null> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Mailbox/changes', { accountId, sinceState }, '0']],
|
||||
);
|
||||
@@ -73,8 +155,9 @@ export async function getMailboxChanges(sinceState: string): Promise<MailboxChan
|
||||
|
||||
export async function createMailbox(
|
||||
data: { name: string; parentId?: string | null },
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const cid = 'new-mailbox';
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/set', {
|
||||
@@ -102,8 +185,9 @@ export async function createMailbox(
|
||||
export async function updateMailbox(
|
||||
id: string,
|
||||
changes: { name?: string; parentId?: string | null },
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
|
||||
]);
|
||||
@@ -117,8 +201,8 @@ export async function updateMailbox(
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMailbox(id: string): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
export async function deleteMailbox(id: string, accountIdOverride?: string): Promise<void> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Mailbox/set', { accountId, destroy: [id] }, '0'],
|
||||
]);
|
||||
@@ -155,9 +239,11 @@ export async function queryEmails(
|
||||
limit?: number;
|
||||
sort?: Array<{ property: string; isAscending: boolean }>;
|
||||
filter?: Record<string, unknown>;
|
||||
/** Owning JMAP account when the mailbox belongs to a shared account. */
|
||||
accountId?: string;
|
||||
},
|
||||
): Promise<{ ids: string[]; total: number; queryState?: string }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = options?.accountId ?? jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
@@ -196,9 +282,11 @@ export async function getEmailQueryChanges(
|
||||
filter?: Record<string, unknown>;
|
||||
upToId?: string;
|
||||
maxChanges?: number;
|
||||
/** Owning JMAP account when the mailbox belongs to a shared account. */
|
||||
accountId?: string;
|
||||
},
|
||||
): Promise<EmailQueryChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = options?.accountId ?? jmapClient.accountId;
|
||||
const filter = buildMailboxQueryFilter(mailboxId, options?.filter);
|
||||
const args: Record<string, unknown> = {
|
||||
accountId,
|
||||
@@ -222,8 +310,8 @@ export async function getEmailQueryChanges(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEmails(ids: string[]): Promise<Email[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
export async function getEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
@@ -233,18 +321,21 @@ export async function getEmails(ids: string[]): Promise<Email[]> {
|
||||
// Returns the Email/get response with the JMAP `state` token. Used by the
|
||||
// store so we can later issue Email/changes(sinceState=…) for incremental
|
||||
// updates instead of re-fetching the full list.
|
||||
export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> {
|
||||
export async function getEmailsWithState(
|
||||
ids: string[],
|
||||
accountIdOverride?: string,
|
||||
): Promise<{ list: Email[]; state: string }> {
|
||||
if (ids.length === 0) {
|
||||
// Email/get with an empty id list still returns a state token; useful for
|
||||
// priming the store after an empty mailbox query.
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
const body = res.methodResponses[0][1];
|
||||
return { list: [], state: body.state as string };
|
||||
}
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'],
|
||||
]);
|
||||
@@ -266,8 +357,9 @@ export interface EmailChangesResult {
|
||||
export async function getEmailChanges(
|
||||
sinceState: string,
|
||||
maxChanges?: number,
|
||||
accountIdOverride?: string,
|
||||
): Promise<EmailChangesResult | null> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const args: Record<string, unknown> = { accountId, sinceState };
|
||||
if (maxChanges) args.maxChanges = maxChanges;
|
||||
const res = await jmapClient.request([['Email/changes', args, '0']]);
|
||||
@@ -283,8 +375,10 @@ export async function getEmailChanges(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFullEmail(id: string): Promise<Email> {
|
||||
const accountId = jmapClient.accountId;
|
||||
export async function getFullEmail(id: string, accountIdOverride?: string): Promise<Email> {
|
||||
// `accountIdOverride` lets the unified inbox open a message that lives under
|
||||
// a group/shared account in the same session instead of the user's own.
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
@@ -304,9 +398,9 @@ export async function getFullEmail(id: string): Promise<Email> {
|
||||
// Batch variant for offline sync. JMAP servers cap how many objects can be
|
||||
// returned in a single Email/get; the caller should chunk to that ceiling
|
||||
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
|
||||
export async function getFullEmails(ids: string[]): Promise<Email[]> {
|
||||
export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
@@ -321,8 +415,38 @@ export async function getFullEmails(ids: string[]): Promise<Email[]> {
|
||||
return res.methodResponses[0][1].list as Email[];
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string): Promise<Thread> {
|
||||
const accountId = jmapClient.accountId;
|
||||
/**
|
||||
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
|
||||
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's
|
||||
* `importRawEmail` import step.
|
||||
*/
|
||||
export async function importEmailBlob(
|
||||
blobId: string,
|
||||
mailboxId: string,
|
||||
keywords: Record<string, boolean> = { $seen: true },
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/import', {
|
||||
accountId,
|
||||
emails: {
|
||||
'import-0': { blobId, mailboxIds: { [mailboxId]: true }, keywords },
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const result = res.methodResponses[0][1];
|
||||
const notCreated = result?.notCreated?.['import-0'];
|
||||
if (notCreated) {
|
||||
throw new Error(notCreated.description || notCreated.type || 'Failed to import email');
|
||||
}
|
||||
const id = result?.created?.['import-0']?.id;
|
||||
if (!id) throw new Error('Email import succeeded but no id was returned');
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string, accountIdOverride?: string): Promise<Thread> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['Thread/get', { accountId, ids: [threadId] }, '0']],
|
||||
);
|
||||
@@ -332,8 +456,9 @@ export async function getThread(threadId: string): Promise<Thread> {
|
||||
export async function setEmailKeywords(
|
||||
emailId: string,
|
||||
keywords: Record<string, boolean>,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, update: { [emailId]: { keywords } } }, '0'],
|
||||
]);
|
||||
@@ -343,8 +468,9 @@ export async function moveEmail(
|
||||
emailId: string,
|
||||
fromMailboxId: string,
|
||||
toMailboxId: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
await jmapClient.request([
|
||||
['Email/set', {
|
||||
accountId,
|
||||
@@ -363,9 +489,10 @@ export async function moveEmails(
|
||||
ids: string[],
|
||||
fromMailboxId: string,
|
||||
toMailboxId: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const id of ids) {
|
||||
update[id] = {
|
||||
@@ -381,22 +508,24 @@ export async function deleteEmails(
|
||||
ids: string[],
|
||||
trashMailboxId: string,
|
||||
currentMailboxId: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
if (currentMailboxId === trashMailboxId) {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
} else {
|
||||
await moveEmails(ids, currentMailboxId, trashMailboxId);
|
||||
await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply keyword maps to several emails in one round-trip.
|
||||
export async function setKeywordsForEmails(
|
||||
updates: Array<{ id: string; keywords: Record<string, boolean> }>,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const update: Record<string, { keywords: Record<string, boolean> }> = {};
|
||||
for (const u of updates) update[u.id] = { keywords: u.keywords };
|
||||
await jmapClient.request([['Email/set', { accountId, update }, '0']]);
|
||||
@@ -407,9 +536,10 @@ export async function setKeywordsForEmails(
|
||||
// the entire map, so we don't need to compute a diff against the current state.
|
||||
export async function restoreEmailMailboxes(
|
||||
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||||
for (const item of items) {
|
||||
const onlyTrue: Record<string, true> = {};
|
||||
@@ -423,6 +553,26 @@ export async function restoreEmailMailboxes(
|
||||
]);
|
||||
}
|
||||
|
||||
// Replace one email's full mailboxIds map. JMAP "mailboxIds" assigns the whole
|
||||
// set, so this is idempotent — replaying it produces the same result no matter
|
||||
// the current server state. The offline outbox relies on that to coalesce and
|
||||
// safely retry move/archive/trash operations.
|
||||
export async function setEmailMailboxes(
|
||||
emailId: string,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
await restoreEmailMailboxes([{ id: emailId, mailboxIds }], accountIdOverride);
|
||||
}
|
||||
|
||||
// Permanently destroy emails (no move-to-trash). Idempotent: destroying an
|
||||
// already-gone id is a no-op on replay.
|
||||
export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
}
|
||||
|
||||
// Archive one or more emails into the archive mailbox, optionally auto-sorting
|
||||
// into year or year/month subfolders. Mirrors the webmail implementation in
|
||||
// lib/jmap/client.ts so behavior stays in sync between platforms.
|
||||
@@ -431,9 +581,10 @@ export async function archiveEmails(
|
||||
archiveMailboxId: string,
|
||||
mode: 'single' | 'year' | 'month',
|
||||
existingMailboxes: Mailbox[],
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
if (emails.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
|
||||
if (mode === 'single') {
|
||||
const updates = Object.fromEntries(
|
||||
@@ -544,14 +695,15 @@ export async function deleteEmail(
|
||||
emailId: string,
|
||||
trashMailboxId: string,
|
||||
currentMailboxId: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
if (currentMailboxId === trashMailboxId) {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
await jmapClient.request([
|
||||
['Email/set', { accountId, destroy: [emailId] }, '0'],
|
||||
]);
|
||||
} else {
|
||||
await moveEmail(emailId, currentMailboxId, trashMailboxId);
|
||||
await moveEmail(emailId, currentMailboxId, trashMailboxId, accountIdOverride);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,8 +711,9 @@ export async function searchEmails(
|
||||
query: string,
|
||||
mailboxId?: string,
|
||||
limit = 30,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
|
||||
if (mailboxId) filter.inMailbox = mailboxId;
|
||||
|
||||
|
||||
+220
-131
@@ -1,188 +1,277 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
|
||||
import type { FileNode, FileNodeRights, Principal } from './types';
|
||||
import { getDownloadUrl, uploadBlob } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
const t = node.type;
|
||||
if (!t) return false;
|
||||
return DIRECTORY_TYPES.has(t) || t.includes('directory');
|
||||
// A FileNode is a folder (container) only when it has no blob content — the
|
||||
// server stores it with `file == null`. Sending a blobId/type/size on create
|
||||
// (as older builds did: type 'd' + an empty blob) makes a 0-byte FILE that
|
||||
// nothing can ever be parented under (#379). The webmail migrates those
|
||||
// legacy flat-named nodes into the real parentId hierarchy on load; this app
|
||||
// reads and writes only the real hierarchy.
|
||||
export function isFolder(node: Pick<FileNode, 'blobId'>): boolean {
|
||||
return node.blobId == null;
|
||||
}
|
||||
|
||||
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
|
||||
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
|
||||
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
|
||||
// e.g. a file inside the `Documents` folder is stored with
|
||||
// `name = "Documents∕report.pdf"`. The display name shown to the user
|
||||
// strips the prefix.
|
||||
export const PATH_SEP = '∕';
|
||||
// Stalwart omits shareWith/myRights from FileNode/get unless they are
|
||||
// requested explicitly, so the share sheet and indicators must name them here.
|
||||
const FILE_NODE_PROPERTIES = [
|
||||
'id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated',
|
||||
'shareWith', 'myRights',
|
||||
];
|
||||
|
||||
// Path is a list of folder display names from root. [] is root,
|
||||
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
|
||||
// `sub` folder under Documents.
|
||||
export function pathToPrefix(path: readonly string[]): string {
|
||||
if (path.length === 0) return '';
|
||||
return path.join(PATH_SEP) + PATH_SEP;
|
||||
export function supportsSharing(): boolean {
|
||||
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS);
|
||||
}
|
||||
|
||||
// True when `node` is a direct child (not a descendant) of `prefix`.
|
||||
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
|
||||
if (!node.name) return false;
|
||||
if (prefix === '') return !node.name.includes(PATH_SEP);
|
||||
if (!node.name.startsWith(prefix)) return false;
|
||||
const rest = node.name.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes(PATH_SEP);
|
||||
function filesAccountId(): string {
|
||||
return (
|
||||
jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.FILES] ??
|
||||
jmapClient.accountId
|
||||
);
|
||||
}
|
||||
|
||||
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
|
||||
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
|
||||
function fileUsing(): string[] {
|
||||
const using: string[] = [CAPABILITIES.CORE];
|
||||
if (jmapClient.hasCapability(CAPABILITIES.FILES)) {
|
||||
using.push(CAPABILITIES.FILES);
|
||||
}
|
||||
// Required for shareWith/myRights on FileNode and for cross-account
|
||||
// (shared-with-me) FileNode/get.
|
||||
if (supportsSharing()) {
|
||||
using.push(CAPABILITIES.PRINCIPALS_OWNER);
|
||||
}
|
||||
return using;
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter and some builds
|
||||
// omit `type` from the default property set. We fetch the whole tree and
|
||||
// filter client-side; properties are listed explicitly so `isDirectory()`
|
||||
// always has a value to look at.
|
||||
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
|
||||
// Nodes fetched from another principal's account are namespaced
|
||||
// "accountId:nodeId" so they can't collide with the primary account's ids.
|
||||
// JMAP ids never contain ':', so the separator unambiguously marks a shared
|
||||
// (cross-account) node.
|
||||
export function isCrossAccountId(id: string | null | undefined): boolean {
|
||||
return id != null && id.includes(':');
|
||||
}
|
||||
|
||||
// Fetch every FileNode in the account, files AND folders, to build the
|
||||
// hierarchy client-side from parentId links.
|
||||
//
|
||||
// IMPORTANT: this uses FileNode/get with ids:null (return-all), NOT
|
||||
// FileNode/query — Stalwart's query only returns leaf files and omits folder
|
||||
// nodes entirely, which would make every folder invisible.
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
USING,
|
||||
fileUsing(),
|
||||
);
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
const result = res.methodResponses[0];
|
||||
if (!result || result[0] === 'error') {
|
||||
throw new Error(result?.[1]?.description || 'FileNode list failed');
|
||||
}
|
||||
return (result[1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
fileNode: Partial<FileNode>,
|
||||
): Promise<FileNode> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', {
|
||||
accountId,
|
||||
create: { 'new-file': fileNode },
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
// Accounts (primary + shared/group) that can hold FileNodes: any non-primary
|
||||
// account that advertises the filenode capability or is non-personal, since
|
||||
// Stalwart doesn't always advertise capabilities on shared accounts.
|
||||
function filesCapableAccountIds(): string[] {
|
||||
const primaryId = filesAccountId();
|
||||
const accounts = jmapClient.currentSession?.accounts ?? {};
|
||||
const rest = Object.entries(accounts)
|
||||
.filter(([id, info]) =>
|
||||
id !== primaryId &&
|
||||
(info.accountCapabilities?.[CAPABILITIES.FILES] != null || !info.isPersonal))
|
||||
.map(([id]) => id);
|
||||
return [primaryId, ...rest];
|
||||
}
|
||||
|
||||
// Fetch every FileNode the logged-in user can see across all accessible
|
||||
// accounts. Nodes owned by another principal (shared with the user) are
|
||||
// tagged `isShared: true` with the owning accountId/accountName, and their
|
||||
// ids/parentIds are namespaced "accountId:nodeId". Mirrors the webmail's
|
||||
// listAllFileNodesAcrossAccounts.
|
||||
export async function getAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||||
const primaryId = filesAccountId();
|
||||
const accounts = jmapClient.currentSession?.accounts ?? {};
|
||||
const all: FileNode[] = [];
|
||||
|
||||
for (const accountId of filesCapableAccountIds()) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
try {
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0];
|
||||
if (!result || result[0] === 'error') continue;
|
||||
for (const node of (result[1].list ?? []) as FileNode[]) {
|
||||
all.push({
|
||||
...node,
|
||||
id: isPrimary ? node.id : `${accountId}:${node.id}`,
|
||||
parentId: node.parentId == null
|
||||
? null
|
||||
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
|
||||
accountId,
|
||||
accountName: accounts[accountId]?.name || accountId,
|
||||
isShared: !isPrimary,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// A single unreachable shared account shouldn't hide the user's own
|
||||
// files; skip it and keep aggregating.
|
||||
}
|
||||
}
|
||||
return created;
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
displayName: string,
|
||||
currentPath: readonly string[],
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
// Stalwart's FileNode/set rejects directory creates without a blobId
|
||||
// ("Missing blob id"), so we upload an empty blob first and reference it.
|
||||
// Mirrors the webmail's createFileDirectory.
|
||||
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
|
||||
const accountId = filesAccountId();
|
||||
const props: Record<string, unknown> = { name };
|
||||
if (parentId !== null) props.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, create: { 'new-dir': props } }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
const created = result.created?.['new-dir'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-dir'];
|
||||
throw new Error(err?.description || 'Create folder failed');
|
||||
}
|
||||
return { ...props, ...created } as FileNode;
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
fileUsing(),
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
// Rename a node and, if it's a folder, cascade the rename to every descendant
|
||||
// so the encoded path stays consistent. The webmail does this same dance.
|
||||
export async function renameFileNode(
|
||||
node: FileNode,
|
||||
newDisplayName: string,
|
||||
currentPath: readonly string[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const prefix = pathToPrefix(currentPath);
|
||||
const oldServerName = node.name;
|
||||
const newServerName = prefix + newDisplayName;
|
||||
if (oldServerName === newServerName) return;
|
||||
|
||||
await updateFileNode(node.id, { name: newServerName });
|
||||
|
||||
if (isDirectory(node)) {
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id) continue;
|
||||
if (n.name.startsWith(oldFolderPrefix)) {
|
||||
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function renameFileNode(id: string, newName: string): Promise<void> {
|
||||
await updateFileNode(id, { name: newName });
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', {
|
||||
accountId,
|
||||
destroy: ids,
|
||||
// The server removes descendant nodes of destroyed folders.
|
||||
onDestroyRemoveChildren: true,
|
||||
}, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
|
||||
// references, so we collect descendants by name-prefix and destroy them all
|
||||
// in one call. Without this, deleting a folder leaves its files orphaned and
|
||||
// still discoverable at the old encoded path.
|
||||
export async function deleteWithDescendants(
|
||||
targets: readonly FileNode[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targets) {
|
||||
ids.add(node.id);
|
||||
if (isDirectory(node)) {
|
||||
const folderPrefix = node.name + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
|
||||
}
|
||||
}
|
||||
const notDestroyed = res.methodResponses[0][1].notDestroyed as
|
||||
| Record<string, { description?: string }>
|
||||
| undefined;
|
||||
const failedIds = Object.keys(notDestroyed ?? {});
|
||||
if (failedIds.length > 0) {
|
||||
throw new Error(
|
||||
notDestroyed![failedIds[0]]?.description || `Failed to delete ${failedIds.length} item(s)`,
|
||||
);
|
||||
}
|
||||
await deleteFileNodes(Array.from(ids));
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
return getDownloadUrl(node.blobId, node.name, node.type, node.accountId);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
displayName: string,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
currentPath: readonly string[],
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
const accountId = filesAccountId();
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({
|
||||
name: fullName,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
const type = blob.type || mimeType || 'application/octet-stream';
|
||||
const props: Record<string, unknown> = {
|
||||
name,
|
||||
// Stalwart caps the stored MIME type; very long types fail the create.
|
||||
type: type.length > 30 ? 'application/octet-stream' : type,
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
};
|
||||
if (parentId !== null) props.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, create: { 'new-file': props } }, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
const created = result.created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = result.notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Upload failed');
|
||||
}
|
||||
return { ...props, ...created } as FileNode;
|
||||
}
|
||||
|
||||
// ── Sharing (RFC 9670) ────────────────────────────────────
|
||||
|
||||
// Add, update, or remove a principal's rights on an owned FileNode (file or
|
||||
// folder). Pass rights: null to revoke. Stalwart applies it via a
|
||||
// `shareWith/{principalId}` patch on FileNode/set. Sharing a folder shares
|
||||
// its whole subtree — which is why this app must store real parentId
|
||||
// hierarchy rather than the legacy flat-name encoding.
|
||||
export async function setFileNodeShare(
|
||||
fileNodeId: string,
|
||||
principalId: string,
|
||||
rights: FileNodeRights | null,
|
||||
): Promise<void> {
|
||||
const accountId = filesAccountId();
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', {
|
||||
accountId,
|
||||
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, '0']],
|
||||
fileUsing(),
|
||||
);
|
||||
const result = res.methodResponses[0][1];
|
||||
if (result.notUpdated?.[fileNodeId]) {
|
||||
throw new Error(result.notUpdated[fileNodeId].description || 'Failed to update file share');
|
||||
}
|
||||
if (!result.updated || !(fileNodeId in result.updated)) {
|
||||
throw new Error('Server did not confirm the share update');
|
||||
}
|
||||
}
|
||||
|
||||
// List all principals visible to the user. Stalwart returns the full
|
||||
// directory regardless of `filter`, so callers filter client-side.
|
||||
export async function getPrincipals(): Promise<Principal[]> {
|
||||
if (!supportsSharing()) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['Principal/query', { accountId }, '0'],
|
||||
['Principal/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' },
|
||||
}, '1'],
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS],
|
||||
);
|
||||
const getResp = res.methodResponses.find((r) => r[0] === 'Principal/get');
|
||||
return (getResp?.[1].list ?? []) as Principal[];
|
||||
}
|
||||
|
||||
// The principal id that represents the logged-in user (excluded from the
|
||||
// share picker — sharing with yourself is a no-op the server may reject).
|
||||
export function ownPrincipalId(): string {
|
||||
return filesAccountId();
|
||||
}
|
||||
|
||||
+56
-4
@@ -60,6 +60,13 @@ export class JMAPClient {
|
||||
return this.credentials?.serverUrl ?? null;
|
||||
}
|
||||
|
||||
// True when the session authenticates with a Bearer token (OAuth handoff or
|
||||
// token login) rather than a username/password. Used by the security screen
|
||||
// to hide password/TOTP management, which only applies to password accounts.
|
||||
get usesBearerAuth(): boolean {
|
||||
return !!this.credentials?.accessToken;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.session !== null && this._accountId !== null;
|
||||
}
|
||||
@@ -144,6 +151,7 @@ export class JMAPClient {
|
||||
|
||||
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
|
||||
if (!this.credentials) return;
|
||||
if (this.credentials.accessToken === next.accessToken) return;
|
||||
this.credentials = {
|
||||
...this.credentials,
|
||||
accessToken: next.accessToken,
|
||||
@@ -451,6 +459,43 @@ export class JMAPClient {
|
||||
return core?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
getMaxCallsInRequest(): number {
|
||||
const core = this.session?.capabilities?.[CAPABILITIES.CORE] as
|
||||
| { maxCallsInRequest?: number }
|
||||
| undefined;
|
||||
return core?.maxCallsInRequest || 16;
|
||||
}
|
||||
|
||||
// ── Shared / group accounts ───────────────────────────
|
||||
// A JMAP session lists every account the credentials can reach: the user's
|
||||
// own, plus any shared/group accounts (Stalwart "group accounts") they are a
|
||||
// member of. Non-personal accounts are the shared mailboxes.
|
||||
|
||||
/**
|
||||
* Mail-capable accounts other than the primary one. Stalwart doesn't always
|
||||
* populate `accountCapabilities` on shared accounts, so an account counts as
|
||||
* mail-capable when it advertises the mail capability OR is non-personal —
|
||||
* the same rule the webmail's getSharedAccounts() applies.
|
||||
*/
|
||||
getSharedMailAccounts(): { id: string; name: string }[] {
|
||||
if (!this.session || !this._accountId) return [];
|
||||
const primaryId = this._accountId;
|
||||
const out: { id: string; name: string }[] = [];
|
||||
for (const [id, info] of Object.entries(this.session.accounts ?? {})) {
|
||||
if (id === primaryId) continue;
|
||||
const advertisesMail = info.accountCapabilities
|
||||
? CAPABILITIES.MAIL in info.accountCapabilities
|
||||
: false;
|
||||
if (!advertisesMail && info.isPersonal) continue;
|
||||
out.push({ id, name: info.name || id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
getAccountName(accountId: string): string | undefined {
|
||||
return this.session?.accounts?.[accountId]?.name;
|
||||
}
|
||||
|
||||
// ── Scheduled send (FUTURERELEASE) ────────────────────
|
||||
// The JMAP submission capability advertises `maxDelayedSend` (max hold in
|
||||
// seconds) and a `submissionExtensions` map; FUTURERELEASE support is what
|
||||
@@ -507,19 +552,26 @@ export class JMAPClient {
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
|
||||
// Expand the RFC 6570 level-1 template the JMAP server advertises.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
||||
// `accountId` targets a shared/group account, whose blobs aren't reachable
|
||||
// through the user's own account id.
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
|
||||
if (!this.session?.downloadUrl) {
|
||||
throw new Error('Download URL not available - not connected');
|
||||
}
|
||||
return this.session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(this.accountId))
|
||||
.replace('{accountId}', encodeURIComponent(accountId ?? this.accountId))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent(name || 'download'))
|
||||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||||
}
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
async fetchBlobArrayBuffer(
|
||||
blobId: string,
|
||||
name?: string,
|
||||
type?: string,
|
||||
accountId?: string,
|
||||
): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||
await this.ensureFreshToken();
|
||||
const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } });
|
||||
let response = await doFetch();
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import type { SieveScript, SieveCapabilities } from '../lib/sieve/types';
|
||||
|
||||
// JMAP Sieve (RFC 9661) bindings. Mirrors the webmail JMAPClient Sieve methods
|
||||
// but follows the mobile convention of a functional api/* module driving the
|
||||
// shared `jmapClient` singleton (see api/vacation.ts, api/blob.ts).
|
||||
|
||||
const SIEVE_USING = [CAPABILITIES.CORE, CAPABILITIES.SIEVE];
|
||||
|
||||
function requireSession() {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
return session;
|
||||
}
|
||||
|
||||
// Sieve lives on its own JMAP account (RFC 9661). Fall back to the mail account
|
||||
// when the server does not advertise a dedicated one (Stalwart uses the same id).
|
||||
export function getSieveAccountId(): string {
|
||||
const session = jmapClient.currentSession;
|
||||
return session?.primaryAccounts?.[CAPABILITIES.SIEVE] ?? jmapClient.accountId;
|
||||
}
|
||||
|
||||
export function isSieveSupported(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
return CAPABILITIES.SIEVE in (session.capabilities ?? {});
|
||||
}
|
||||
|
||||
export function getSieveCapabilities(): SieveCapabilities | null {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return null;
|
||||
const info = session.accounts?.[getSieveAccountId()];
|
||||
const caps = info?.accountCapabilities?.[CAPABILITIES.SIEVE];
|
||||
return (caps as SieveCapabilities) ?? null;
|
||||
}
|
||||
|
||||
export async function getSieveScripts(): Promise<SieveScript[]> {
|
||||
const res = await jmapClient.request(
|
||||
[['SieveScript/get', { accountId: getSieveAccountId() }, '0']],
|
||||
SIEVE_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/get') {
|
||||
return ((resp[1] as { list?: SieveScript[] }).list ?? []) as SieveScript[];
|
||||
}
|
||||
throw new Error('Failed to fetch Sieve scripts');
|
||||
}
|
||||
|
||||
export async function getSieveScriptContent(blobId: string): Promise<string> {
|
||||
const session = requireSession();
|
||||
const url = session.downloadUrl
|
||||
.replace('{accountId}', encodeURIComponent(getSieveAccountId()))
|
||||
.replace('{blobId}', encodeURIComponent(blobId))
|
||||
.replace('{name}', encodeURIComponent('script.sieve'))
|
||||
.replace('{type}', encodeURIComponent('application/sieve'));
|
||||
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: jmapClient.authHeader },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function uploadSieveBlob(content: string): Promise<string> {
|
||||
const session = requireSession();
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(getSieveAccountId()),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sieve',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
// A plain string body is encoded as UTF-8 by RN's fetch. (api/blob.ts uses
|
||||
// an ArrayBuffer only because typed-array bodies get stringified there.)
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Failed to upload Sieve script: ${response.status}${detail ? ` ${detail.substring(0, 200)}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string };
|
||||
if (typeof direct.blobId === 'string') return direct.blobId;
|
||||
const nested = raw[getSieveAccountId()] as { blobId?: string } | undefined;
|
||||
if (nested?.blobId) return nested.blobId;
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
export async function createSieveScript(
|
||||
name: string,
|
||||
content: string,
|
||||
activate = true,
|
||||
): Promise<SieveScript> {
|
||||
const blobId = await uploadSieveBlob(content);
|
||||
const accountId = getSieveAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
create: { 'new-script': { name, blobId } },
|
||||
};
|
||||
if (activate) setArgs.onSuccessActivateScript = '#new-script';
|
||||
|
||||
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/set') {
|
||||
const result = resp[1] as {
|
||||
notCreated?: Record<string, { description?: string }>;
|
||||
created?: Record<string, { id?: string }>;
|
||||
};
|
||||
if (result.notCreated?.['new-script']) {
|
||||
throw new Error(result.notCreated['new-script'].description ?? 'Failed to create Sieve script');
|
||||
}
|
||||
const createdId = result.created?.['new-script']?.id;
|
||||
if (createdId) {
|
||||
const scripts = await getSieveScripts();
|
||||
const script = scripts.find((s) => s.id === createdId);
|
||||
if (script) return script;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to create Sieve script');
|
||||
}
|
||||
|
||||
export async function updateSieveScript(
|
||||
scriptId: string,
|
||||
content: string,
|
||||
activate = true,
|
||||
): Promise<void> {
|
||||
const blobId = await uploadSieveBlob(content);
|
||||
const accountId = getSieveAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
update: { [scriptId]: { blobId } },
|
||||
};
|
||||
if (activate) setArgs.onSuccessActivateScript = scriptId;
|
||||
|
||||
const res = await jmapClient.request([['SieveScript/set', setArgs, '0']], SIEVE_USING);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/set') {
|
||||
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
|
||||
if (result.notUpdated?.[scriptId]) {
|
||||
throw new Error(result.notUpdated[scriptId].description ?? 'Failed to update Sieve script');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to update Sieve script');
|
||||
}
|
||||
|
||||
export async function deleteSieveScript(scriptId: string): Promise<void> {
|
||||
const res = await jmapClient.request(
|
||||
[['SieveScript/set', { accountId: getSieveAccountId(), destroy: [scriptId] }, '0']],
|
||||
SIEVE_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/set') {
|
||||
const result = resp[1] as { notDestroyed?: Record<string, { description?: string }> };
|
||||
if (result.notDestroyed?.[scriptId]) {
|
||||
throw new Error(result.notDestroyed[scriptId].description ?? 'Failed to delete Sieve script');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to delete Sieve script');
|
||||
}
|
||||
|
||||
export async function validateSieveScript(
|
||||
content: string,
|
||||
): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
const blobId = await uploadSieveBlob(content);
|
||||
const res = await jmapClient.request(
|
||||
[['SieveScript/validate', { accountId: getSieveAccountId(), blobId }, '0']],
|
||||
SIEVE_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'SieveScript/validate') {
|
||||
const result = resp[1] as { error?: { description?: string } };
|
||||
if (result.error) {
|
||||
return { isValid: false, errors: [result.error.description ?? 'Validation failed'] };
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
if (resp && typeof resp[0] === 'string' && resp[0].endsWith('error')) {
|
||||
const error = resp[1] as { description?: string };
|
||||
return { isValid: false, errors: [error.description ?? 'Validation failed'] };
|
||||
}
|
||||
return { isValid: false, errors: ['Unexpected validation response'] };
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export interface JMAPAccountInfo {
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
isReadOnly: boolean;
|
||||
// Per-account capability objects (RFC 8620 §2). Optional because not every
|
||||
// server populates it; the Sieve panel reads its limits/extensions from here.
|
||||
accountCapabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type JMAPMethodCall = [string, Record<string, unknown>, string];
|
||||
@@ -96,7 +99,15 @@ export interface MailboxRights {
|
||||
}
|
||||
|
||||
export interface Mailbox {
|
||||
/**
|
||||
* Unique within the app. For the user's own folders this is the raw JMAP id;
|
||||
* for a shared (group) account's folders it's `<accountId>:<rawId>` so ids
|
||||
* from different accounts can't collide. Use `originalId` when talking to
|
||||
* the server. Mirrors the webmail's scheme in [lib/jmap/client.ts].
|
||||
*/
|
||||
id: string;
|
||||
/** Raw JMAP id — set only on shared folders, where `id` carries a prefix. */
|
||||
originalId?: string;
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
role?: string | null;
|
||||
@@ -107,6 +118,12 @@ export interface Mailbox {
|
||||
unreadThreads: number;
|
||||
myRights: MailboxRights;
|
||||
isSubscribed?: boolean;
|
||||
/** JMAP account the folder lives under (primary or a shared/group account). */
|
||||
accountId?: string;
|
||||
/** Display name of the owning account, used as the sidebar group header. */
|
||||
accountName?: string;
|
||||
/** True when the folder belongs to a shared/group account, not the user. */
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
@@ -402,6 +419,7 @@ export interface VirtualLocation {
|
||||
}
|
||||
|
||||
export interface RecurrenceRule {
|
||||
'@type'?: 'RecurrenceRule';
|
||||
frequency: string;
|
||||
interval?: number;
|
||||
until?: string;
|
||||
@@ -416,6 +434,8 @@ export interface RecurrenceRule {
|
||||
bySecond?: number[];
|
||||
bySetPosition?: number[];
|
||||
firstDayOfWeek?: string;
|
||||
rscale?: string;
|
||||
skip?: string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
@@ -449,6 +469,9 @@ export interface CalendarEvent {
|
||||
excludedRecurrenceRules?: RecurrenceRule[];
|
||||
recurrenceId?: string;
|
||||
originalId?: string;
|
||||
// Client-only: the event's server-side calendarIds (raw, unprefixed), kept
|
||||
// when `calendarIds` is remapped to namespaced store ids for shared events.
|
||||
originalCalendarIds?: Record<string, boolean>;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, Alert>;
|
||||
locations?: Record<string, EventLocation>;
|
||||
@@ -464,6 +487,12 @@ export interface CalendarEvent {
|
||||
color?: string;
|
||||
// Client-only: account the event/occurrence belongs to (for shared/virtual).
|
||||
localAccountId?: string;
|
||||
// Client-only: JMAP account the event was fetched from. Absent for the
|
||||
// primary account; set for events on calendars shared with the user so
|
||||
// mutations can be routed to the owning account.
|
||||
accountId?: string;
|
||||
// Client-only: true when the event lives on a calendar shared with the user.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarRights {
|
||||
@@ -489,6 +518,21 @@ export interface Calendar {
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: CalendarRights;
|
||||
// Client-only: JMAP account the calendar was fetched from. Absent for the
|
||||
// primary account; set for calendars shared with the user.
|
||||
accountId?: string;
|
||||
// Client-only: the real JMAP id of a shared calendar, before its `id` was
|
||||
// namespaced as `${accountId}:${id}` to stay unique in the store. Mutations
|
||||
// and event queries must use this raw id against the owning account.
|
||||
originalId?: string;
|
||||
// Client-only: true when the calendar lives in another account (shared
|
||||
// with the user by its owner).
|
||||
isShared?: boolean;
|
||||
// Client-only: set when `color` has been replaced by the viewer's local
|
||||
// override for a shared calendar (see lib/calendar-utils). When true, the
|
||||
// override wins over per-event colors so the whole shared calendar paints
|
||||
// uniformly.
|
||||
colorIsLocalOverride?: boolean;
|
||||
}
|
||||
|
||||
// ─── Files (FileNode) ───────────────────────────────────
|
||||
@@ -502,6 +546,40 @@ export interface FileNode {
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||
// principals capability and the properties are requested explicitly. A node
|
||||
// is shared-out when `shareWith` has entries; `myRights` describes what the
|
||||
// viewer may do (always full rights on owned nodes).
|
||||
myRights?: FileNodeRights;
|
||||
shareWith?: Record<string, FileNodeRights> | null;
|
||||
// True when this node was fetched from another principal's account that was
|
||||
// shared with the logged-in user ("Shared with me").
|
||||
isShared?: boolean;
|
||||
// Owning account's JMAP id and display name, set when aggregating nodes
|
||||
// across accounts so blob downloads route to the right account.
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
}
|
||||
|
||||
// FileNode rights as defined by Stalwart's JmapSharedObject implementation.
|
||||
export interface FileNodeRights {
|
||||
mayRead: boolean;
|
||||
mayAddChildren: boolean;
|
||||
mayRename: boolean;
|
||||
mayDelete: boolean;
|
||||
mayModifyContent: boolean;
|
||||
mayShare: boolean;
|
||||
}
|
||||
|
||||
// RFC 9670 principal — an entity (user, group, resource) that files and
|
||||
// calendars can be shared with. On Stalwart the principal id doubles as the
|
||||
// account id of that user/group.
|
||||
export interface Principal {
|
||||
id: string;
|
||||
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
// ─── Push / SSE ─────────────────────────────────────────
|
||||
@@ -535,4 +613,6 @@ export const CAPABILITIES = {
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
PRINCIPALS: 'urn:ietf:params:jmap:principals',
|
||||
PRINCIPALS_OWNER: 'urn:ietf:params:jmap:principals:owner',
|
||||
} as const;
|
||||
|
||||
+88
-27
@@ -20,6 +20,17 @@ const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
export interface UnifiedEmail extends Email {
|
||||
/** Registry account id this message belongs to (not the JMAP account id). */
|
||||
sourceAccountId: string;
|
||||
/**
|
||||
* JMAP account id the message lives under. Equals the registry account's
|
||||
* primary JMAP account for own mail, or a group/shared owner account when
|
||||
* the message came from a group inbox. Carried so opening can target the
|
||||
* right account via an Email/get override.
|
||||
*/
|
||||
jmapAccountId: string;
|
||||
/** True when this message belongs to a group/shared inbox, not the user's own. */
|
||||
isShared: boolean;
|
||||
/** Human label for the owning account when shared (e.g. "Support"). */
|
||||
sharedLabel?: string;
|
||||
}
|
||||
|
||||
export interface UnifiedInboxResult {
|
||||
@@ -90,7 +101,10 @@ async function ensureFreshCredentials(
|
||||
tokenEndpoint: next.tokenEndpoint,
|
||||
clientId: next.clientId,
|
||||
};
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
const current = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!current || current.accessToken !== updated.accessToken) {
|
||||
await jmapClient.setStoredCredentials(accountId, updated);
|
||||
}
|
||||
return updated;
|
||||
} catch {
|
||||
// Fall back to the existing (possibly expired) token; the request will
|
||||
@@ -116,29 +130,15 @@ async function jmapPost(
|
||||
return body.methodResponses ?? [];
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
// Fetch the most recent inbox messages for a single JMAP account reachable via
|
||||
// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates
|
||||
// them with registry/sharing metadata.
|
||||
async function fetchInboxEmailsForJmapAccount(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
jmapAccountId: string,
|
||||
limit: number,
|
||||
): Promise<UnifiedEmail[]> {
|
||||
let creds = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!creds) throw new Error('No stored credentials');
|
||||
creds = await ensureFreshCredentials(accountId, creds);
|
||||
|
||||
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
|
||||
const authHeader = authHeaderFor(creds);
|
||||
|
||||
// Session discovery.
|
||||
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader, Accept: 'application/json' },
|
||||
});
|
||||
if (sessionRes.status === 401) throw new Error('Session expired');
|
||||
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
|
||||
const session = (await sessionRes.json()) as JMAPSession;
|
||||
const apiUrl = rewriteApiUrl(session, baseUrl);
|
||||
const jmapAccountId = resolveJmapAccountId(session);
|
||||
if (!jmapAccountId) throw new Error('No mail account in session');
|
||||
|
||||
// Find the inbox mailbox.
|
||||
): Promise<Email[]> {
|
||||
const mailboxResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
@@ -149,7 +149,6 @@ async function fetchInboxForAccount(
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
|
||||
if (!inbox) return [];
|
||||
|
||||
// Query + fetch the most recent messages.
|
||||
const queryResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
@@ -170,19 +169,81 @@ async function fetchInboxForAccount(
|
||||
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const list = (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
return list.map((e) => ({ ...e, sourceAccountId: accountId }));
|
||||
return (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
limit: number,
|
||||
includeGroup: boolean,
|
||||
): Promise<UnifiedEmail[]> {
|
||||
let creds = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!creds) throw new Error('No stored credentials');
|
||||
creds = await ensureFreshCredentials(accountId, creds);
|
||||
|
||||
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
|
||||
const authHeader = authHeaderFor(creds);
|
||||
|
||||
// Session discovery.
|
||||
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader, Accept: 'application/json' },
|
||||
});
|
||||
if (sessionRes.status === 401) throw new Error('Session expired');
|
||||
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
|
||||
const session = (await sessionRes.json()) as JMAPSession;
|
||||
const apiUrl = rewriteApiUrl(session, baseUrl);
|
||||
const primaryJmapId = resolveJmapAccountId(session);
|
||||
if (!primaryJmapId) throw new Error('No mail account in session');
|
||||
|
||||
// Always include the user's own primary inbox. When group inboxes are on,
|
||||
// also scan every other (non-personal / shared) account reachable in the
|
||||
// session — these are the group/shared mailboxes the user belongs to.
|
||||
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
|
||||
{ jmapId: primaryJmapId, isShared: false },
|
||||
];
|
||||
if (includeGroup) {
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryJmapId) continue;
|
||||
// Only accounts that expose the Mail capability can have an inbox.
|
||||
const hasMail =
|
||||
info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {});
|
||||
if (!hasMail) continue;
|
||||
targets.push({ jmapId: id, isShared: true, label: info.name });
|
||||
}
|
||||
}
|
||||
|
||||
const perTarget = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit);
|
||||
return list.map<UnifiedEmail>((e) => ({
|
||||
...e,
|
||||
sourceAccountId: accountId,
|
||||
jmapAccountId: target.jmapId,
|
||||
isShared: target.isShared,
|
||||
sharedLabel: target.isShared ? target.label : undefined,
|
||||
}));
|
||||
} catch {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
// account's inbox; skip it and keep the others.
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return perTarget.flat();
|
||||
}
|
||||
|
||||
export async function fetchUnifiedInbox(
|
||||
accountIds: string[],
|
||||
perAccountLimit = 25,
|
||||
opts: { includeGroup?: boolean } = {},
|
||||
): Promise<UnifiedInboxResult> {
|
||||
const { includeGroup = false } = opts;
|
||||
const errors: Record<string, string> = {};
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
try {
|
||||
return await fetchInboxForAccount(id, perAccountLimit);
|
||||
return await fetchInboxForAccount(id, perAccountLimit, includeGroup);
|
||||
} catch (err) {
|
||||
errors[id] = err instanceof Error ? err.message : 'Failed to load';
|
||||
return [] as UnifiedEmail[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-nati
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||||
import type { Email } from '../api/types';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
@@ -27,6 +27,17 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
interface EmailBodyViewProps {
|
||||
email: Email;
|
||||
senderEmail?: string;
|
||||
/** Owning account when the message lives in a shared/group mailbox. */
|
||||
jmapAccountId?: string;
|
||||
// The body is a native WebView, which on Android swallows horizontal touches
|
||||
// before the surrounding pager can see them. We detect a clear horizontal
|
||||
// swipe inside the page and report it here so the pager can change messages.
|
||||
onSwipe?: (direction: 'prev' | 'next') => void;
|
||||
// Pinch-zoom state from inside the page: `pinching` while two fingers are
|
||||
// down, `zoomed` while the content is scaled in. The hosts use it to freeze
|
||||
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
|
||||
// scroll the pane away or switch mails.
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
}
|
||||
|
||||
function extractHtmlBody(email: Email): string | null {
|
||||
@@ -45,29 +56,188 @@ function extractTextBody(email: Email): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
|
||||
// heart -> cyan) and stylesheet-defined background images, which the static CSS
|
||||
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
|
||||
// those so they keep their original colors. Only injected when inversion is on.
|
||||
const DARK_REINVERT_SCRIPT = `
|
||||
(function () {
|
||||
if (document.__rnDarkInvertDone) return;
|
||||
var doc = document;
|
||||
var win = window;
|
||||
if (!doc.body) return;
|
||||
document.__rnDarkInvertDone = true;
|
||||
|
||||
// Re-invert elements with stylesheet-defined background images (CSS attribute
|
||||
// selectors only catch inline styles, not rules that survive in <style>).
|
||||
doc.body.querySelectorAll('*').forEach(function (el) {
|
||||
// Skip elements already handled by CSS attribute selectors.
|
||||
if (el.style.backgroundImage || el.style.background ||
|
||||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
|
||||
// Skip leaf media elements (already re-inverted by CSS).
|
||||
var tag = el.tagName;
|
||||
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
|
||||
var computed = win.getComputedStyle(el);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Only re-invert if this container doesn't have media children.
|
||||
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
|
||||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
|
||||
// run in a span that re-inverts. Only act when the ancestor invert depth is
|
||||
// odd - emojis inside a double-inverted bgcolor container already render at
|
||||
// their original colors.
|
||||
var emojiRe;
|
||||
try {
|
||||
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
|
||||
} catch (e) {
|
||||
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
|
||||
}
|
||||
var emojiTestRe = /\\p{Extended_Pictographic}/u;
|
||||
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
|
||||
|
||||
function isOddInvertDepth(start) {
|
||||
var count = 0;
|
||||
var n = start;
|
||||
while (n) {
|
||||
if (n === doc.body) { count++; break; }
|
||||
var cs = win.getComputedStyle(n);
|
||||
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
var p = node.parentElement;
|
||||
while (p) {
|
||||
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return emojiTestRe.test(node.nodeValue || '')
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
|
||||
var emojiTextNodes = [];
|
||||
var cur;
|
||||
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
|
||||
|
||||
emojiTextNodes.forEach(function (textNode) {
|
||||
var parent = textNode.parentElement;
|
||||
if (!parent || !isOddInvertDepth(parent)) return;
|
||||
var text = textNode.nodeValue || '';
|
||||
emojiRe.lastIndex = 0;
|
||||
var frag = doc.createDocumentFragment();
|
||||
var lastIndex = 0;
|
||||
var m;
|
||||
while ((m = emojiRe.exec(text)) !== null) {
|
||||
if (m.index > lastIndex) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
var span = doc.createElement('span');
|
||||
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
|
||||
span.textContent = m[0];
|
||||
frag.appendChild(span);
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
if (lastIndex === 0) return;
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
parent.replaceChild(frag, textNode);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const HEIGHT_REPORTER = `
|
||||
(function () {
|
||||
// One-time: wrap body contents in a block-level div we can measure reliably.
|
||||
// body.scrollHeight on Android WebView is pinned to the container viewport
|
||||
// height, so we can't trust it. A dedicated wrapper gives a deterministic
|
||||
// bounding box.
|
||||
var wrapperEl = null;
|
||||
function ensureWrapper() {
|
||||
var b = document.body;
|
||||
if (!b) return null;
|
||||
if (wrapperEl && wrapperEl.isConnected) return wrapperEl;
|
||||
var wrap = document.getElementById('__rn_email_body_wrap__');
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.id = '__rn_email_body_wrap__';
|
||||
wrap.style.display = 'block';
|
||||
wrap.style.width = '100%';
|
||||
wrap.style.transformOrigin = 'top left';
|
||||
while (b.firstChild) wrap.appendChild(b.firstChild);
|
||||
b.appendChild(wrap);
|
||||
}
|
||||
wrapperEl = wrap;
|
||||
return wrap;
|
||||
}
|
||||
// The width available for the email content (body content-box).
|
||||
function availWidth() {
|
||||
var b = document.body;
|
||||
var cs = window.getComputedStyle(b);
|
||||
var padL = parseFloat(cs.paddingLeft) || 0;
|
||||
var padR = parseFloat(cs.paddingRight) || 0;
|
||||
return b.clientWidth - padL - padR;
|
||||
}
|
||||
// Shared zoom state, mutated by the pinch-zoom handler injected after this
|
||||
// script. "fit" is the shrink-to-fit scale, "scale" the user's pinch factor
|
||||
// on top of it, "tx" pans the zoomed overflow into view horizontally.
|
||||
// Vertical panning needs no state here: the height reports below carry the
|
||||
// scaled height, so the surrounding native ScrollView grows and scrolls.
|
||||
var Z = window.__rnZoom = window.__rnZoom || {
|
||||
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
|
||||
};
|
||||
// Apply the combined fit × pinch transform using the cached layout numbers
|
||||
// (cheap — no reflow), clamping the pan so content edges pin to the viewport.
|
||||
function applyTransform(w) {
|
||||
var s = Z.fit * Z.scale;
|
||||
if (Z.scale > 1.001 || Z.fit < 0.999) {
|
||||
var minTx = Math.min(0, Z.avail - Z.layoutW * s);
|
||||
if (Z.tx < minTx) Z.tx = minTx;
|
||||
if (Z.tx > 0) Z.tx = 0;
|
||||
w.style.width = Z.layoutW + 'px';
|
||||
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
|
||||
} else {
|
||||
Z.tx = 0;
|
||||
w.style.width = '100%';
|
||||
w.style.transform = 'none';
|
||||
}
|
||||
}
|
||||
// Shrink-to-fit: emails authored at a fixed width (e.g. 600px tables) overflow
|
||||
// a phone viewport and force horizontal scrolling. Lay the content out at its
|
||||
// natural width, then scale the whole block down so its widest element fits
|
||||
// the screen — the way native mail clients do. No sideways scrolling.
|
||||
function fitToWidth(w) {
|
||||
if (!w) return;
|
||||
var avail = availWidth();
|
||||
if (avail <= 0) return;
|
||||
// Reset to full width so wrapping content reports its true overflow extent
|
||||
// (the widest unbreakable element) rather than a previously-scaled box.
|
||||
w.style.width = '100%';
|
||||
var content = w.scrollWidth;
|
||||
Z.avail = avail;
|
||||
Z.layoutW = content > avail + 1 ? content : avail;
|
||||
Z.fit = avail / Z.layoutW;
|
||||
applyTransform(w);
|
||||
}
|
||||
function measure() {
|
||||
var w = ensureWrapper();
|
||||
if (!w) return 0;
|
||||
// While a pinch is in flight only re-apply the transform — a full re-fit
|
||||
// resets the wrapper width and would thrash layout on every frame.
|
||||
if (Z.pinching) applyTransform(w);
|
||||
else fitToWidth(w);
|
||||
// getBoundingClientRect reflects the applied transform, so r.height is the
|
||||
// already-scaled visual height of the content.
|
||||
var r = w.getBoundingClientRect();
|
||||
var b = document.body;
|
||||
var cs = window.getComputedStyle(b);
|
||||
@@ -117,7 +287,16 @@ const HEIGHT_REPORTER = `
|
||||
if (document.body) ro.observe(document.body);
|
||||
} catch (e) {}
|
||||
try {
|
||||
var mo = new MutationObserver(report);
|
||||
var mo = new MutationObserver(function (records) {
|
||||
for (var i = 0; i < records.length; i++) {
|
||||
// Ignore the style mutations fitToWidth() applies to the wrapper itself,
|
||||
// otherwise scaling would re-trigger a measure and loop indefinitely.
|
||||
var rec = records[i];
|
||||
if (rec.target === wrapperEl && rec.type === 'attributes') continue;
|
||||
report();
|
||||
return;
|
||||
}
|
||||
});
|
||||
if (document.body) mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
|
||||
} catch (e) {}
|
||||
try {
|
||||
@@ -126,11 +305,163 @@ const HEIGHT_REPORTER = `
|
||||
img.addEventListener('error', report);
|
||||
});
|
||||
} catch (e) {}
|
||||
// Hooks for the pinch-zoom handler script.
|
||||
window.__rnApplyZoom = function () { var w = ensureWrapper(); if (w) applyTransform(w); };
|
||||
window.__rnReport = report;
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps) {
|
||||
// Detect a clear horizontal flick inside the WebView and report it. Uses
|
||||
// capture-phase, passive listeners so it never blocks vertical scrolling, text
|
||||
// selection, or link taps — it only reacts to a quick, horizontal-dominant
|
||||
// gesture on release. Guarded so re-injection (onLoadEnd) is a no-op.
|
||||
const SWIPE_DETECTOR = `
|
||||
(function () {
|
||||
if (window.__rnSwipeInit) return;
|
||||
window.__rnSwipeInit = true;
|
||||
var x0 = 0, y0 = 0, t0 = 0, tracking = false;
|
||||
document.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1) { tracking = false; return; }
|
||||
var t = e.touches[0];
|
||||
x0 = t.clientX; y0 = t.clientY; t0 = Date.now(); tracking = true;
|
||||
}, { passive: true, capture: true });
|
||||
document.addEventListener('touchend', function (e) {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
// While the content is pinch-zoomed, horizontal flicks pan the zoomed
|
||||
// content — never switch mails.
|
||||
if (window.__rnZoom && (window.__rnZoom.pinching || window.__rnZoom.scale > 1.001)) return;
|
||||
var t = e.changedTouches[0];
|
||||
if (!t) return;
|
||||
var dx = t.clientX - x0, dy = t.clientY - y0, dt = Date.now() - t0;
|
||||
if (dt < 700 && Math.abs(dx) > 55 && Math.abs(dx) > Math.abs(dy) * 1.8) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'swipe', dir: dx < 0 ? 'next' : 'prev' }));
|
||||
}
|
||||
}
|
||||
}, { passive: true, capture: true });
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
// Pinch zoom + pan for the email content, driven entirely in the page: native
|
||||
// WebView zoom can't work here because the WebView is auto-height inside a
|
||||
// native ScrollView (viewport meta pins it to scale 1). A two-finger pinch
|
||||
// scales the measured wrapper (on top of the shrink-to-fit scale the height
|
||||
// reporter maintains) anchored at the fingers' horizontal midpoint; while
|
||||
// zoomed, one-finger horizontal drags pan via translateX and vertical drags
|
||||
// still scroll the surrounding ScrollView, which sees the scaled height
|
||||
// through the regular height reports. Pinch moves call preventDefault so the
|
||||
// engine consumes the gesture and Android parents don't intercept it; `zoom`
|
||||
// messages let the RN side freeze the pane scroll / pager as a second guard.
|
||||
const PINCH_ZOOM = `
|
||||
(function () {
|
||||
if (window.__rnPinchInit) return;
|
||||
window.__rnPinchInit = true;
|
||||
var Z = window.__rnZoom = window.__rnZoom || {
|
||||
scale: 1, tx: 0, pinching: false, fit: 1, layoutW: 0, avail: 0,
|
||||
};
|
||||
var MAX_ZOOM = 4;
|
||||
var pinch = null; // { d0, s0, cx0, tx0 }
|
||||
var pan = null; // { x0, tx0 }
|
||||
var rafPending = false;
|
||||
|
||||
function post(msg) {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
function apply() {
|
||||
if (window.__rnApplyZoom) window.__rnApplyZoom();
|
||||
}
|
||||
// Height reports are rAF-throttled during the gesture so the RN container
|
||||
// keeps up with the growing content without re-measuring on every move.
|
||||
function scheduleReport() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
requestAnimationFrame(function () {
|
||||
rafPending = false;
|
||||
if (window.__rnReport) window.__rnReport();
|
||||
});
|
||||
}
|
||||
function dist(t0, t1) {
|
||||
var dx = t1.clientX - t0.clientX, dy = t1.clientY - t0.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length === 2) {
|
||||
var t0 = e.touches[0], t1 = e.touches[1];
|
||||
pinch = {
|
||||
d0: dist(t0, t1) || 1,
|
||||
s0: Z.scale,
|
||||
cx0: (t0.clientX + t1.clientX) / 2,
|
||||
tx0: Z.tx,
|
||||
};
|
||||
pan = null;
|
||||
if (!Z.pinching) {
|
||||
Z.pinching = true;
|
||||
post({ type: 'zoom', pinching: true, scale: Z.scale });
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if (e.touches.length === 1 && Z.scale > 1.001) {
|
||||
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
|
||||
} else {
|
||||
pan = null;
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
if (pinch && e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
var t0 = e.touches[0], t1 = e.touches[1];
|
||||
var z = pinch.s0 * (dist(t0, t1) / pinch.d0);
|
||||
if (z < 1) z = 1;
|
||||
if (z > MAX_ZOOM) z = MAX_ZOOM;
|
||||
// Keep the content under the fingers' midpoint stationary horizontally:
|
||||
// the wrapper-local point that started under cx0 must map back to the
|
||||
// current midpoint at the new scale. (applyTransform clamps the result.)
|
||||
var cx = (t0.clientX + t1.clientX) / 2;
|
||||
var local = (pinch.cx0 - pinch.tx0) / (Z.fit * pinch.s0);
|
||||
Z.scale = z;
|
||||
Z.tx = cx - local * (Z.fit * z);
|
||||
apply();
|
||||
scheduleReport();
|
||||
} else if (pan && e.touches.length === 1 && Z.scale > 1.001) {
|
||||
// No preventDefault: vertical-dominant drags must keep scrolling the
|
||||
// native ScrollView around the WebView. Horizontal-dominant drags stay
|
||||
// inside the WebView and pan here.
|
||||
Z.tx = pan.tx0 + (e.touches[0].clientX - pan.x0);
|
||||
apply();
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (pinch && e.touches.length < 2) {
|
||||
pinch = null;
|
||||
// Snap back to the fitted layout when the pinch ends near scale 1.
|
||||
if (Z.scale < 1.02) { Z.scale = 1; Z.tx = 0; }
|
||||
Z.pinching = false;
|
||||
apply();
|
||||
if (window.__rnReport) window.__rnReport();
|
||||
post({ type: 'zoom', pinching: false, scale: Z.scale });
|
||||
// A finger left on screen continues as a pan.
|
||||
if (e.touches.length === 1 && Z.scale > 1.001) {
|
||||
pan = { x0: e.touches[0].clientX, tx0: Z.tx };
|
||||
}
|
||||
}
|
||||
if (e.touches.length === 0) pan = null;
|
||||
}
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true, capture: true });
|
||||
document.addEventListener('touchcancel', onTouchEnd, { passive: true, capture: true });
|
||||
true;
|
||||
})();
|
||||
`;
|
||||
|
||||
export default function EmailBodyView({
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange,
|
||||
}: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
@@ -234,7 +565,9 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
await Promise.all(
|
||||
toFetch.map(async ({ ref, att }) => {
|
||||
try {
|
||||
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||
const buf = await jmapClient.fetchBlobArrayBuffer(
|
||||
att.blobId, att.name, att.type, jmapAccountId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const b64 = arrayBufferToBase64(buf);
|
||||
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
|
||||
@@ -248,7 +581,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [rawHtml, email.attachments]);
|
||||
}, [rawHtml, email.attachments, jmapAccountId]);
|
||||
|
||||
const source = React.useMemo(() => {
|
||||
if (rawHtml) {
|
||||
@@ -273,8 +606,36 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
};
|
||||
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
|
||||
|
||||
// Inversion is applied for HTML bodies in dark mode unless the email ships its
|
||||
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
|
||||
// stylesheet backgrounds) so it runs before the height reporter measures.
|
||||
const applyInversion =
|
||||
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
|
||||
const injectedJs = React.useMemo(
|
||||
() =>
|
||||
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
|
||||
+ SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[applyInversion],
|
||||
);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
const parsed = parseInt(e.nativeEvent.data, 10);
|
||||
const data = e.nativeEvent.data;
|
||||
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
|
||||
if (data.charCodeAt(0) === 123 /* '{' */) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'swipe' && (msg.dir === 'prev' || msg.dir === 'next')) {
|
||||
onSwipe?.(msg.dir);
|
||||
} else if (msg.type === 'zoom') {
|
||||
onZoomChange?.({
|
||||
pinching: !!msg.pinching,
|
||||
zoomed: typeof msg.scale === 'number' && msg.scale > 1.01,
|
||||
});
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
return;
|
||||
}
|
||||
const parsed = parseInt(data, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) return;
|
||||
setHeight((prev) => (Math.abs(parsed - prev) < 2 ? prev : parsed));
|
||||
};
|
||||
@@ -316,16 +677,25 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
originWhitelist={['about:blank']}
|
||||
source={source}
|
||||
javaScriptEnabled
|
||||
injectedJavaScript={HEIGHT_REPORTER}
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={() => {
|
||||
// A (re)load resets the page's zoom state to 1 — drop any host-side
|
||||
// scroll locks so they can't go stale (e.g. cid images arriving
|
||||
// reload the source mid-zoom).
|
||||
onZoomChange?.({ pinching: false, zoomed: false });
|
||||
// Re-inject after load to cover Android cases where
|
||||
// `injectedJavaScript` runs too early to see the final layout.
|
||||
// `injectedJavaScript` runs too early to see the final layout. The
|
||||
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
|
||||
// again here is a no-op once the body has been processed.
|
||||
setTimeout(() => {
|
||||
webviewRef.current?.injectJavaScript(HEIGHT_REPORTER);
|
||||
webviewRef.current?.injectJavaScript(injectedJs);
|
||||
}, 50);
|
||||
}}
|
||||
scrollEnabled={false}
|
||||
// Native Android zoom would scale the viewport inside the fixed-size
|
||||
// container; pinch zoom is implemented in the page instead.
|
||||
setBuiltInZoomControls={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
setSupportMultipleWindows={false}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Mail } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import type { Identity } from '../api/types';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
interface IdentitySheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
identities: Identity[];
|
||||
selectedIdentityId: string | null;
|
||||
onPick: (identity: Identity) => void;
|
||||
}
|
||||
|
||||
export function IdentitySheet({
|
||||
visible, onClose, identities, selectedIdentityId, onPick,
|
||||
}: IdentitySheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
const { t } = useLocaleStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.scrollList}>
|
||||
{identities.map((identity) => {
|
||||
const isSelected = identity.id === selectedIdentityId;
|
||||
return (
|
||||
<Pressable
|
||||
key={identity.id}
|
||||
onPress={() => {
|
||||
onPick(identity);
|
||||
onClose();
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.identityRow,
|
||||
pressed && styles.identityRowPressed,
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconWrap}>
|
||||
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.identityDetails}>
|
||||
{!!identity.name && (
|
||||
<Text style={styles.identityName} numberOfLines={1}>
|
||||
{identity.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
|
||||
{identity.email}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<View style={styles.checkWrap}>
|
||||
<Check size={16} color={c.primary} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sheetOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '60%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetHandleHit: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
sheetHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: c.border,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: {
|
||||
...typography.bodySemibold,
|
||||
color: c.text,
|
||||
},
|
||||
sheetClose: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
scrollList: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
identityRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 52,
|
||||
},
|
||||
identityRowPressed: {
|
||||
backgroundColor: c.surfaceHover,
|
||||
},
|
||||
iconWrap: {
|
||||
marginRight: spacing.md,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
identityDetails: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
identityName: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
},
|
||||
identityEmail: {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
identityEmailOnly: {
|
||||
...typography.bodyMedium,
|
||||
color: c.text,
|
||||
marginTop: 0,
|
||||
},
|
||||
checkWrap: {
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -98,7 +98,9 @@ export function MoveSheet({
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
// A shared account's header is a grouping row, not a folder.
|
||||
const canTarget =
|
||||
!node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent;
|
||||
return (
|
||||
<Pressable
|
||||
key={node.id}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { CloudOff } from 'lucide-react-native';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useOutboxStore } from '../stores/outbox-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
@@ -14,12 +15,18 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const online = useNetworkStore((s) => s.online);
|
||||
// Pending offline mutations that will replay once we're back online.
|
||||
const queued = useOutboxStore((s) => s.entries.length);
|
||||
if (online) return null;
|
||||
const queuedHint = queued > 0
|
||||
? `${queued} change${queued === 1 ? '' : 's'} will sync when you reconnect`
|
||||
: null;
|
||||
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<View style={styles.bar}>
|
||||
<CloudOff size={14} color={c.primaryForeground} />
|
||||
<Text style={styles.text} numberOfLines={1}>
|
||||
You are offline{hint ? ` — ${hint}` : ''}
|
||||
You are offline{suffix ? ` — ${suffix}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
|
||||
interface QrScanModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
// Fires once with the raw decoded QR string. The parent is responsible for
|
||||
// closing the modal and interpreting the payload.
|
||||
onScanned: (data: string) => void;
|
||||
}
|
||||
|
||||
export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
const c = useColors();
|
||||
const theme = useResolvedTheme();
|
||||
const styles = React.useMemo(() => makeStyles(c, theme), [c, theme]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
// Guards against the camera firing onBarcodeScanned dozens of times for the
|
||||
// same code before the modal tears down.
|
||||
const handled = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) handled.current = false;
|
||||
}, [visible]);
|
||||
|
||||
const handleBarcode = React.useCallback(
|
||||
(result: BarcodeScanningResult) => {
|
||||
if (handled.current) return;
|
||||
handled.current = true;
|
||||
onScanned(result.data);
|
||||
},
|
||||
[onScanned],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.container}>
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarcode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{permission?.granted ? (
|
||||
// Dims everything except the scan square. Four scrim regions leave a
|
||||
// transparent hole so the camera shows through only inside the frame.
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<View style={styles.scrim} />
|
||||
<View style={styles.scrimCenterRow}>
|
||||
<View style={styles.scrimSide} />
|
||||
<View style={styles.frame} />
|
||||
<View style={styles.scrimSide} />
|
||||
</View>
|
||||
<View style={[styles.scrim, styles.scrimBottom]}>
|
||||
<View style={styles.hintWrap}>
|
||||
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text>
|
||||
<Text style={styles.hint}>
|
||||
Settings → Devices → Add phone shows a code. Point the camera at it.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<SafeAreaView style={styles.overlay}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
|
||||
<X size={24} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Sign-in code</Text>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
|
||||
{!permission?.granted ? (
|
||||
<View style={styles.permissionWrap}>
|
||||
<Text style={styles.permissionText}>
|
||||
{permission && !permission.canAskAgain
|
||||
? 'Camera access is disabled. Enable it in Settings to scan a sign-in QR code.'
|
||||
: 'Bulwark Mail needs camera access to scan a sign-in QR code.'}
|
||||
</Text>
|
||||
<Button variant="default" size="md" onPress={() => void requestPermission()}>
|
||||
Allow camera access
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette, theme: 'light' | 'dark') {
|
||||
const scrimColor = theme === 'light' ? 'rgba(248,250,252,0.72)' : 'rgba(0,0,0,0.6)';
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000000' },
|
||||
overlay: { flex: 1, justifyContent: 'flex-start' },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: Platform.OS === 'android' ? spacing.xl : spacing.sm,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
closeButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
title: { ...typography.h3, color: c.text },
|
||||
scrim: { flex: 1, backgroundColor: scrimColor },
|
||||
scrimCenterRow: { flexDirection: 'row', height: 240 },
|
||||
scrimSide: { flex: 1, backgroundColor: scrimColor },
|
||||
scrimBottom: { alignItems: 'center', paddingTop: spacing.xl },
|
||||
frame: {
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderWidth: 3,
|
||||
borderColor: c.text,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl },
|
||||
hintTitle: { ...typography.bodySemibold, color: c.text, textAlign: 'center' },
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
permissionWrap: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.lg,
|
||||
paddingHorizontal: spacing.xxl,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
permissionText: { ...typography.body, color: c.text, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
const MIN_HEIGHT = 220;
|
||||
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
||||
|
||||
export type RichTextCommand =
|
||||
| 'bold'
|
||||
@@ -54,6 +51,13 @@ export interface RichTextEditorHandle {
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
setHtml(html: string): void;
|
||||
/**
|
||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||
* rejects after `timeoutMs` when the page script is dead or the bridge is
|
||||
* broken — callers must treat that as "content unknown", never fall back to
|
||||
* possibly-stale state (issue #9: doing so silently sent blank replies).
|
||||
*/
|
||||
getHtml(timeoutMs?: number): Promise<string>;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
@@ -66,273 +70,6 @@ interface Props {
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient.
|
||||
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
|
||||
var trimmed = (url || '').trim();
|
||||
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
|
||||
ref,
|
||||
@@ -340,7 +77,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [height, setHeight] = React.useState(MIN_HEIGHT);
|
||||
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
@@ -364,6 +101,27 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
// Pending getHtml() readbacks, keyed by request id. Timers reject requests
|
||||
// the page never answers (dead script / broken bridge).
|
||||
const snapshotSeq = React.useRef(0);
|
||||
const pendingSnapshots = React.useRef(
|
||||
new Map<number, {
|
||||
resolve: (html: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>(),
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const pending = pendingSnapshots.current;
|
||||
return () => {
|
||||
for (const { timer, reject } of pending.values()) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Editor unmounted'));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
@@ -382,6 +140,16 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
getHtml: (timeoutMs = 2000) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = ++snapshotSeq.current;
|
||||
const timer = setTimeout(() => {
|
||||
pendingSnapshots.current.delete(id);
|
||||
reject(new Error('Editor did not answer'));
|
||||
}, timeoutMs);
|
||||
pendingSnapshots.current.set(id, { resolve, reject, timer });
|
||||
call(`window.__rne && window.__rne.getHtml(${id})`);
|
||||
}),
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
@@ -400,7 +168,17 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
break;
|
||||
case 'height': {
|
||||
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
|
||||
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
|
||||
break;
|
||||
}
|
||||
case 'htmlSnapshot': {
|
||||
const p = data.payload as { id?: number; html?: string } | null;
|
||||
const entry = typeof p?.id === 'number' ? pendingSnapshots.current.get(p.id) : undefined;
|
||||
if (entry) {
|
||||
pendingSnapshots.current.delete(p!.id!);
|
||||
clearTimeout(entry.timer);
|
||||
entry.resolve(typeof p?.html === 'string' ? p.html : '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'selection':
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||
Clock, Layers,
|
||||
Clock, Layers, Users,
|
||||
} from 'lucide-react-native';
|
||||
import type { LucideIcon } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
@@ -83,6 +83,8 @@ interface SidebarRowProps {
|
||||
label: string;
|
||||
depth: number;
|
||||
isSelected: boolean;
|
||||
/** Header for a shared/group account: styled as a section, never selected. */
|
||||
isAccountHeader?: boolean;
|
||||
unread: number;
|
||||
total: number;
|
||||
hasChildren: boolean;
|
||||
@@ -92,7 +94,7 @@ interface SidebarRowProps {
|
||||
}
|
||||
|
||||
function SidebarRow({
|
||||
icon, label, depth, isSelected, unread, total,
|
||||
icon, label, depth, isSelected, isAccountHeader, unread, total,
|
||||
hasChildren, isExpanded, onPress, onToggleExpand,
|
||||
}: SidebarRowProps) {
|
||||
const c = useColors();
|
||||
@@ -126,7 +128,11 @@ function SidebarRow({
|
||||
</View>
|
||||
<View style={styles.rowIcon}>{icon}</View>
|
||||
<Text
|
||||
style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}
|
||||
style={[
|
||||
styles.rowLabel,
|
||||
isSelected && styles.rowLabelSelected,
|
||||
isAccountHeader && styles.rowLabelAccount,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
@@ -467,20 +473,34 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
||||
visibleNodes.map((node) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = iconFor(node.role, node.name, hasChildren, isExpanded);
|
||||
const isSelected = node.id === currentMailboxId;
|
||||
// A shared/group account's header has no mailbox behind it —
|
||||
// tapping it only opens or closes that account's folders.
|
||||
const Icon = node.isAccountNode
|
||||
? Users
|
||||
: iconFor(node.role, node.name, hasChildren, isExpanded);
|
||||
const isSelected = !node.isAccountNode && node.id === currentMailboxId;
|
||||
return (
|
||||
<SidebarRow
|
||||
key={node.id}
|
||||
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
|
||||
icon={
|
||||
<Icon
|
||||
size={16}
|
||||
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
|
||||
/>
|
||||
}
|
||||
label={node.name}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
isAccountHeader={node.isAccountNode}
|
||||
unread={node.unreadEmails}
|
||||
total={node.totalEmails}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onPress={() => handleSelect(node.id)}
|
||||
onPress={
|
||||
node.isAccountNode
|
||||
? () => toggleExpand(node.id)
|
||||
: () => handleSelect(node.id)
|
||||
}
|
||||
onToggleExpand={() => toggleExpand(node.id)}
|
||||
/>
|
||||
);
|
||||
@@ -741,6 +761,13 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.bodySemibold,
|
||||
color: c.text,
|
||||
},
|
||||
rowLabelAccount: {
|
||||
...typography.caption,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
counts: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import { typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
|
||||
import {
|
||||
shouldClaimGesture, dragOffset, resolveRelease, exitsRow,
|
||||
COMMIT_THRESHOLD, REVEAL_WIDTH,
|
||||
type SwipeConfig,
|
||||
} from './swipe-gesture';
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
@@ -26,17 +31,6 @@ interface SwipeableRowProps {
|
||||
mode?: SwipeMode;
|
||||
}
|
||||
|
||||
// Instant mode: distance the user must drag past on release to fire the action.
|
||||
const COMMIT_THRESHOLD = 96;
|
||||
// Reveal mode: drag past this on release to snap to the open (revealed) state.
|
||||
const ACTIVATION_THRESHOLD = 32;
|
||||
// Reveal mode: width of the action band shown when revealed.
|
||||
const REVEAL_WIDTH = 88;
|
||||
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
|
||||
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
|
||||
// Distance the row flies off-screen by before the action callback fires.
|
||||
const EXIT_DISTANCE = 600;
|
||||
|
||||
@@ -58,12 +52,6 @@ function actionLabel(action: SwipeAction, context: { unread: boolean; starred: b
|
||||
return ACTION_META[action].defaultLabel;
|
||||
}
|
||||
|
||||
// Actions that visibly remove the row from the list (so the row should fly off
|
||||
// instead of snapping back). Toggle-style actions stay in place.
|
||||
function exitsRow(action: SwipeAction): boolean {
|
||||
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
children, leftAction, rightAction, context, onAction, mode = 'instant',
|
||||
}: SwipeableRowProps) {
|
||||
@@ -73,19 +61,37 @@ export function SwipeableRow({
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
// The PanResponder below is built once, so its handlers close over the props
|
||||
// of the render that built it. Reading them through a ref instead keeps the
|
||||
// gesture in step with the row it is actually attached to. Three ways that
|
||||
// went wrong before: changing a swipe action in settings kept firing the old
|
||||
// one; entering selection mode (which passes 'none') still fired actions;
|
||||
// and `onAction` captured a `handleSwipeAction` from before the mailbox ids
|
||||
// had loaded, so on a slow connection swipe did nothing even untouched.
|
||||
// Same idiom as RichTextEditor's callback refs.
|
||||
const latest = useRef<SwipeConfig & { onAction: (a: SwipeAction) => void }>({
|
||||
leftAction, rightAction, mode, onAction,
|
||||
});
|
||||
latest.current = { leftAction, rightAction, mode, onAction };
|
||||
|
||||
// Reveal-mode-only state: which side (if any) is currently sitting open.
|
||||
const openSideRef = useRef<'left' | 'right' | null>(null);
|
||||
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||
|
||||
const springTo = (toValue: number) => {
|
||||
Animated.spring(dx, { toValue, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
};
|
||||
// Spring back to rest without changing which side is open.
|
||||
const settle = () => springTo(0);
|
||||
|
||||
const close = () => {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
springTo(0);
|
||||
openSideRef.current = null;
|
||||
setOpenSide(null);
|
||||
};
|
||||
|
||||
const openTo = (side: 'left' | 'right') => {
|
||||
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
|
||||
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
springTo(side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH);
|
||||
openSideRef.current = side;
|
||||
setOpenSide(side);
|
||||
};
|
||||
@@ -101,14 +107,14 @@ export function SwipeableRow({
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
latest.current.onAction(action);
|
||||
// The row is about to be removed; reset translation in case it isn't
|
||||
// (e.g. the action failed silently) so we don't leave it off-screen.
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
onAction(action);
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
latest.current.onAction(action);
|
||||
settle();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,77 +123,56 @@ export function SwipeableRow({
|
||||
if (action === 'none') return;
|
||||
if (exitsRow(action)) {
|
||||
// Let the row collapse a frame, then fire so the parent's list update
|
||||
// has a clean starting point.
|
||||
requestAnimationFrame(() => onAction(action));
|
||||
// has a clean starting point. Deferred, so read the callback on arrival
|
||||
// rather than capturing today's.
|
||||
requestAnimationFrame(() => latest.current.onAction(action));
|
||||
} else {
|
||||
onAction(action);
|
||||
latest.current.onAction(action);
|
||||
}
|
||||
};
|
||||
|
||||
const responder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
// Reveal mode: if a side is already open, allow the gesture so the
|
||||
// user can drag it closed.
|
||||
if (mode === 'reveal' && openSideRef.current === null) {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
} else if (mode !== 'reveal') {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
}
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const base =
|
||||
mode === 'reveal'
|
||||
? openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0
|
||||
: 0;
|
||||
const next = base + g.dx;
|
||||
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
|
||||
dx.setValue(clamped);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
if (mode === 'reveal') {
|
||||
const base =
|
||||
openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0;
|
||||
const finalDx = base + g.dx;
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
openTo('right');
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
openTo('left');
|
||||
} else {
|
||||
close();
|
||||
// Built once. Every handler reads `latest.current`, so a rebuild would be a
|
||||
// no-op anyway — which is what makes useMemo (free to discard its cache)
|
||||
// safe here.
|
||||
const responder = React.useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (!shouldClaimGesture(g, latest.current, openSideRef.current)) return false;
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
dx.setValue(dragOffset(g, latest.current, openSideRef.current));
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
const outcome = resolveRelease(g, latest.current, openSideRef.current);
|
||||
switch (outcome.kind) {
|
||||
case 'open':
|
||||
openTo(outcome.side);
|
||||
break;
|
||||
case 'close':
|
||||
close();
|
||||
break;
|
||||
case 'fire':
|
||||
fly(outcome.direction * (widthRef.current || EXIT_DISTANCE), outcome.action);
|
||||
break;
|
||||
case 'settle':
|
||||
settle();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
} else {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (mode === 'reveal') close();
|
||||
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
).current;
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (latest.current.mode === 'reveal') close();
|
||||
else settle();
|
||||
claimed.current = false;
|
||||
},
|
||||
onPanResponderTerminationRequest: () => !claimed.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
|
||||
widthRef.current = e.nativeEvent.layout.width;
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 50,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
shouldClaimGesture,
|
||||
dragOffset,
|
||||
resolveRelease,
|
||||
restingOffset,
|
||||
exitsRow,
|
||||
COMMIT_THRESHOLD,
|
||||
ACTIVATION_THRESHOLD,
|
||||
REVEAL_WIDTH,
|
||||
type SwipeConfig,
|
||||
} from '../swipe-gesture';
|
||||
|
||||
const instant = (over: Partial<SwipeConfig> = {}): SwipeConfig => ({
|
||||
mode: 'instant',
|
||||
leftAction: 'delete',
|
||||
rightAction: 'archive',
|
||||
...over,
|
||||
});
|
||||
|
||||
const reveal = (over: Partial<SwipeConfig> = {}): SwipeConfig => instant({ mode: 'reveal', ...over });
|
||||
|
||||
// PanResponder hands over dx/dy/vx; the rest of the gesture state is unused.
|
||||
const g = (dx: number, opts: { dy?: number; vx?: number } = {}) => ({
|
||||
dx,
|
||||
dy: opts.dy ?? 0,
|
||||
vx: opts.vx ?? 0,
|
||||
});
|
||||
|
||||
describe('shouldClaimGesture', () => {
|
||||
it('ignores drags too short to be intentional', () => {
|
||||
expect(shouldClaimGesture(g(4), instant(), null)).toBe(false);
|
||||
expect(shouldClaimGesture(g(-4), instant(), null)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves mostly-vertical drags to the list scroll', () => {
|
||||
expect(shouldClaimGesture(g(20, { dy: 40 }), instant(), null)).toBe(false);
|
||||
expect(shouldClaimGesture(g(40, { dy: 20 }), instant(), null)).toBe(true);
|
||||
});
|
||||
|
||||
it("declines the direction whose action is 'none'", () => {
|
||||
const config = instant({ rightAction: 'none' });
|
||||
expect(shouldClaimGesture(g(50), config, null)).toBe(false);
|
||||
expect(shouldClaimGesture(g(-50), config, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("declines both directions in selection mode, where both actions are 'none'", () => {
|
||||
const config = instant({ leftAction: 'none', rightAction: 'none' });
|
||||
expect(shouldClaimGesture(g(50), config, null)).toBe(false);
|
||||
expect(shouldClaimGesture(g(-50), config, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('claims any direction while a reveal band is open, so it can be dragged shut', () => {
|
||||
const config = reveal({ leftAction: 'none', rightAction: 'archive' });
|
||||
expect(shouldClaimGesture(g(-50), config, 'right')).toBe(true);
|
||||
// ...but not once it is closed again.
|
||||
expect(shouldClaimGesture(g(-50), config, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dragOffset', () => {
|
||||
it('tracks the finger in instant mode', () => {
|
||||
expect(dragOffset(g(70), instant(), null)).toBe(70);
|
||||
expect(dragOffset(g(-70), instant(), null)).toBe(-70);
|
||||
});
|
||||
|
||||
it('clamps to the overshoot limit', () => {
|
||||
expect(dragOffset(g(9999), instant(), null)).toBe(240);
|
||||
expect(dragOffset(g(-9999), instant(), null)).toBe(-240);
|
||||
// Reveal mode allows much less overshoot than instant.
|
||||
expect(dragOffset(g(9999), reveal(), null)).toBeCloseTo(REVEAL_WIDTH * 1.4);
|
||||
});
|
||||
|
||||
it('offsets from the open position in reveal mode', () => {
|
||||
// Already open to the right; dragging back left should approach 0.
|
||||
expect(dragOffset(g(-REVEAL_WIDTH), reveal(), 'right')).toBe(0);
|
||||
expect(dragOffset(g(-10), reveal(), 'right')).toBe(REVEAL_WIDTH - 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRelease — instant mode', () => {
|
||||
it('fires the action for the direction dragged past the threshold', () => {
|
||||
expect(resolveRelease(g(COMMIT_THRESHOLD), instant(), null)).toEqual({
|
||||
kind: 'fire',
|
||||
action: 'archive',
|
||||
direction: 1,
|
||||
});
|
||||
expect(resolveRelease(g(-COMMIT_THRESHOLD), instant(), null)).toEqual({
|
||||
kind: 'fire',
|
||||
action: 'delete',
|
||||
direction: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a short fast flick as a commit', () => {
|
||||
expect(resolveRelease(g(50, { vx: 0.8 }), instant(), null)).toMatchObject({ kind: 'fire' });
|
||||
// Same distance, too slow.
|
||||
expect(resolveRelease(g(50, { vx: 0.2 }), instant(), null)).toEqual({ kind: 'settle' });
|
||||
});
|
||||
|
||||
it('springs back when the drag stops short', () => {
|
||||
expect(resolveRelease(g(COMMIT_THRESHOLD - 1), instant(), null)).toEqual({ kind: 'settle' });
|
||||
});
|
||||
|
||||
it("never fires an action configured as 'none'", () => {
|
||||
const config = instant({ rightAction: 'none' });
|
||||
expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' });
|
||||
});
|
||||
|
||||
it('fires nothing in selection mode, however hard the row is flung', () => {
|
||||
const config = instant({ leftAction: 'none', rightAction: 'none' });
|
||||
expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' });
|
||||
expect(resolveRelease(g(-999, { vx: -5 }), config, null)).toEqual({ kind: 'settle' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRelease — reveal mode', () => {
|
||||
it('opens the band rather than firing', () => {
|
||||
expect(resolveRelease(g(ACTIVATION_THRESHOLD + 1), reveal(), null)).toEqual({
|
||||
kind: 'open',
|
||||
side: 'right',
|
||||
});
|
||||
expect(resolveRelease(g(-ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({
|
||||
kind: 'open',
|
||||
side: 'left',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes when the drag stops short', () => {
|
||||
expect(resolveRelease(g(ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({ kind: 'close' });
|
||||
});
|
||||
|
||||
it('measures from the open position, so a small drag keeps it open', () => {
|
||||
// Open to the right, nudged 5px further right: still well past the threshold.
|
||||
expect(resolveRelease(g(5), reveal(), 'right')).toEqual({ kind: 'open', side: 'right' });
|
||||
// Dragged back past the resting point: closes.
|
||||
expect(resolveRelease(g(-REVEAL_WIDTH), reveal(), 'right')).toEqual({ kind: 'close' });
|
||||
});
|
||||
|
||||
it("closes instead of opening a side whose action is 'none'", () => {
|
||||
const config = reveal({ rightAction: 'none' });
|
||||
expect(resolveRelease(g(999), config, null)).toEqual({ kind: 'close' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('restingOffset', () => {
|
||||
it('maps the open side to the row translation it sits at', () => {
|
||||
expect(restingOffset('right')).toBe(REVEAL_WIDTH);
|
||||
expect(restingOffset('left')).toBe(-REVEAL_WIDTH);
|
||||
expect(restingOffset(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitsRow', () => {
|
||||
it('flags the actions that remove the row from the list', () => {
|
||||
expect((['archive', 'delete', 'spam', 'move'] as const).every(exitsRow)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps toggle actions in place', () => {
|
||||
expect((['read', 'star', 'pin', 'none'] as const).some(exitsRow)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
|
||||
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
|
||||
import { CalendarDays } from 'lucide-react-native';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import {
|
||||
buildEventDayIndex,
|
||||
dayKey,
|
||||
eventsOnDayFromIndex,
|
||||
getEventStartDate,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { EventCard } from './EventCard';
|
||||
|
||||
@@ -19,6 +20,7 @@ interface AgendaViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
eventsByDay?: EventDayIndex;
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export function AgendaView({
|
||||
events,
|
||||
calendars,
|
||||
eventsByDay,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
}: AgendaViewProps) {
|
||||
const c = useColors();
|
||||
@@ -50,32 +53,35 @@ export function AgendaView({
|
||||
);
|
||||
|
||||
const sections = React.useMemo<DaySection[]>(() => {
|
||||
const result: DaySection[] = [];
|
||||
const start = new Date(fromDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
|
||||
// every day that actually has events, always include a "Today" anchor, and
|
||||
// render them sorted ascending. Driving the list off the events (rather than
|
||||
// a fixed day-counter) guarantees every upcoming loaded event appears - not
|
||||
// just the ones inside a brittle N-day window from the anchor.
|
||||
const anchor = startOfDay(fromDate);
|
||||
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
|
||||
|
||||
for (let i = 0; i < daysAhead; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
|
||||
// All-day events first within a day, then by start time, then title
|
||||
// (stable tie-break) - mirrors the comparator the webmail uses for
|
||||
// packing week segments and keeps multi-event days predictable.
|
||||
if (a.showWithoutTime !== b.showWithoutTime) {
|
||||
return a.showWithoutTime ? -1 : 1;
|
||||
}
|
||||
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
});
|
||||
if (dayEvents.length === 0 && !isToday(day)) continue;
|
||||
result.push({
|
||||
const days = new Map<string, Date>();
|
||||
for (const key of index.keys()) {
|
||||
const day = parseISO(key); // index keys are local yyyy-MM-dd
|
||||
if (isNaN(day.getTime())) continue;
|
||||
if (day < anchor || day >= horizonEnd) continue;
|
||||
days.set(key, day);
|
||||
}
|
||||
// Always anchor "Today" so the view has a stable starting point even when
|
||||
// the current day has no events (webmail injects the same anchor).
|
||||
const today = startOfDay(new Date());
|
||||
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
|
||||
|
||||
return [...days.values()]
|
||||
.sort((a, b) => a.getTime() - b.getTime())
|
||||
.map((day) => ({
|
||||
title: formatDayHeader(day),
|
||||
date: day,
|
||||
data: dayEvents,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
|
||||
// start, then title), so no extra sort is needed here.
|
||||
data: eventsOnDayFromIndex(index, day),
|
||||
}));
|
||||
}, [fromDate, daysAhead, index]);
|
||||
|
||||
if (sections.length === 0) {
|
||||
@@ -107,7 +113,7 @@ export function AgendaView({
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.itemWrap}>
|
||||
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
|
||||
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
|
||||
</View>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Check, X, Upload, Rss } from 'lucide-react-native';
|
||||
import { Check, X, Upload, Rss, Shuffle, Star } from 'lucide-react-native';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
visible: boolean;
|
||||
@@ -26,6 +27,11 @@ interface CalendarSidebarDrawerProps {
|
||||
onClose: () => void;
|
||||
onImport?: () => void;
|
||||
onManageSubscriptions?: () => void;
|
||||
// Long-press actions. Set-default applies to the user's own calendars;
|
||||
// color change/reset applies to shared calendars (per-viewer recolor).
|
||||
onSetDefault?: (calendar: Calendar) => void;
|
||||
onSetColor?: (calendar: Calendar, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
}
|
||||
|
||||
export function CalendarSidebarDrawer({
|
||||
@@ -36,6 +42,9 @@ export function CalendarSidebarDrawer({
|
||||
onClose,
|
||||
onImport,
|
||||
onManageSubscriptions,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -43,6 +52,7 @@ export function CalendarSidebarDrawer({
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const openDuration = useAnimDuration(240);
|
||||
const closeDuration = useAnimDuration(200);
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -60,6 +70,7 @@ export function CalendarSidebarDrawer({
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
setExpandedId(null);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideX, {
|
||||
toValue: -Dimensions.get('window').width,
|
||||
@@ -78,8 +89,23 @@ export function CalendarSidebarDrawer({
|
||||
|
||||
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
|
||||
|
||||
const myCalendars = calendars.filter((c) => !c.myRights || c.myRights.mayWrite !== false);
|
||||
const subscribed = calendars.filter((c) => c.myRights && c.myRights.mayWrite === false);
|
||||
const sharedCalendars = calendars.filter((cal) => cal.isShared);
|
||||
const myCalendars = calendars.filter(
|
||||
(cal) => !cal.isShared && (!cal.myRights || cal.myRights.mayWrite !== false),
|
||||
);
|
||||
const subscribed = calendars.filter(
|
||||
(cal) => !cal.isShared && cal.myRights && cal.myRights.mayWrite === false,
|
||||
);
|
||||
|
||||
const sectionProps = {
|
||||
hiddenSet,
|
||||
onToggle,
|
||||
expandedId,
|
||||
onExpand: setExpandedId,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -110,21 +136,15 @@ export function CalendarSidebarDrawer({
|
||||
)}
|
||||
|
||||
{myCalendars.length > 0 && (
|
||||
<Section
|
||||
title="My calendars"
|
||||
calendars={myCalendars}
|
||||
hiddenSet={hiddenSet}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<Section title="My calendars" calendars={myCalendars} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{sharedCalendars.length > 0 && (
|
||||
<Section title="Shared with me" calendars={sharedCalendars} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{subscribed.length > 0 && (
|
||||
<Section
|
||||
title="Subscribed"
|
||||
calendars={subscribed}
|
||||
hiddenSet={hiddenSet}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<Section title="Subscribed" calendars={subscribed} {...sectionProps} />
|
||||
)}
|
||||
|
||||
{(onImport || onManageSubscriptions) && (
|
||||
@@ -161,11 +181,21 @@ function Section({
|
||||
calendars,
|
||||
hiddenSet,
|
||||
onToggle,
|
||||
expandedId,
|
||||
onExpand,
|
||||
onSetDefault,
|
||||
onSetColor,
|
||||
onResetColor,
|
||||
}: {
|
||||
title: string;
|
||||
calendars: Calendar[];
|
||||
hiddenSet: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
expandedId: string | null;
|
||||
onExpand: (id: string | null) => void;
|
||||
onSetDefault?: (calendar: Calendar) => void;
|
||||
onSetColor?: (calendar: Calendar, color: string) => void;
|
||||
onResetColor?: (calendar: Calendar) => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -174,30 +204,85 @@ function Section({
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
{calendars.map((cal) => {
|
||||
const visible = !hiddenSet.has(cal.id);
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canSetDefault = !!onSetDefault && !cal.isShared && !isBirthday && !cal.isDefault;
|
||||
const canRecolor = !!onSetColor && !!cal.isShared;
|
||||
const hasActions = canSetDefault || canRecolor;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<Pressable
|
||||
key={cal.id}
|
||||
onPress={() => onToggle(cal.id)}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.swatch,
|
||||
{
|
||||
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
|
||||
borderColor: getCalendarColor(cal),
|
||||
},
|
||||
<View key={cal.id}>
|
||||
<Pressable
|
||||
onPress={() => onToggle(cal.id)}
|
||||
onLongPress={hasActions ? () => onExpand(expanded ? null : cal.id) : undefined}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
>
|
||||
{visible && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<Text style={[styles.rowName, !visible && styles.rowNameMuted]}>
|
||||
{cal.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View
|
||||
style={[
|
||||
styles.swatch,
|
||||
{
|
||||
backgroundColor: visible ? getCalendarColor(cal) : 'transparent',
|
||||
borderColor: getCalendarColor(cal),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{visible && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<Text style={[styles.rowName, !visible && styles.rowNameMuted]} numberOfLines={1}>
|
||||
{cal.name}
|
||||
</Text>
|
||||
{cal.isDefault && !cal.isShared && (
|
||||
<Star size={14} color={c.textMuted} fill={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.actionsPanel}>
|
||||
{canSetDefault && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onSetDefault!(cal); }}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Star size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>Set as default calendar</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canRecolor && (
|
||||
<>
|
||||
<View style={styles.paletteRow}>
|
||||
{CALENDAR_COLOR_PALETTE.map((color) => {
|
||||
const active = cal.color?.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
<Pressable
|
||||
key={color}
|
||||
onPress={() => { onExpand(null); onSetColor!(cal, color); }}
|
||||
style={[
|
||||
styles.paletteSwatch,
|
||||
{ backgroundColor: color },
|
||||
active && styles.paletteSwatchActive,
|
||||
]}
|
||||
>
|
||||
{active && <Check size={12} color={c.textInverse} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{!!onResetColor && cal.colorIsLocalOverride && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onResetColor(cal); }}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
>
|
||||
<Shuffle size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>Random color</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -279,6 +364,43 @@ function makeStyles(c: ThemePalette) {
|
||||
rowName: { flex: 1, ...typography.body, color: c.text },
|
||||
rowNameMuted: { color: c.textMuted },
|
||||
|
||||
actionsPanel: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
panelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 40,
|
||||
},
|
||||
panelRowText: { ...typography.body, color: c.text },
|
||||
paletteRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
paletteSwatch: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: radius.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
paletteSwatchActive: {
|
||||
borderWidth: 2,
|
||||
borderColor: c.text,
|
||||
},
|
||||
|
||||
actionsSection: {
|
||||
marginTop: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
|
||||
@@ -5,11 +5,17 @@ import { format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
|
||||
import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
|
||||
interface EventCardProps {
|
||||
event: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onPress?: (event: CalendarEvent) => void;
|
||||
onLongPress?: (event: CalendarEvent) => void;
|
||||
}
|
||||
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
|
||||
return event.participants ? Object.keys(event.participants).length : 0;
|
||||
}
|
||||
|
||||
function formatTimeRange(event: CalendarEvent): string {
|
||||
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) return 'All day';
|
||||
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
const fmt = timePattern(timeFormat);
|
||||
return `${format(start, fmt)} – ${format(end, fmt)}`;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
|
||||
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const color = getEventColor(event, calendars);
|
||||
const time = formatTimeRange(event);
|
||||
const time = formatTimeRange(event, timeFormat);
|
||||
const count = participantCount(event);
|
||||
|
||||
return (
|
||||
@@ -79,7 +86,8 @@ function makeStyles(c: ThemePalette) {
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderBottomRightRadius: radius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
eventTimeRange,
|
||||
getEventColor,
|
||||
getPrimaryCalendarId,
|
||||
timePattern,
|
||||
type TimeFormat,
|
||||
} from '../../lib/calendar-utils';
|
||||
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
interface EventDetailSheetProps {
|
||||
event: CalendarEvent | null;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onClose: () => void;
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
onDelete?: (event: CalendarEvent) => void;
|
||||
@@ -57,7 +60,7 @@ interface EventDetailSheetProps {
|
||||
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function formatRange(event: CalendarEvent): string {
|
||||
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
|
||||
const { start, end, allDay } = eventTimeRange(event);
|
||||
if (allDay) {
|
||||
if (
|
||||
@@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
|
||||
}
|
||||
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
|
||||
}
|
||||
const t = timePattern(timeFormat);
|
||||
const sameDay =
|
||||
start.getFullYear() === end.getFullYear() &&
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getDate() === end.getDate();
|
||||
if (sameDay) {
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
|
||||
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
|
||||
}
|
||||
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
|
||||
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
|
||||
}
|
||||
|
||||
function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
@@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null {
|
||||
export function EventDetailSheet({
|
||||
event,
|
||||
calendars,
|
||||
timeFormat,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -156,7 +161,7 @@ export function EventDetailSheet({
|
||||
const color = getEventColor(event, calendars);
|
||||
const calendarId = getPrimaryCalendarId(event);
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const range = formatRange(event);
|
||||
const range = formatRange(event, timeFormat);
|
||||
const recurrence = recurrenceLabel(event);
|
||||
const reminders = alertsToReminders(event.alerts);
|
||||
const participants = event.participants ? Object.values(event.participants) : [];
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
CalendarEvent,
|
||||
Participant,
|
||||
EventLocation,
|
||||
RecurrenceRule,
|
||||
VirtualLocation,
|
||||
} from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -49,10 +50,12 @@ import {
|
||||
REMINDER_PRESETS,
|
||||
type Reminder,
|
||||
} from '../../lib/calendar-alerts';
|
||||
import { buildRecurrenceSummary, isSimpleRecurrenceRule } from '../../lib/recurrence';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
|
||||
const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'none', label: 'Does not repeat' },
|
||||
@@ -60,6 +63,7 @@ const RECURRENCE_OPTIONS: { value: RecurrenceOption; label: string }[] = [
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'yearly', label: 'Yearly' },
|
||||
{ value: 'custom', label: 'Custom…' },
|
||||
];
|
||||
|
||||
interface EventModalProps {
|
||||
@@ -99,11 +103,15 @@ function buildDuration(start: Date, end: Date): string {
|
||||
function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption {
|
||||
const rule = event?.recurrenceRules?.[0];
|
||||
if (!rule) return 'none';
|
||||
const f = rule.frequency.toLowerCase();
|
||||
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
|
||||
return f as RecurrenceOption;
|
||||
if (isSimpleRecurrenceRule(rule)) {
|
||||
const f = rule.frequency.toLowerCase();
|
||||
if (f === 'daily' || f === 'weekly' || f === 'monthly' || f === 'yearly') {
|
||||
return f as RecurrenceOption;
|
||||
}
|
||||
}
|
||||
return 'none';
|
||||
// Anything the simple presets can't represent is edited via the custom
|
||||
// recurrence editor.
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function detectLocation(event: CalendarEvent | null | undefined): string {
|
||||
@@ -156,9 +164,18 @@ export function EventModal({
|
||||
const [allDay, setAllDay] = React.useState(false);
|
||||
const [start, setStart] = React.useState<Date>(() => nextHalfHour(defaultDate ?? new Date()));
|
||||
const [end, setEnd] = React.useState<Date>(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1));
|
||||
const [calendarId, setCalendarId] = React.useState<string>(defaultCalendarId || calendars[0]?.id || '');
|
||||
// New events land in the explicitly requested calendar, else the account's
|
||||
// default calendar, else the first one.
|
||||
const fallbackCalendarId =
|
||||
defaultCalendarId
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
||||
|| calendars[0]?.id
|
||||
|| '';
|
||||
const [calendarId, setCalendarId] = React.useState<string>(fallbackCalendarId);
|
||||
const [participants, setParticipants] = React.useState<Record<string, Participant>>({});
|
||||
const [recurrence, setRecurrence] = React.useState<RecurrenceOption>('none');
|
||||
const [customRule, setCustomRule] = React.useState<RecurrenceRule | null>(null);
|
||||
const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false);
|
||||
const [reminders, setReminders] = React.useState<Reminder[]>([]);
|
||||
const [location, setLocation] = React.useState('');
|
||||
const [videoUrl, setVideoUrl] = React.useState('');
|
||||
@@ -181,7 +198,10 @@ export function EventModal({
|
||||
setEnd(getEventEndDate(event));
|
||||
setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || '');
|
||||
setParticipants(event.participants || {});
|
||||
setRecurrence(detectRecurrence(event));
|
||||
const detected = detectRecurrence(event);
|
||||
setRecurrence(detected);
|
||||
setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders(alertsToReminders(event.alerts));
|
||||
setLocation(detectLocation(event));
|
||||
setVideoUrl(detectVideoUrl(event));
|
||||
@@ -192,9 +212,16 @@ export function EventModal({
|
||||
setAllDay(false);
|
||||
setStart(d);
|
||||
setEnd(addHours(d, 1));
|
||||
setCalendarId(defaultCalendarId || calendars[0]?.id || '');
|
||||
setCalendarId(
|
||||
defaultCalendarId
|
||||
|| calendars.find((cal) => cal.isDefault && !cal.isShared)?.id
|
||||
|| calendars[0]?.id
|
||||
|| '',
|
||||
);
|
||||
setParticipants({});
|
||||
setRecurrence('none');
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
setReminders([]);
|
||||
setLocation('');
|
||||
setVideoUrl('');
|
||||
@@ -241,7 +268,12 @@ export function EventModal({
|
||||
: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end),
|
||||
participants: Object.keys(participants).length > 0 ? participants : undefined,
|
||||
recurrenceRules: recurrenceFromOption(recurrence),
|
||||
recurrenceRules:
|
||||
recurrence === 'custom'
|
||||
? customRule
|
||||
? [customRule]
|
||||
: undefined
|
||||
: recurrenceFromOption(recurrence),
|
||||
alerts: remindersToAlerts(reminders),
|
||||
useDefaultAlerts: reminders.length > 0 ? false : undefined,
|
||||
locations: buildLocations(location),
|
||||
@@ -259,7 +291,10 @@ export function EventModal({
|
||||
};
|
||||
|
||||
const selectedCalendar = calendars.find((c) => c.id === calendarId);
|
||||
const recurrenceLabel = RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
|
||||
const recurrenceLabel =
|
||||
recurrence === 'custom'
|
||||
? (customRule && buildRecurrenceSummary(customRule)) || 'Custom'
|
||||
: RECURRENCE_OPTIONS.find((o) => o.value === recurrence)?.label || 'None';
|
||||
|
||||
const addReminder = (minutesBefore: number) => {
|
||||
setReminderPickerOpen(false);
|
||||
@@ -431,8 +466,14 @@ export function EventModal({
|
||||
opt.value === recurrence && styles.popoverRowActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRecurrence(opt.value);
|
||||
setRecurrenceOpen(false);
|
||||
if (opt.value === 'custom') {
|
||||
setRecurrenceEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
setRecurrence(opt.value);
|
||||
setCustomRule(null);
|
||||
setRecurrenceEditorOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
@@ -440,6 +481,23 @@ export function EventModal({
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{recurrenceEditorOpen && (
|
||||
<RecurrenceEditor
|
||||
rule={customRule}
|
||||
eventStart={start}
|
||||
onSave={(rule) => {
|
||||
setCustomRule(rule);
|
||||
setRecurrence('custom');
|
||||
setRecurrenceEditorOpen(false);
|
||||
}}
|
||||
onCancel={() => setRecurrenceEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{recurrence === 'custom' && customRule && !recurrenceEditorOpen && (
|
||||
<Pressable style={styles.addRow} onPress={() => setRecurrenceEditorOpen(true)}>
|
||||
<Text style={styles.addRowText}>Edit custom recurrence</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section icon={<Bell size={16} color={c.textMuted} />} label="Reminders">
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { RecurrenceRule } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button } from '..';
|
||||
import {
|
||||
EDITOR_FREQUENCIES,
|
||||
UNIT_LABELS,
|
||||
WEEKDAYS,
|
||||
buildRecurrenceSummary,
|
||||
buildRuleFromEditorValue,
|
||||
capitalize,
|
||||
editorValueFromRule,
|
||||
monthName,
|
||||
nthLabel,
|
||||
weekdayName,
|
||||
type EditorFrequency,
|
||||
type EndsMode,
|
||||
type MonthlyMode,
|
||||
type RecurrenceEditorValue,
|
||||
} from '../../lib/recurrence';
|
||||
|
||||
interface RecurrenceEditorProps {
|
||||
rule: RecurrenceRule | null;
|
||||
eventStart: Date;
|
||||
onSave: (rule: RecurrenceRule) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Small popover-style select, matching the EventModal field pattern.
|
||||
function InlineSelect<T extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
flex,
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (value: T) => void;
|
||||
flex?: boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
return (
|
||||
<View style={flex ? styles.selectFlex : undefined}>
|
||||
<Pressable style={styles.selectButton} onPress={() => setOpen((v) => !v)}>
|
||||
<Text style={styles.selectText} numberOfLines={1}>
|
||||
{selected?.label ?? String(value)}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
{open && (
|
||||
<View style={styles.popover}>
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={String(opt.value)}
|
||||
style={[styles.popoverRow, opt.value === value && styles.popoverRowActive]}
|
||||
onPress={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.popoverRowText}>{opt.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [text, setText] = React.useState(String(value));
|
||||
React.useEffect(() => setText(String(value)), [value]);
|
||||
return (
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
onEndEditing={() => {
|
||||
const n = parseInt(text, 10);
|
||||
const clamped = Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
|
||||
setText(String(clamped));
|
||||
onChange(clamped);
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
style={styles.numberInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
|
||||
editorValueFromRule(rule, eventStart),
|
||||
);
|
||||
const [showUntilPicker, setShowUntilPicker] = React.useState(false);
|
||||
|
||||
const update = (patch: Partial<RecurrenceEditorValue>) =>
|
||||
setValue((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const toggleWeekDay = (day: string) => {
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
weekDays: prev.weekDays.includes(day)
|
||||
? prev.weekDays.length > 1
|
||||
? prev.weekDays.filter((d) => d !== day)
|
||||
: prev.weekDays
|
||||
: [...prev.weekDays, day],
|
||||
}));
|
||||
};
|
||||
|
||||
const builtRule = buildRuleFromEditorValue(value, eventStart);
|
||||
const summary = buildRecurrenceSummary(builtRule);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Every</Text>
|
||||
<NumberInput
|
||||
value={value.interval}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(interval) => update({ interval })}
|
||||
/>
|
||||
<InlineSelect<EditorFrequency>
|
||||
flex
|
||||
value={value.frequency}
|
||||
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))}
|
||||
onChange={(frequency) => update({ frequency })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{value.frequency === 'weekly' && (
|
||||
<View style={styles.weekdayRow}>
|
||||
{WEEKDAYS.map((day) => {
|
||||
const active = value.weekDays.includes(day);
|
||||
return (
|
||||
<Pressable
|
||||
key={day}
|
||||
onPress={() => toggleWeekDay(day)}
|
||||
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
|
||||
>
|
||||
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
|
||||
{weekdayName(day, 'short')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{value.frequency === 'yearly' && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>In</Text>
|
||||
<InlineSelect<number>
|
||||
flex
|
||||
value={value.month}
|
||||
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
|
||||
value: m,
|
||||
label: capitalize(monthName(m)),
|
||||
}))}
|
||||
onChange={(month) => update({ month })}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(value.frequency === 'monthly' || value.frequency === 'yearly') && (
|
||||
<View style={styles.row}>
|
||||
<InlineSelect<MonthlyMode>
|
||||
value={value.monthlyMode}
|
||||
options={[
|
||||
{ value: 'day', label: 'On day' },
|
||||
{ value: 'nth', label: 'On the' },
|
||||
]}
|
||||
onChange={(monthlyMode) => update({ monthlyMode })}
|
||||
/>
|
||||
{value.monthlyMode === 'day' ? (
|
||||
<NumberInput
|
||||
value={value.monthDay}
|
||||
min={1}
|
||||
max={31}
|
||||
onChange={(monthDay) => update({ monthDay })}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<InlineSelect<number>
|
||||
flex
|
||||
value={value.nth}
|
||||
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))}
|
||||
onChange={(nth) => update({ nth })}
|
||||
/>
|
||||
<InlineSelect<string>
|
||||
flex
|
||||
value={value.nthDay}
|
||||
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))}
|
||||
onChange={(nthDay) => update({ nthDay })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Ends</Text>
|
||||
<InlineSelect<EndsMode>
|
||||
flex={value.endsMode === 'never'}
|
||||
value={value.endsMode}
|
||||
options={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: 'on', label: 'On date' },
|
||||
{ value: 'after', label: 'After' },
|
||||
]}
|
||||
onChange={(endsMode) => update({ endsMode })}
|
||||
/>
|
||||
{value.endsMode === 'on' && (
|
||||
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
|
||||
<Text style={styles.selectText}>
|
||||
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{value.endsMode === 'after' && (
|
||||
<>
|
||||
<NumberInput
|
||||
value={value.count}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(count) => update({ count })}
|
||||
/>
|
||||
<Text style={styles.muted}>occurrences</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.summary} numberOfLines={2}>
|
||||
{summary}
|
||||
</Text>
|
||||
<View style={styles.footerButtons}>
|
||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
|
||||
Done
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showUntilPicker && (
|
||||
<DateTimePicker
|
||||
value={parseISO(value.untilDate)}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={(_, d) => {
|
||||
setShowUntilPicker(false);
|
||||
if (d) update({ untilDate: format(d, 'yyyy-MM-dd') });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
rowLabel: { ...typography.body, color: c.textSecondary },
|
||||
muted: { ...typography.caption, color: c.textMuted, flexShrink: 1 },
|
||||
|
||||
numberInput: {
|
||||
minWidth: 52,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
selectFlex: { flex: 1, minWidth: 0 },
|
||||
selectButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minHeight: 36,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
selectText: { ...typography.body, color: c.text, flexShrink: 1 },
|
||||
|
||||
popover: {
|
||||
marginTop: spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.background,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
popoverRowActive: { backgroundColor: c.primaryBg },
|
||||
popoverRowText: { ...typography.body, color: c.text },
|
||||
|
||||
weekdayRow: { flexDirection: 'row', gap: 4 },
|
||||
weekdayChip: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
},
|
||||
weekdayChipActive: {
|
||||
borderColor: c.primary,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
weekdayChipText: { ...typography.caption, color: c.textMuted },
|
||||
weekdayChipTextActive: { color: c.primary },
|
||||
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
summary: { ...typography.caption, color: c.textMuted },
|
||||
footerButtons: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import type { ContactCard, Email, CalendarEvent } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { getEmails, queryEmailsByFilter } from '../../api/email';
|
||||
import { useCalendarStore } from '../../stores/calendar-store';
|
||||
import { getEventColor } from '../../lib/calendar-utils';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
@@ -87,6 +88,7 @@ export function ContactActivity({ contact }: Props) {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const calendarEvents = useCalendarStore((s) => s.events);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
|
||||
const addresses = React.useMemo(() => getContactEmails(contact), [contact]);
|
||||
const addressKey = addresses.join(',');
|
||||
@@ -145,74 +147,85 @@ export function ContactActivity({ contact }: Props) {
|
||||
<View style={styles.wrap}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MailIcon size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Recent emails</Text>
|
||||
</View>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
<View style={styles.sectionIcon}>
|
||||
<MailIcon size={16} color={c.textMuted} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
<Text style={styles.sectionLabel}>Recent emails</Text>
|
||||
</View>
|
||||
<View style={styles.sectionBody}>
|
||||
{emailsLoading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
) : emailsError ? (
|
||||
<Text style={styles.emptyText}>Couldn't load emails.</Text>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No emails with this contact.</Text>
|
||||
) : (
|
||||
emails.map((email) => {
|
||||
const sender = email.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'Unknown';
|
||||
return (
|
||||
<Pressable
|
||||
key={email.id}
|
||||
onPress={() =>
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
})
|
||||
}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<SenderAvatar name={senderName} email={sender?.email} size={32} />
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTop}>
|
||||
<Text style={styles.rowSender} numberOfLines={1}>{senderName}</Text>
|
||||
<Text style={styles.rowDate}>{formatRelativeDate(email.receivedAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.rowSubject} numberOfLines={1}>
|
||||
{email.subject || '(no subject)'}
|
||||
</Text>
|
||||
{email.preview ? (
|
||||
<Text style={styles.rowPreview} numberOfLines={1}>{email.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<CalendarDays size={14} color={c.primary} />
|
||||
<Text style={styles.sectionTitle}>Upcoming events</Text>
|
||||
<View style={styles.sectionIcon}>
|
||||
<CalendarDays size={16} color={c.textMuted} />
|
||||
</View>
|
||||
<Text style={styles.sectionLabel}>Upcoming events</Text>
|
||||
</View>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
<View style={styles.sectionBody}>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No upcoming events.</Text>
|
||||
) : (
|
||||
upcomingEvents.map((event) => (
|
||||
<View key={event.id} style={styles.eventRow}>
|
||||
<View
|
||||
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
|
||||
/>
|
||||
<Text style={styles.eventTime}>{formatEventTime(event)}</Text>
|
||||
<View style={styles.eventInfo}>
|
||||
<Text style={styles.eventTitle} numberOfLines={1}>
|
||||
{event.title || '(no title)'}
|
||||
</Text>
|
||||
<Text style={styles.eventDate}>
|
||||
{formatRelativeDate(event.utcStart || event.start)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -220,18 +233,18 @@ export function ContactActivity({ contact }: Props) {
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrap: { gap: spacing.md, paddingHorizontal: spacing.lg, marginBottom: spacing.lg },
|
||||
section: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: c.primary,
|
||||
wrap: { gap: spacing.lg },
|
||||
section: { gap: spacing.sm },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
sectionIcon: { width: 16 },
|
||||
sectionLabel: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
sectionTitle: { ...typography.bodySemibold, color: c.text },
|
||||
sectionBody: { paddingLeft: spacing.lg + spacing.xs, gap: spacing.xs },
|
||||
loading: { paddingVertical: spacing.sm, alignItems: 'flex-start' },
|
||||
emptyText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
@@ -251,10 +264,16 @@ function makeStyles(c: ThemePalette) {
|
||||
rowPreview: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
eventRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'baseline', paddingVertical: 4 },
|
||||
eventDot: {
|
||||
width: componentSizes.eventDot,
|
||||
height: componentSizes.eventDot,
|
||||
borderRadius: componentSizes.eventDot / 2,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
eventTime: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
width: 64,
|
||||
width: 52,
|
||||
},
|
||||
eventInfo: { flex: 1, minWidth: 0 },
|
||||
eventTitle: { ...typography.body, color: c.text },
|
||||
|
||||
@@ -23,7 +23,12 @@ export default function TagAssignSheet({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const keywords = useContactsStore(selectKeywordsUsed);
|
||||
// Subscribe to the stable `contacts` array and derive the keyword list in a
|
||||
// memo. Passing `selectKeywordsUsed` straight to the store would return a
|
||||
// fresh array on every call, which trips useSyncExternalStore's snapshot
|
||||
// check and spins into an infinite render loop.
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const keywords = React.useMemo(() => selectKeywordsUsed(contacts), [contacts]);
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
|
||||
|
||||
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
|
||||
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
type RolePreset = 'read' | 'readWrite' | 'manager';
|
||||
|
||||
// Same presets the webmail's share dialog offers (#408). "custom" rights set
|
||||
// by another client are shown as-is and left untouched until the user picks a
|
||||
// preset for that principal.
|
||||
const FILE_PRESETS: Record<RolePreset, FileNodeRights> = {
|
||||
read: {
|
||||
mayRead: true, mayAddChildren: false, mayRename: false,
|
||||
mayDelete: false, mayModifyContent: false, mayShare: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayRead: true, mayAddChildren: true, mayRename: true,
|
||||
mayDelete: true, mayModifyContent: true, mayShare: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true, mayAddChildren: true, mayRename: true,
|
||||
mayDelete: true, mayModifyContent: true, mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
const PRESET_LABELS: Record<RolePreset, string> = {
|
||||
read: 'Viewer',
|
||||
readWrite: 'Editor',
|
||||
manager: 'Manager',
|
||||
};
|
||||
|
||||
const PRESET_ORDER: RolePreset[] = ['read', 'readWrite', 'manager'];
|
||||
|
||||
function detectPreset(rights: FileNodeRights): RolePreset | 'custom' {
|
||||
for (const preset of PRESET_ORDER) {
|
||||
const expected = FILE_PRESETS[preset];
|
||||
const keys = Object.keys(expected) as (keyof FileNodeRights)[];
|
||||
if (keys.every((k) => expected[k] === (rights[k] ?? false))) return preset;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
interface ShareSheetProps {
|
||||
node: FileNode | null;
|
||||
onClose: () => void;
|
||||
/** Fired after any successful share change so the parent can refresh. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(false);
|
||||
const [shares, setShares] = useState<Record<string, FileNodeRights>>({});
|
||||
const [search, setSearch] = useState('');
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
setShares(node.shareWith ?? {});
|
||||
setSearch('');
|
||||
setLoadingPrincipals(true);
|
||||
getPrincipals()
|
||||
.then(setPrincipals)
|
||||
.catch(() => setPrincipals([]))
|
||||
.finally(() => setLoadingPrincipals(false));
|
||||
}, [node]);
|
||||
|
||||
const principalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of principals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [principals]);
|
||||
|
||||
const sharedEntries = useMemo(
|
||||
() => Object.entries(shares).filter(([, rights]) => rights != null),
|
||||
[shares],
|
||||
);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const self = ownPrincipalId();
|
||||
const q = search.trim().toLowerCase();
|
||||
return principals
|
||||
.filter((p) => p.id !== self && shares[p.id] == null)
|
||||
.filter((p) =>
|
||||
!q ||
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.email?.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q))
|
||||
.slice(0, 25);
|
||||
}, [principals, shares, search]);
|
||||
|
||||
const applyShare = useCallback(
|
||||
async (principalId: string, rights: FileNodeRights | null) => {
|
||||
if (!node || savingId) return;
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await setFileNodeShare(node.id, principalId, rights);
|
||||
setShares((prev) => {
|
||||
const next = { ...prev };
|
||||
if (rights == null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return next;
|
||||
});
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
Alert.alert('Sharing failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
},
|
||||
[node, savingId, onChanged],
|
||||
);
|
||||
|
||||
if (!node) return null;
|
||||
const Icon = isFolder(node) ? Folder : FileText;
|
||||
|
||||
const renderPrincipalLabel = (principalId: string) => {
|
||||
const p = principalsById.get(principalId);
|
||||
return (
|
||||
<View style={styles.principalInfo}>
|
||||
<Text style={styles.principalName} numberOfLines={1}>
|
||||
{p?.description || p?.name || principalId}
|
||||
</Text>
|
||||
{p?.email ? (
|
||||
<Text style={styles.principalEmail} numberOfLines={1}>
|
||||
{p.email}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.backdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Icon size={18} color={c.textMuted} />
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Share “{node.name}”
|
||||
</Text>
|
||||
</View>
|
||||
{isFolder(node) ? (
|
||||
<Text style={styles.subtitle}>
|
||||
Sharing a folder shares everything inside it.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{sharedEntries.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Shared with</Text>
|
||||
{sharedEntries.map(([principalId, rights]) => {
|
||||
const preset = detectPreset(rights);
|
||||
const busy = savingId === principalId;
|
||||
return (
|
||||
<View key={principalId} style={styles.shareRow}>
|
||||
{renderPrincipalLabel(principalId)}
|
||||
<View style={styles.roleChips}>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<Pressable
|
||||
key={p}
|
||||
onPress={() => void applyShare(principalId, FILE_PRESETS[p])}
|
||||
disabled={busy}
|
||||
style={[styles.chip, preset === p && styles.chipActive]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.chipText, preset === p && styles.chipTextActive]}
|
||||
>
|
||||
{PRESET_LABELS[p]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{preset === 'custom' ? (
|
||||
<Text style={styles.customLabel}>Custom</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => void applyShare(principalId, null)}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Remove access"
|
||||
>
|
||||
<Trash2 size={18} color={c.error} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
<Text style={styles.emptyText}>Not shared with anyone yet</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>Add people</Text>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search by name or email"
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{loadingPrincipals ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={c.textMuted}
|
||||
style={{ marginTop: spacing.sm }}
|
||||
/>
|
||||
) : candidates.length === 0 ? (
|
||||
<Text style={styles.noMatches}>
|
||||
{principals.length === 0 ? 'No other users found' : 'No matches'}
|
||||
</Text>
|
||||
) : (
|
||||
candidates.map((p) => (
|
||||
<Pressable
|
||||
key={p.id}
|
||||
onPress={() => void applyShare(p.id, FILE_PRESETS.read)}
|
||||
disabled={savingId != null}
|
||||
style={({ pressed }) => [
|
||||
styles.candidateRow,
|
||||
pressed && { backgroundColor: c.surfaceHover },
|
||||
]}
|
||||
>
|
||||
{renderPrincipalLabel(p.id)}
|
||||
{savingId === p.id ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<UserPlus size={18} color={c.primary} />
|
||||
)}
|
||||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Pressable onPress={onClose} style={styles.doneBtn}>
|
||||
<Text style={styles.doneText}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xxl,
|
||||
paddingHorizontal: spacing.md,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
...typography.h3,
|
||||
color: c.text,
|
||||
flexShrink: 1,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
scroll: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
fontWeight: '600' as const,
|
||||
color: c.textMuted,
|
||||
textTransform: 'uppercase' as const,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
shareRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
principalInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
principalName: {
|
||||
fontSize: typography.body.fontSize,
|
||||
color: c.text,
|
||||
},
|
||||
principalEmail: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
roleChips: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: c.primaryBg,
|
||||
borderColor: c.primary,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
chipTextActive: {
|
||||
color: c.primary,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
customLabel: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
fontStyle: 'italic' as const,
|
||||
},
|
||||
emptyRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.body.fontSize,
|
||||
color: c.textMuted,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: c.text,
|
||||
fontSize: typography.body.fontSize,
|
||||
backgroundColor: c.surface,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
noMatches: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
candidateRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
doneBtn: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
doneText: {
|
||||
color: c.primaryForeground,
|
||||
fontWeight: '600' as const,
|
||||
fontSize: typography.body.fontSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, Plus, Trash2 } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Select, ToggleSwitch } from '../settings/settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
||||
import { buildMailboxTree, type MailboxNode } from '../../lib/mailbox-tree';
|
||||
import { generateUUID } from '../../lib/uuid';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
import type {
|
||||
FilterRule,
|
||||
FilterCondition,
|
||||
FilterAction,
|
||||
FilterConditionField,
|
||||
FilterComparator,
|
||||
FilterActionType,
|
||||
} from '../../lib/sieve/types';
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body'];
|
||||
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = ['move', 'copy', 'forward', 'mark_read', 'star', 'add_label', 'discard', 'reject', 'keep', 'stop'];
|
||||
|
||||
const ACTIONS_WITH_VALUE = new Set<FilterActionType>(['move', 'copy', 'forward', 'reject', 'add_label']);
|
||||
const ACTIONS_WITH_MAILBOX = new Set<FilterActionType>(['move', 'copy']);
|
||||
|
||||
function makeEmptyCondition(): FilterCondition {
|
||||
return { field: 'from', comparator: 'contains', value: '' };
|
||||
}
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: 'move', value: '' };
|
||||
}
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
visible: boolean;
|
||||
rule?: FilterRule;
|
||||
mailboxes: Mailbox[];
|
||||
onSave: (rule: FilterRule) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Flatten the mailbox tree into pickable options, building the Sieve-canonical
|
||||
// folder path for each (inbox -> "INBOX", not the localized display name).
|
||||
function buildMailboxOptions(mailboxes: Mailbox[]): { value: string; label: string }[] {
|
||||
const tree = buildMailboxTree(mailboxes);
|
||||
const options: { value: string; label: string }[] = [];
|
||||
const walk = (nodes: MailboxNode[], parentPath: string) => {
|
||||
for (const node of nodes) {
|
||||
const segment = node.role === 'inbox' ? 'INBOX' : node.name;
|
||||
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
options.push({ value: fullPath, label: `${' '.repeat(node.depth * 3)}${node.name}` });
|
||||
if (node.children.length > 0) walk(node.children, fullPath);
|
||||
}
|
||||
};
|
||||
walk(tree, '');
|
||||
return options;
|
||||
}
|
||||
|
||||
export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: FilterRuleModalProps) {
|
||||
const c = useColors();
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const keywords = useKeywordsStore((s) => s.keywords);
|
||||
const isEdit = !!rule;
|
||||
|
||||
const [name, setName] = useState(rule?.name || '');
|
||||
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>(
|
||||
rule?.conditions.length ? [...rule.conditions] : [makeEmptyCondition()],
|
||||
);
|
||||
const [actions, setActions] = useState<FilterAction[]>(
|
||||
rule?.actions.length ? [...rule.actions] : [makeEmptyAction()],
|
||||
);
|
||||
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false);
|
||||
|
||||
const mailboxOptions = useMemo(() => buildMailboxOptions(mailboxes), [mailboxes]);
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
|
||||
[t],
|
||||
);
|
||||
const actionTypeOptions = useMemo(
|
||||
() => ALL_ACTION_TYPES.map((a) => ({ value: a, label: t(`settings.filters.action_types.${a}`, a) })),
|
||||
[t],
|
||||
);
|
||||
const keywordOptions = useMemo(
|
||||
() => keywords.map((kw) => ({ value: kw.id, label: kw.label })),
|
||||
[keywords],
|
||||
);
|
||||
|
||||
const comparatorOptions = useCallback(
|
||||
(field: FilterConditionField) =>
|
||||
(field === 'size' ? SIZE_COMPARATORS : TEXT_COMPARATORS).map((cmp) => ({
|
||||
value: cmp,
|
||||
label: t(`settings.filters.comparators.${cmp}`, cmp),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const updateCondition = (index: number, updates: Partial<FilterCondition>) => {
|
||||
setConditions((prev) =>
|
||||
prev.map((cond, i) => {
|
||||
if (i !== index) return cond;
|
||||
const updated = { ...cond, ...updates };
|
||||
if (updates.field === 'size' && !SIZE_COMPARATORS.includes(cond.comparator)) {
|
||||
updated.comparator = 'greater_than';
|
||||
}
|
||||
if (updates.field && updates.field !== 'size' && SIZE_COMPARATORS.includes(cond.comparator)) {
|
||||
updated.comparator = 'contains';
|
||||
}
|
||||
if (updates.field && updates.field !== 'header') {
|
||||
delete updated.headerName;
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeCondition = (index: number) => {
|
||||
if (conditions.length <= 1) return;
|
||||
setConditions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateAction = (index: number, updates: Partial<FilterAction>) => {
|
||||
setActions((prev) =>
|
||||
prev.map((act, i) => {
|
||||
if (i !== index) return act;
|
||||
const updated = { ...act, ...updates };
|
||||
if (updates.type && !ACTIONS_WITH_VALUE.has(updates.type)) {
|
||||
delete updated.value;
|
||||
}
|
||||
if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) {
|
||||
updated.value = mailboxOptions[0]?.value ?? '';
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeAction = (index: number) => {
|
||||
if (actions.length <= 1) return;
|
||||
setActions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
Alert.alert(t('settings.filters.validation_empty_name', 'Rule name is required'));
|
||||
return;
|
||||
}
|
||||
const validConditions = conditions.filter((cond) => cond.value.trim());
|
||||
if (validConditions.length === 0) {
|
||||
Alert.alert(t('settings.filters.validation_empty_conditions', 'At least one condition with a value is required'));
|
||||
return;
|
||||
}
|
||||
const validActions = actions.filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim());
|
||||
if (validActions.length === 0) {
|
||||
Alert.alert(t('settings.filters.validation_empty_actions', 'At least one action is required'));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
id: rule?.id || generateUUID(),
|
||||
name: trimmedName,
|
||||
enabled: rule?.enabled ?? true,
|
||||
matchType,
|
||||
conditions: validConditions,
|
||||
actions: validActions,
|
||||
stopProcessing,
|
||||
});
|
||||
}, [name, conditions, actions, matchType, stopProcessing, rule, onSave, t]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerClose}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>
|
||||
{isEdit ? t('settings.filters.edit_rule', 'Edit Rule') : t('settings.filters.new_rule', 'New Rule')}
|
||||
</Text>
|
||||
<View style={styles.headerRightSpacer} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
|
||||
{/* Name */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.rule_name', 'Rule Name')}</Text>
|
||||
<Input
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t('settings.filters.rule_name_placeholder', 'e.g., Sort newsletters')}
|
||||
maxLength={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Match type */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.match_type', 'Match type')}</Text>
|
||||
<View style={styles.matchRow}>
|
||||
{(['all', 'any'] as const).map((mt) => {
|
||||
const selected = matchType === mt;
|
||||
return (
|
||||
<Pressable
|
||||
key={mt}
|
||||
onPress={() => setMatchType(mt)}
|
||||
style={[styles.matchBtn, selected ? styles.matchBtnOn : styles.matchBtnOff]}
|
||||
>
|
||||
<Text style={selected ? styles.matchTextOn : styles.matchTextOff}>
|
||||
{mt === 'all'
|
||||
? t('settings.filters.match_all', 'Match ALL conditions')
|
||||
: t('settings.filters.match_any', 'Match ANY condition')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Conditions */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.conditions', 'Conditions')}</Text>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{conditions.map((condition, index) => (
|
||||
<View key={index} style={styles.card}>
|
||||
<View style={styles.cardTopRow}>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onChange={(v) => updateCondition(index, { field: v as FilterConditionField })}
|
||||
options={fieldOptions}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => removeCondition(index)}
|
||||
disabled={conditions.length <= 1}
|
||||
hitSlop={8}
|
||||
style={[styles.removeBtn, conditions.length <= 1 && styles.removeBtnDisabled]}
|
||||
>
|
||||
<Trash2 size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{condition.field === 'header' && (
|
||||
<Input
|
||||
value={condition.headerName || ''}
|
||||
onChangeText={(v) => updateCondition(index, { headerName: v })}
|
||||
placeholder={t('settings.filters.header_name', 'Header name')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={condition.comparator}
|
||||
onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })}
|
||||
options={comparatorOptions(condition.field)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChangeText={(v) => updateCondition(index, { value: v })}
|
||||
placeholder={
|
||||
condition.field === 'size'
|
||||
? t('settings.filters.size_placeholder', 'e.g., 1000000')
|
||||
: t('settings.filters.header_placeholder', 'Value')
|
||||
}
|
||||
keyboardType={condition.field === 'size' ? 'numeric' : 'default'}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setConditions((prev) => [...prev, makeEmptyCondition()])}
|
||||
style={styles.addRow}
|
||||
>
|
||||
<Plus size={14} color={c.primary} />
|
||||
<Text style={styles.addText}>{t('settings.filters.add_condition', 'Add Condition')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View>
|
||||
<Text style={styles.label}>{t('settings.filters.actions', 'Actions')}</Text>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{actions.map((action, index) => (
|
||||
<View key={index} style={styles.card}>
|
||||
<View style={styles.cardTopRow}>
|
||||
<Select
|
||||
value={action.type}
|
||||
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
|
||||
options={actionTypeOptions}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => removeAction(index)}
|
||||
disabled={actions.length <= 1}
|
||||
hitSlop={8}
|
||||
style={[styles.removeBtn, actions.length <= 1 && styles.removeBtnDisabled]}
|
||||
>
|
||||
<Trash2 size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{ACTIONS_WITH_MAILBOX.has(action.type) && (
|
||||
mailboxOptions.length > 0 ? (
|
||||
<Select
|
||||
value={action.value || ''}
|
||||
onChange={(v) => updateAction(index, { value: v })}
|
||||
options={mailboxOptions}
|
||||
style={{ alignSelf: 'stretch' }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.move_to_folder', 'Select folder')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{action.type === 'forward' && (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.forward_placeholder', 'email@example.com')}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{action.type === 'reject' && (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.reject_placeholder', 'Your email has been rejected')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{action.type === 'add_label' && (
|
||||
keywordOptions.length > 0 ? (
|
||||
<Select
|
||||
value={action.value || ''}
|
||||
onChange={(v) => updateAction(index, { value: v })}
|
||||
options={keywordOptions}
|
||||
style={{ alignSelf: 'stretch' }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={action.value || ''}
|
||||
onChangeText={(v) => updateAction(index, { value: v })}
|
||||
placeholder={t('settings.filters.label_placeholder', 'Select tag')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setActions((prev) => [...prev, makeEmptyAction()])}
|
||||
style={styles.addRow}
|
||||
>
|
||||
<Plus size={14} color={c.primary} />
|
||||
<Text style={styles.addText}>{t('settings.filters.add_action', 'Add Action')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Stop processing */}
|
||||
<View style={styles.stopRow}>
|
||||
<Text style={styles.stopLabel}>
|
||||
{t('settings.filters.stop_processing', 'Stop processing subsequent rules')}
|
||||
</Text>
|
||||
<ToggleSwitch checked={stopProcessing} onChange={setStopProcessing} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button variant="outline" onPress={onClose}>
|
||||
{t('settings.filters.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onPress={handleSave} disabled={!name.trim()}>
|
||||
{t('settings.filters.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerClose: {
|
||||
width: 40, height: 40,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1, textAlign: 'center' },
|
||||
headerRightSpacer: { width: 40 },
|
||||
|
||||
body: { padding: spacing.lg, gap: spacing.xl, paddingBottom: spacing.xxxl },
|
||||
label: { ...typography.bodyMedium, color: c.text, marginBottom: spacing.sm },
|
||||
|
||||
matchRow: { flexDirection: 'row', gap: spacing.sm },
|
||||
matchBtn: { paddingHorizontal: spacing.md, paddingVertical: 8, borderRadius: radius.sm },
|
||||
matchBtnOn: { backgroundColor: c.primary },
|
||||
matchBtnOff: { backgroundColor: c.muted },
|
||||
matchTextOn: { ...typography.caption, color: c.primaryForeground, fontWeight: '500' },
|
||||
matchTextOff: { ...typography.caption, color: c.text },
|
||||
|
||||
card: {
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
cardTopRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
removeBtn: {
|
||||
width: 36, height: 36,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
removeBtnDisabled: { opacity: 0.3 },
|
||||
|
||||
addRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: spacing.sm },
|
||||
addText: { ...typography.body, color: c.primary, fontWeight: '500' },
|
||||
|
||||
stopRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
stopLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, AlertTriangle, CheckCircle } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import Button from '../Button';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
interface SieveEditorSheetProps {
|
||||
visible: boolean;
|
||||
content: string;
|
||||
onSave: (content: string) => void;
|
||||
onClose: () => void;
|
||||
onValidate: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
}
|
||||
|
||||
export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate }: SieveEditorSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const [script, setScript] = useState(content);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{ isValid: boolean; errors?: string[] } | null>(null);
|
||||
const [showSaveWarning, setShowSaveWarning] = useState(false);
|
||||
|
||||
// Re-seed the editor whenever it's (re)opened with fresh content. Keying the
|
||||
// editor on `content` while closed avoids a stale draft on the next open.
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setScript(content);
|
||||
setValidationResult(null);
|
||||
setShowSaveWarning(false);
|
||||
}
|
||||
}, [visible, content]);
|
||||
|
||||
const lineCount = script.split('\n').length;
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
try {
|
||||
const result = await onValidate(script);
|
||||
setValidationResult(result);
|
||||
} catch {
|
||||
setValidationResult({ isValid: false, errors: [t('settings.filters.sieve_editor.validation_failed', 'Validation request failed')] });
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [script, onValidate, t]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!showSaveWarning) {
|
||||
setShowSaveWarning(true);
|
||||
return;
|
||||
}
|
||||
onSave(script);
|
||||
}, [script, showSaveWarning, onSave]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerClose}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{t('settings.filters.sieve_editor.title', 'Sieve Script Editor')}</Text>
|
||||
<View style={styles.headerRightSpacer} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<View style={styles.warningBanner}>
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
{t('settings.filters.sieve_editor.warning', 'Editing the raw Sieve script may break visual rule editing. Changes made here override the visual builder.')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.editorWrap} contentContainerStyle={styles.editorContent}>
|
||||
<TextInput
|
||||
value={script}
|
||||
onChangeText={(v) => {
|
||||
setScript(v);
|
||||
setValidationResult(null);
|
||||
setShowSaveWarning(false);
|
||||
}}
|
||||
style={styles.editor}
|
||||
multiline
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
textAlignVertical="top"
|
||||
placeholder={t('settings.filters.sieve_editor.script_content', 'Sieve script')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
accessibilityLabel={t('settings.filters.sieve_editor.script_content', 'Sieve script')}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<Text style={styles.lineCount}>{lineCount} {lineCount === 1 ? 'line' : 'lines'}</Text>
|
||||
|
||||
{validationResult && (
|
||||
<View style={[styles.resultBanner, validationResult.isValid ? styles.resultOk : styles.resultErr]}>
|
||||
{validationResult.isValid ? (
|
||||
<>
|
||||
<CheckCircle size={16} color={c.success} />
|
||||
<Text style={[styles.resultText, { color: c.success }]}>
|
||||
{t('settings.filters.sieve_editor.valid', 'Script is valid')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle size={16} color={c.error} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.resultText, { color: c.error, fontWeight: '600' }]}>
|
||||
{t('settings.filters.sieve_editor.invalid', 'Script has errors')}
|
||||
</Text>
|
||||
{validationResult.errors?.map((err, i) => (
|
||||
<Text key={i} style={styles.errorDetail}>{err}</Text>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showSaveWarning && (
|
||||
<View style={styles.warningBanner}>
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
{t('settings.filters.sieve_editor.save_warning', 'Saving will overwrite any visual rules. This cannot be undone. Tap Save again to confirm.')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={handleValidate}
|
||||
disabled={isValidating || !script.trim()}
|
||||
icon={isValidating ? <ActivityIndicator size="small" color={c.text} /> : undefined}
|
||||
>
|
||||
{isValidating
|
||||
? t('settings.filters.sieve_editor.validating', 'Validating...')
|
||||
: t('settings.filters.sieve_editor.validate', 'Validate')}
|
||||
</Button>
|
||||
<View style={styles.footerRight}>
|
||||
<Button variant="outline" onPress={onClose}>
|
||||
{t('settings.filters.sieve_editor.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onPress={handleSave} disabled={!script.trim()}>
|
||||
{showSaveWarning
|
||||
? t('settings.filters.sieve_editor.confirm_save', 'Confirm Save')
|
||||
: t('settings.filters.sieve_editor.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerClose: {
|
||||
width: 40, height: 40,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1, textAlign: 'center' },
|
||||
headerRightSpacer: { width: 40 },
|
||||
|
||||
body: { flex: 1, padding: spacing.lg, gap: spacing.md },
|
||||
|
||||
warningBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
warningText: { ...typography.caption, color: '#fde68a', flex: 1 },
|
||||
|
||||
editorWrap: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
editorContent: { flexGrow: 1 },
|
||||
editor: {
|
||||
flex: 1,
|
||||
minHeight: 240,
|
||||
padding: spacing.md,
|
||||
color: c.text,
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
lineCount: { ...typography.caption, color: c.mutedForeground, textAlign: 'right' },
|
||||
|
||||
resultBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
resultOk: { backgroundColor: c.successBg, borderColor: c.success },
|
||||
resultErr: { backgroundColor: c.errorBg, borderColor: c.errorBorder },
|
||||
resultText: { ...typography.caption },
|
||||
errorDetail: {
|
||||
...typography.caption,
|
||||
color: c.error,
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }),
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
footerRight: { flexDirection: 'row', gap: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -5,3 +5,5 @@ export { default as Badge } from './Badge';
|
||||
export { Card, SectionHeader, SettingItemRow } from './Card';
|
||||
export { default as RadioGroup } from './RadioGroup';
|
||||
export { default as Dialog } from './Dialog';
|
||||
export { IdentitySheet } from './IdentitySheet';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||
import { useOutboxStore } from '../../stores/outbox-store';
|
||||
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
@@ -22,6 +23,14 @@ const DAY_OPTIONS = [
|
||||
{ value: '90', label: 'Last 90 days' },
|
||||
];
|
||||
|
||||
const MAX_MB_OPTIONS = [
|
||||
{ value: '25', label: '25 MB' },
|
||||
{ value: '50', label: '50 MB' },
|
||||
{ value: '100', label: '100 MB' },
|
||||
{ value: '250', label: '250 MB' },
|
||||
{ value: '500', label: '500 MB' },
|
||||
];
|
||||
|
||||
function formatRelativeTime(ms: number | undefined): string {
|
||||
if (!ms) return 'never';
|
||||
const diff = Date.now() - ms;
|
||||
@@ -58,7 +67,10 @@ export function AboutDataSettings() {
|
||||
|
||||
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const queuedChanges = useOutboxStore((s) => s.entries.length);
|
||||
const flushOutbox = useOutboxStore((s) => s.flush);
|
||||
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
|
||||
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
|
||||
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
|
||||
@@ -73,7 +85,9 @@ export function AboutDataSettings() {
|
||||
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
|
||||
|
||||
const handleSyncNow = () => {
|
||||
void runOfflineSync({ days: offlineDays });
|
||||
void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB });
|
||||
// Also drain any queued offline changes while we're at it.
|
||||
void flushOutbox();
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
@@ -142,6 +156,16 @@ export function AboutDataSettings() {
|
||||
options={DAY_OPTIONS}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Maximum size"
|
||||
description="Oldest messages are removed when the cache grows past this."
|
||||
>
|
||||
<Select
|
||||
value={String(offlineMaxMB)}
|
||||
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
|
||||
options={MAX_MB_OPTIONS}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.cacheStatsBox}>
|
||||
<View style={styles.cacheStatsHeader}>
|
||||
@@ -161,6 +185,11 @@ export function AboutDataSettings() {
|
||||
? `Last sync failed: ${sync.message ?? 'unknown error'}`
|
||||
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
|
||||
</Text>
|
||||
{queuedChanges > 0 && (
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{`${queuedChanges} change${queuedChanges === 1 ? '' : 's'} waiting to sync`}
|
||||
</Text>
|
||||
)}
|
||||
{syncBusy && sync.total > 0 && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import {
|
||||
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
|
||||
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
@@ -13,45 +17,44 @@ import {
|
||||
isClientCertSupported,
|
||||
pickClientCertAlias,
|
||||
} from '../../lib/client-cert';
|
||||
import {
|
||||
isStalwartSupported,
|
||||
fetchAuthInfo,
|
||||
fetchEncryptionType,
|
||||
fetchPrincipal,
|
||||
changePassword,
|
||||
updateDisplayName,
|
||||
enableTotp,
|
||||
disableTotp,
|
||||
createAppPassword,
|
||||
removeAppPassword,
|
||||
createApiKey,
|
||||
removeApiKey,
|
||||
type AppCredentialInfo,
|
||||
type AppCredentialInput,
|
||||
type AuthInfo,
|
||||
type EncryptionType,
|
||||
} from '../../api/account-security';
|
||||
import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
device: string;
|
||||
location: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
const MOCK_SESSIONS: Session[] = [
|
||||
{ id: '1', device: 'iPhone 15 Pro - Bulwark Mobile', location: 'Berlin, DE', current: true },
|
||||
{ id: '2', device: 'Chrome on macOS', location: 'Berlin, DE', current: false },
|
||||
];
|
||||
|
||||
export function AccountSecuritySettings() {
|
||||
// ── TLS client certificate (mobile-only) ──────────────────
|
||||
// Picks an installed Android cert for mTLS handshakes. Independent of the
|
||||
// Stalwart account features below, so it stays visible even on non-Stalwart
|
||||
// servers.
|
||||
function ClientCertSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [currentPwd, setCurrentPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
|
||||
|
||||
const certSupported = isClientCertSupported();
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certSupported) return;
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, [certSupported]);
|
||||
}, []);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
// Hint the picker with the JMAP server host so it can pre-filter to certs
|
||||
// issued for it when the OEM picker honours that.
|
||||
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
|
||||
const alias = await pickClientCertAlias(host);
|
||||
setCertAlias(alias);
|
||||
@@ -62,7 +65,7 @@ export function AccountSecuritySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const onClearCert = async () => {
|
||||
const onClearCert = () => {
|
||||
if (certBusy) return;
|
||||
Alert.alert(
|
||||
'Stop using client certificate?',
|
||||
@@ -87,218 +90,644 @@ export function AccountSecuritySettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && (
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>
|
||||
{certAlias ? `Active: ${certAlias}` : 'Not set'}
|
||||
</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.
|
||||
</Text>
|
||||
</View>
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>{certAlias ? `Active: ${certAlias}` : 'Not set'}</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.rowGap}>
|
||||
<Button variant="outline" size="sm" disabled={certBusy} onPress={() => { void onPickCert(); }}>
|
||||
{certAlias ? 'Choose another' : 'Pick certificate'}
|
||||
</Button>
|
||||
{certAlias && (
|
||||
<Button variant="outline" size="sm" disabled={certBusy} onPress={onClearCert}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Password change ───────────────────────────────────────
|
||||
function PasswordChangeSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [currentPwd, setCurrentPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (newPwd.length < 8) { setError('New password must be at least 8 characters.'); return; }
|
||||
if (newPwd !== confirmPwd) { setError('Passwords do not match.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await changePassword(currentPwd, newPwd);
|
||||
setCurrentPwd(''); setNewPwd(''); setConfirmPwd('');
|
||||
Alert.alert('Password changed', 'Your account password was updated.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Current password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input value={currentPwd} onChangeText={setCurrentPwd} secureTextEntry={!showCurrent} autoCapitalize="none" containerStyle={{ flex: 1 }} />
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
||||
{showCurrent ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.certActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onPickCert(); }}
|
||||
>
|
||||
{certAlias ? 'Choose another' : 'Pick certificate'}
|
||||
</View>
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>New password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input value={newPwd} onChangeText={setNewPwd} secureTextEntry={!showNew} autoCapitalize="none" containerStyle={{ flex: 1 }} />
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
||||
{showNew ? <EyeOff size={16} color={c.mutedForeground} /> : <Eye size={16} color={c.mutedForeground} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Confirm password</Text>
|
||||
<Input value={confirmPwd} onChangeText={setConfirmPwd} secureTextEntry={!showNew} autoCapitalize="none" />
|
||||
</View>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={saving}
|
||||
disabled={saving || !currentPwd || !newPwd || !confirmPwd}
|
||||
onPress={() => { void submit(); }}
|
||||
icon={<Key size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Display name ──────────────────────────────────────────
|
||||
function DisplayNameSection({ initial, onSaved }: { initial: string; onSaved: (name: string) => void }) {
|
||||
const c = useColors();
|
||||
const [name, setName] = useState(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => { setName(initial); }, [initial]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDisplayName(name);
|
||||
onSaved(name);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (err) {
|
||||
Alert.alert('Update failed', err instanceof Error ? err.message : 'Failed to update display name.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingItem label="Display name" description="The name shown on this account.">
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
||||
<Input value={name} onChangeText={setName} placeholder="Display name" containerStyle={{ width: 150 }} />
|
||||
<Button size="sm" loading={saving} disabled={saving || name === initial} onPress={() => { void save(); }}>
|
||||
{saved ? <Check size={14} color={c.primaryForeground} /> : 'Save'}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────
|
||||
function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () => void }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [enrolment, setEnrolment] = useState<TotpEnrolment | null>(null);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setEnrolment(null); setDisableOpen(false);
|
||||
setPassword(''); setOtpCode(''); setError(null);
|
||||
};
|
||||
|
||||
const handleToggle = (enable: boolean) => {
|
||||
setError(''); setPassword(''); setOtpCode('');
|
||||
if (enable) {
|
||||
setEnrolment(generateTotpEnrolment(jmapClient.username ?? 'account'));
|
||||
setDisableOpen(false);
|
||||
} else {
|
||||
setEnrolment(null);
|
||||
setDisableOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEnable = async () => {
|
||||
if (!enrolment) return;
|
||||
if (!password) { setError('Enter your current password.'); return; }
|
||||
if (!otpCode.trim()) { setError('Enter the 6-digit code from your app.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await enableTotp(password, enrolment.url, otpCode.trim());
|
||||
reset();
|
||||
onChanged();
|
||||
Alert.alert('Two-factor enabled', 'You will be asked for a code at next sign-in.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to enable two-factor authentication.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDisable = async () => {
|
||||
if (!password) { setError('Enter your current password.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await disableTotp(password);
|
||||
reset();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to disable two-factor authentication.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRow}>
|
||||
<Shield size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Two-Factor Authentication</Text>
|
||||
</View>
|
||||
|
||||
<SettingItem label="Authenticator app" description="Require a one-time code at login." noBorder>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
||||
<ToggleSwitch checked={enabled || !!enrolment} onChange={handleToggle} disabled={saving} />
|
||||
<Text style={[styles.statusText, { color: enabled ? c.success : c.mutedForeground }]}>
|
||||
{enabled ? 'Active' : 'Inactive'}
|
||||
</Text>
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{enrolment && (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelHint}>
|
||||
Add this secret to your authenticator app, then enter the 6-digit code to confirm.
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<ExternalLink size={14} color={c.text} />}
|
||||
onPress={() => { void Linking.openURL(enrolment.url).catch(() => Alert.alert('No authenticator app', 'Could not open an authenticator app. Add the secret below manually.')); }}
|
||||
>
|
||||
Add to authenticator app
|
||||
</Button>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Or enter this secret manually</Text>
|
||||
<Text selectable style={styles.secretText}>{enrolment.secretFormatted}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Current password</Text>
|
||||
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Verification code</Text>
|
||||
<Input value={otpCode} onChangeText={setOtpCode} keyboardType="number-pad" maxLength={6} />
|
||||
</View>
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<View style={styles.rowGap}>
|
||||
<Button size="sm" loading={saving} disabled={saving || !password || !otpCode} onPress={() => { void confirmEnable(); }}>
|
||||
Confirm
|
||||
</Button>
|
||||
{certAlias && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onClearCert(); }}
|
||||
>
|
||||
Clear
|
||||
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{disableOpen && (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelHint}>Enter your password to turn off two-factor authentication.</Text>
|
||||
<Input value={password} onChangeText={setPassword} secureTextEntry autoCapitalize="none" placeholder="Current password" />
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<View style={styles.rowGap}>
|
||||
<Button variant="destructive" size="sm" loading={saving} disabled={saving || !password} onPress={() => { void confirmDisable(); }}>
|
||||
Disable
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onPress={reset}>Cancel</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App passwords / API keys ──────────────────────────────
|
||||
function parseIpList(raw: string): string[] {
|
||||
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
interface CredentialSectionProps {
|
||||
icon: typeof Smartphone;
|
||||
title: string;
|
||||
description: string;
|
||||
nameLabel: string;
|
||||
namePlaceholder: string;
|
||||
entries: AppCredentialInfo[];
|
||||
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CredentialSection({ icon: Icon, title, description, nameLabel, namePlaceholder, entries, onCreate, onRemove }: CredentialSectionProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [expiry, setExpiry] = useState<Date | null>(null);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const resetForm = () => { setName(''); setExpiry(null); setAllowedIpsRaw(''); };
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await onCreate({
|
||||
description: name.trim(),
|
||||
expiresAt: expiry ? expiry.toISOString() : null,
|
||||
allowedIps: parseIpList(allowedIpsRaw),
|
||||
});
|
||||
setCreatedSecret(result.secret);
|
||||
resetForm();
|
||||
setShowAdd(false);
|
||||
} catch (err) {
|
||||
Alert.alert('Could not create', err instanceof Error ? err.message : undefined);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
Alert.alert('Remove credential?', 'Any client using it will stop working.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await onRemove(id);
|
||||
} catch (err) {
|
||||
Alert.alert('Could not remove', err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const onPickDate = (_e: DateTimePickerEvent, date?: Date) => {
|
||||
setShowPicker(false);
|
||||
if (date) setExpiry(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRowBetween}>
|
||||
<View style={styles.headerRow}>
|
||||
<Icon size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />} onPress={() => setShowAdd((v) => !v)}>
|
||||
Add
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.panelHint}>{description}</Text>
|
||||
|
||||
{createdSecret && (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelHint}>Copy this now — it is shown only once. Tap and hold to select.</Text>
|
||||
<Text selectable style={styles.secretText}>{createdSecret}</Text>
|
||||
<View style={{ alignItems: 'flex-start' }}>
|
||||
<Button variant="ghost" size="sm" onPress={() => setCreatedSecret(null)}>Done</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<View style={styles.panel}>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>{nameLabel}</Text>
|
||||
<Input value={name} onChangeText={setName} placeholder={namePlaceholder} />
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Expires (optional)</Text>
|
||||
<View style={styles.rowGap}>
|
||||
<Button variant="outline" size="sm" icon={<Calendar size={14} color={c.text} />} onPress={() => setShowPicker(true)}>
|
||||
{expiry ? expiry.toLocaleDateString() : 'No expiry'}
|
||||
</Button>
|
||||
{expiry && (
|
||||
<Pressable style={styles.clearExpiry} onPress={() => setExpiry(null)}>
|
||||
<X size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{showPicker && (
|
||||
<DateTimePicker value={expiry ?? new Date()} mode="date" minimumDate={new Date()} onChange={onPickDate} />
|
||||
)}
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.pwLabel}>Allowed IPs (optional)</Text>
|
||||
<Input value={allowedIpsRaw} onChangeText={setAllowedIpsRaw} placeholder="1.2.3.4, 10.0.0.0/8" autoCapitalize="none" />
|
||||
<Text style={styles.fieldHint}>Comma or space separated. Leave empty to allow any IP.</Text>
|
||||
</View>
|
||||
<View style={styles.rowGap}>
|
||||
<Button size="sm" loading={saving} disabled={saving || !name.trim()} onPress={() => { void handleAdd(); }}>Create</Button>
|
||||
<Button variant="ghost" size="sm" onPress={() => { setShowAdd(false); resetForm(); }}>Cancel</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{entries.map((entry) => (
|
||||
<View key={entry.id} style={styles.credRow}>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.credName} numberOfLines={1}>{entry.description || entry.id}</Text>
|
||||
{entry.createdAt && (
|
||||
<Text style={styles.credMeta}>
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{entry.allowedIps.length > 0 && (
|
||||
<View style={styles.ipWrap}>
|
||||
{entry.allowedIps.map((ip) => (
|
||||
<View key={ip} style={styles.ipPill}><Text style={styles.ipText}>{ip}</Text></View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Pressable style={styles.iconBtn} onPress={() => handleRemove(entry.id)}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>None yet.</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Email client (OAuth accounts) ─────────────────────────
|
||||
function EmailClientSection() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const username = jmapClient.username ?? '';
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRow}>
|
||||
<Monitor size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Email client setup</Text>
|
||||
</View>
|
||||
<Text style={styles.panelHint}>
|
||||
To configure another mail client, use the username below and an app password (create one above) as the password.
|
||||
</Text>
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.pwLabel}>JMAP / IMAP username</Text>
|
||||
<Text selectable style={styles.secretText}>{username}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Encryption at rest (read-only status) ─────────────────
|
||||
function EncryptionSection({ type }: { type: EncryptionType }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const enabled = type !== 'Disabled';
|
||||
return (
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.headerRow}>
|
||||
<Lock size={16} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>Encryption at rest</Text>
|
||||
</View>
|
||||
<SettingItem label="Stored message encryption" description="Server-side encryption of stored messages." noBorder>
|
||||
<Text style={[styles.statusText, { color: enabled ? c.success : c.mutedForeground }]}>
|
||||
{enabled ? `Active (${type})` : 'Disabled'}
|
||||
</Text>
|
||||
</SettingItem>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen ────────────────────────────────────────────────
|
||||
export function AccountSecuritySettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const certSupported = isClientCertSupported();
|
||||
const isOAuth = jmapClient.usesBearerAuth;
|
||||
|
||||
// null = still probing; false = server lacks the Stalwart extension.
|
||||
const [supported, setSupported] = useState<boolean | null>(null);
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [encryption, setEncryption] = useState<EncryptionType>('Disabled');
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const reloadAuth = useCallback(async () => {
|
||||
try {
|
||||
setAuth(await fetchAuthInfo());
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) { setSupported(false); return; }
|
||||
if (!isStalwartSupported()) { setSupported(false); return; }
|
||||
setSupported(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const authInfo = await fetchAuthInfo();
|
||||
if (cancelled) return;
|
||||
setAuth(authInfo);
|
||||
if (!isOAuth) {
|
||||
// Principal + crypto only matter for password accounts; failures are
|
||||
// non-fatal (e.g. a non-admin principal read is forbidden).
|
||||
const [principal, enc] = await Promise.allSettled([fetchPrincipal(), fetchEncryptionType()]);
|
||||
if (cancelled) return;
|
||||
if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName);
|
||||
if (enc.status === 'fulfilled') setEncryption(enc.value);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load security settings.');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOAuth]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && <ClientCertSection />}
|
||||
|
||||
{supported === null && (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator color={c.mutedForeground} />
|
||||
<Text style={styles.panelHint}>Detecting server features…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{supported === false && (
|
||||
<SettingsSection title="Account security" description="Manage your password, two-factor authentication, and app credentials.">
|
||||
<Text style={styles.emptyText}>
|
||||
Account security management requires a Stalwart server. These features are not available for this account.
|
||||
</Text>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Current password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input
|
||||
value={currentPwd}
|
||||
onChangeText={setCurrentPwd}
|
||||
secureTextEntry={!showCurrent}
|
||||
containerStyle={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowCurrent((v) => !v)}>
|
||||
{showCurrent ? (
|
||||
<EyeOff size={16} color={c.mutedForeground} />
|
||||
) : (
|
||||
<Eye size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{supported && (
|
||||
<SettingsSection title="Account security" description="Manage your password, two-factor authentication, and app credentials.">
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
{loadError ? <Text style={styles.errorText}>{loadError}</Text> : null}
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>New password</Text>
|
||||
<View style={styles.pwInputRow}>
|
||||
<Input
|
||||
value={newPwd}
|
||||
onChangeText={setNewPwd}
|
||||
secureTextEntry={!showNew}
|
||||
containerStyle={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable style={styles.eyeBtn} onPress={() => setShowNew((v) => !v)}>
|
||||
{showNew ? (
|
||||
<EyeOff size={16} color={c.mutedForeground} />
|
||||
) : (
|
||||
<Eye size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.pwField}>
|
||||
<Text style={styles.pwLabel}>Confirm password</Text>
|
||||
<Input
|
||||
value={confirmPwd}
|
||||
onChangeText={setConfirmPwd}
|
||||
secureTextEntry={!showNew}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Button size="sm" icon={<Key size={14} color={c.primaryForeground} />}>
|
||||
Change Password
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Two-Factor Authentication" description="Add an extra layer of security with an authenticator app.">
|
||||
<SettingItem label="Enable 2FA" description="Require a one-time code at login.">
|
||||
<ToggleSwitch checked={twoFactor} onChange={setTwoFactor} />
|
||||
</SettingItem>
|
||||
|
||||
{twoFactor && (
|
||||
<View style={styles.infoBox}>
|
||||
<Smartphone size={16} color={c.primary} />
|
||||
<Text style={styles.infoText}>
|
||||
Scan the QR code in your authenticator app to finish setup.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="App Passwords" description="Generate passwords for other mail clients.">
|
||||
{appPwds.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Lock size={24} color={c.mutedForeground} />
|
||||
<Text style={styles.emptyText}>No app passwords yet.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ alignItems: 'flex-start' }}>
|
||||
<Button variant="outline" size="sm">
|
||||
Generate New
|
||||
</Button>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Active Sessions" description="Devices currently signed into your account.">
|
||||
{MOCK_SESSIONS.map((s) => (
|
||||
<View key={s.id} style={styles.sessionRow}>
|
||||
<View style={styles.sessionIcon}>
|
||||
<Monitor size={18} color={c.text} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.sessionDevice}>{s.device}</Text>
|
||||
<Text style={styles.sessionMeta}>
|
||||
{s.location} · {s.current ? 'Current' : 'Active'}
|
||||
</Text>
|
||||
</View>
|
||||
{!s.current && (
|
||||
<Pressable style={styles.trashBtn}>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<PasswordChangeSection />
|
||||
<DisplayNameSection initial={displayName} onSaved={setDisplayName} />
|
||||
<TotpSection enabled={!!auth?.otpEnabled} onChanged={() => { void reloadAuth(); }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CredentialSection
|
||||
icon={Smartphone}
|
||||
title="App passwords"
|
||||
description="Generate passwords for other mail clients that can't do interactive login."
|
||||
nameLabel="Name"
|
||||
namePlaceholder="e.g. Thunderbird laptop"
|
||||
entries={auth?.appPasswords ?? []}
|
||||
onCreate={async (input) => { const r = await createAppPassword(input); await reloadAuth(); return r; }}
|
||||
onRemove={async (id) => { await removeAppPassword(id); await reloadAuth(); }}
|
||||
/>
|
||||
|
||||
<CredentialSection
|
||||
icon={Terminal}
|
||||
title="API keys"
|
||||
description="Tokens for scripts and integrations that use the JMAP API."
|
||||
nameLabel="Name"
|
||||
namePlaceholder="e.g. Backup script"
|
||||
entries={auth?.apiKeys ?? []}
|
||||
onCreate={async (input) => { const r = await createApiKey(input); await reloadAuth(); return r; }}
|
||||
onRemove={async (id) => { await removeApiKey(id); await reloadAuth(); }}
|
||||
/>
|
||||
|
||||
{isOAuth && <EmailClientSection />}
|
||||
{!isOAuth && <EncryptionSection type={encryption} />}
|
||||
</View>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxxl },
|
||||
pwField: { gap: 4 },
|
||||
pwLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
eyeBtn: { padding: spacing.sm },
|
||||
infoBox: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
backgroundColor: c.primaryBg,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoText: {
|
||||
...typography.caption,
|
||||
color: c.primary,
|
||||
flex: 1,
|
||||
},
|
||||
empty: {
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyText: {
|
||||
...typography.body,
|
||||
color: c.mutedForeground,
|
||||
},
|
||||
sessionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sessionIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.muted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sessionDevice: { ...typography.bodyMedium, color: c.text },
|
||||
sessionMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
trashBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
certRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
certStatus: { ...typography.bodyMedium, color: c.text },
|
||||
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
|
||||
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
});
|
||||
container: { gap: spacing.xxxl },
|
||||
rowGap: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
|
||||
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.lg },
|
||||
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
headerRowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
headerTitle: { ...typography.bodyMedium, color: c.text },
|
||||
|
||||
statusText: { ...typography.caption, fontWeight: '500' },
|
||||
|
||||
pwField: { gap: 4 },
|
||||
pwLabel: { ...typography.caption, color: c.mutedForeground, marginBottom: 4 },
|
||||
pwInputRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
eyeBtn: { padding: spacing.sm },
|
||||
fieldHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4, fontSize: 11 },
|
||||
errorText: { ...typography.caption, color: c.error },
|
||||
|
||||
panel: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
panelHint: { ...typography.caption, color: c.mutedForeground },
|
||||
secretText: {
|
||||
...typography.body,
|
||||
fontFamily: 'monospace',
|
||||
color: c.text,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
clearExpiry: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
|
||||
credRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
credName: { ...typography.bodyMedium, color: c.text },
|
||||
credMeta: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
ipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
|
||||
ipPill: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.xs, backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
|
||||
ipText: { fontSize: 10, fontFamily: 'monospace', color: c.mutedForeground },
|
||||
iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm },
|
||||
emptyText: { ...typography.caption, color: c.mutedForeground, fontStyle: 'italic' },
|
||||
|
||||
// TLS client cert
|
||||
certRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingVertical: spacing.md },
|
||||
certStatus: { ...typography.bodyMedium, color: c.text },
|
||||
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,14 @@ export function ContactsSettings() {
|
||||
const groupByLetter = useSettingsStore((s) => s.groupContactsByLetter);
|
||||
const setGroupByLetter = useSettingsStore((s) => s.setGroupContactsByLetter);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
const books = useContactsStore(selectAddressBooksWithCount);
|
||||
const addressBooks = useContactsStore((s) => s.addressBooks);
|
||||
// Derive in a memo from stable store fields. Subscribing with
|
||||
// `selectAddressBooksWithCount` directly returns a new array each call and
|
||||
// sends useSyncExternalStore into an infinite render loop.
|
||||
const books = React.useMemo(
|
||||
() => selectAddressBooksWithCount(addressBooks, contacts),
|
||||
[addressBooks, contacts],
|
||||
);
|
||||
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
|
||||
const fetchContacts = useContactsStore((s) => s.fetchContacts);
|
||||
const createAddressBook = useContactsStore((s) => s.createAddressBook);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { RotateCcw } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SpaceReplacement } from '../../stores/settings-store';
|
||||
import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
EMAIL_TOKENS,
|
||||
ATTACHMENT_TOKENS,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
DEFAULT_ATTACHMENT_TEMPLATE,
|
||||
type EmailFilenameOptions,
|
||||
} from '../../lib/download-filename';
|
||||
|
||||
const SAMPLE = buildSampleEmail();
|
||||
const SAMPLE_ATTACHMENT = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
|
||||
|
||||
export function DownloadsSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const emailTemplate = useSettingsStore((s) => s.emailExportTemplate);
|
||||
const attachmentTemplate = useSettingsStore((s) => s.attachmentExportTemplate);
|
||||
const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.exportLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const transforms: EmailFilenameOptions = { spaceReplacement, lowercase, stripDiacritics };
|
||||
const emailPreview = emailExportFilename(SAMPLE, { ...transforms, template: emailTemplate });
|
||||
const attachmentPreview = attachmentDownloadFilename(SAMPLE, SAMPLE_ATTACHMENT, {
|
||||
...transforms,
|
||||
template: attachmentTemplate,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
<SettingsSection
|
||||
title="Email Filename"
|
||||
description="Template used when exporting a message as an .eml file."
|
||||
>
|
||||
<Input
|
||||
value={emailTemplate}
|
||||
onChangeText={(v) => update('emailExportTemplate', v)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => update('emailExportTemplate', DEFAULT_EMAIL_TEMPLATE)}
|
||||
style={styles.resetRow}
|
||||
>
|
||||
<RotateCcw size={12} color={c.mutedForeground} />
|
||||
<Text style={styles.resetText}>Reset to default</Text>
|
||||
</Pressable>
|
||||
<View style={styles.previewBox}>
|
||||
<Text style={styles.previewLabel}>Preview</Text>
|
||||
<Text style={styles.previewValue}>{emailPreview}</Text>
|
||||
</View>
|
||||
<TokenList tokens={EMAIL_TOKENS} styles={styles} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Attachment Filename"
|
||||
description="Template used when saving or sharing an attachment."
|
||||
>
|
||||
<Input
|
||||
value={attachmentTemplate}
|
||||
onChangeText={(v) => update('attachmentExportTemplate', v)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => update('attachmentExportTemplate', DEFAULT_ATTACHMENT_TEMPLATE)}
|
||||
style={styles.resetRow}
|
||||
>
|
||||
<RotateCcw size={12} color={c.mutedForeground} />
|
||||
<Text style={styles.resetText}>Reset to default</Text>
|
||||
</Pressable>
|
||||
<View style={styles.previewBox}>
|
||||
<Text style={styles.previewLabel}>Preview</Text>
|
||||
<Text style={styles.previewValue}>{attachmentPreview}</Text>
|
||||
</View>
|
||||
<TokenList tokens={ATTACHMENT_TOKENS} styles={styles} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Filename Transform"
|
||||
description="Applied to every exported filename."
|
||||
>
|
||||
<View style={styles.group}>
|
||||
<SettingItem label="Spaces" description="Replace spaces in filenames." noBorder />
|
||||
<RadioGroup
|
||||
value={spaceReplacement}
|
||||
onChange={(v) => update('exportSpaceReplacement', v as SpaceReplacement)}
|
||||
options={[
|
||||
{ value: 'keep', label: 'Keep' },
|
||||
{ value: 'underscore', label: 'Underscore' },
|
||||
{ value: 'dash', label: 'Dash' },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SettingItem label="Lowercase" description="Force the whole filename to lowercase.">
|
||||
<ToggleSwitch checked={lowercase} onChange={(v) => update('exportLowercase', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label="Strip Accents"
|
||||
description="Convert accented letters to plain ASCII (ä → a)."
|
||||
>
|
||||
<ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenList({
|
||||
tokens,
|
||||
styles,
|
||||
}: {
|
||||
tokens: { token: string; description: string }[];
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tokenWrap}>
|
||||
{tokens.map((t) => (
|
||||
<View key={t.token} style={styles.tokenPill}>
|
||||
<Text style={styles.tokenText}>{`{${t.token}}`}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
group: { gap: spacing.sm },
|
||||
resetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
resetText: { ...typography.caption, color: c.mutedForeground },
|
||||
previewBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
gap: 2,
|
||||
},
|
||||
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
|
||||
previewValue: { ...typography.captionMedium, color: c.text },
|
||||
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
|
||||
tokenPill: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
tokenText: { fontSize: 11, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
@@ -1,184 +1,468 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, GripVertical, X, Code, Filter, AlertTriangle, RotateCcw } from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import {
|
||||
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
|
||||
ChevronUp, ChevronDown, Palmtree,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useFilterStore } from '../../stores/filter-store';
|
||||
import { useVacationStore } from '../../stores/vacation-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { FilterRuleModal } from '../filters/FilterRuleModal';
|
||||
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
|
||||
import type { FilterRule } from '../../lib/sieve/types';
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
summary: string;
|
||||
action: string;
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === 'external' || r.origin === 'opaque';
|
||||
}
|
||||
|
||||
const SAMPLE_RULES: Rule[] = [];
|
||||
function summarizeRule(rule: FilterRule, t: Translate): string {
|
||||
const joiner = rule.matchType === 'all' ? t('settings.filters.and', 'and') : t('settings.filters.or', 'or');
|
||||
const conditions = rule.conditions.slice(0, 2).map((cond) => {
|
||||
const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field);
|
||||
const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator);
|
||||
return `${field} ${comparator} "${cond.value}"`;
|
||||
});
|
||||
const extra = rule.conditions.length > 2 ? ` (+${rule.conditions.length - 2})` : '';
|
||||
const actions = rule.actions.slice(0, 2).map((a) => {
|
||||
const action = t(`settings.filters.action_types.${a.type}`, a.type);
|
||||
return a.value ? `${action} "${a.value}"` : action;
|
||||
});
|
||||
return `${conditions.join(` ${joiner} `)}${extra} → ${actions.join(', ')}`;
|
||||
}
|
||||
|
||||
export function FilterSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const expandedView = useSettingsStore((s) => s.filtersExpandedView);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const [rules, setRules] = useState<Rule[]>(SAMPLE_RULES);
|
||||
const [isOpaque] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
// Sieve rules run on the user's own account, so only own folders are
|
||||
// valid "file into" targets.
|
||||
const allMailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const mailboxes = useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
|
||||
const vacationEnabled = useVacationStore((s) => s.isEnabled);
|
||||
|
||||
const {
|
||||
rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings,
|
||||
fetchFilters, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
|
||||
setOpaqueScript, resetToVisualBuilder, validateScript,
|
||||
} = useFilterStore();
|
||||
|
||||
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
const [showSieveEditor, setShowSieveEditor] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
useEffect(() => {
|
||||
void fetchFilters();
|
||||
}, [fetchFilters]);
|
||||
|
||||
const remove = (id: string) => {
|
||||
setRules((rs) => rs.filter((r) => r.id !== id));
|
||||
setDeleteId(null);
|
||||
};
|
||||
const showVacationBanner = (vacationEnabled || vacationSettings?.isEnabled) ?? false;
|
||||
|
||||
// Editable (Bulwark-managed) rules, in order, for reorder math.
|
||||
const editableIds = useMemo(
|
||||
() => rules.filter((r) => !isReadonlyRule(r)).map((r) => r.id),
|
||||
[rules],
|
||||
);
|
||||
|
||||
const persist = useCallback(async (rollback: () => void) => {
|
||||
try {
|
||||
await saveFilters();
|
||||
} catch {
|
||||
rollback();
|
||||
Alert.alert(t('settings.filters.save_failed', 'Failed to save filters'));
|
||||
}
|
||||
}, [saveFilters, t]);
|
||||
|
||||
const handleToggle = useCallback((ruleId: string) => {
|
||||
toggleRule(ruleId);
|
||||
void persist(() => toggleRule(ruleId));
|
||||
}, [toggleRule, persist]);
|
||||
|
||||
const handleDelete = useCallback((rule: FilterRule) => {
|
||||
Alert.alert(
|
||||
t('settings.filters.delete_rule', 'Delete Rule'),
|
||||
t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'),
|
||||
[
|
||||
{ text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('settings.filters.confirm_delete', 'Delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
deleteRule(rule.id);
|
||||
void persist(() => addRule(rule));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [deleteRule, addRule, persist, t]);
|
||||
|
||||
const handleSaveRule = useCallback((rule: FilterRule) => {
|
||||
const previous = useFilterStore.getState().rules;
|
||||
if (editingRule) updateRule(rule.id, rule);
|
||||
else addRule(rule);
|
||||
setShowRuleModal(false);
|
||||
setEditingRule(undefined);
|
||||
void persist(() => useFilterStore.setState({ rules: previous }));
|
||||
}, [editingRule, updateRule, addRule, persist]);
|
||||
|
||||
const handleSaveSieve = useCallback((content: string) => {
|
||||
const previous = useFilterStore.getState();
|
||||
setOpaqueScript(content);
|
||||
setShowSieveEditor(false);
|
||||
void persist(() => useFilterStore.setState({
|
||||
isOpaque: previous.isOpaque,
|
||||
rules: previous.rules,
|
||||
rawScript: previous.rawScript,
|
||||
}));
|
||||
}, [setOpaqueScript, persist]);
|
||||
|
||||
const handleResetToVisual = useCallback(() => {
|
||||
if (!showResetConfirm) {
|
||||
setShowResetConfirm(true);
|
||||
return;
|
||||
}
|
||||
const previous = useFilterStore.getState();
|
||||
resetToVisualBuilder();
|
||||
setShowResetConfirm(false);
|
||||
void persist(() => useFilterStore.setState({
|
||||
isOpaque: previous.isOpaque,
|
||||
rules: previous.rules,
|
||||
rawScript: previous.rawScript,
|
||||
externalRequires: previous.externalRequires,
|
||||
}));
|
||||
}, [showResetConfirm, resetToVisualBuilder, persist]);
|
||||
|
||||
const moveRule = useCallback((ruleId: string, dir: -1 | 1) => {
|
||||
const idx = editableIds.indexOf(ruleId);
|
||||
const target = idx + dir;
|
||||
if (idx === -1 || target < 0 || target >= editableIds.length) return;
|
||||
const previousOrder = [...editableIds];
|
||||
const next = [...editableIds];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
reorderRules(next);
|
||||
void persist(() => reorderRules(previousOrder));
|
||||
}, [editableIds, reorderRules, persist]);
|
||||
|
||||
const handleValidate = useCallback(
|
||||
(content: string) => validateScript(content),
|
||||
[validateScript],
|
||||
);
|
||||
|
||||
// ── Status screens ──────────────────────────────────────
|
||||
if (!isSupported && !isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
<Text style={styles.statusText}>{t('settings.filters.not_supported', 'Your mail server does not support email filters.')}</Text>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator size="small" color={c.mutedForeground} />
|
||||
<Text style={styles.statusText}>{t('settings.filters.loading', 'Loading filters...')}</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
<Text style={[styles.statusText, { color: c.error }]}>{t('settings.filters.fetch_error', 'Failed to load filters')}</Text>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title="Filters & Rules" description="Server-side rules that process incoming mail.">
|
||||
<View style={{ gap: spacing.xl }}>
|
||||
<SettingsSection title={t('settings.filters.title', 'Email Filters')} description={t('settings.filters.description', 'Create rules to automatically sort, label, and manage incoming emails')}>
|
||||
{isOpaque && (
|
||||
<View style={styles.opaqueWarning}>
|
||||
<AlertTriangle size={16} color="#fbbf24" />
|
||||
<View style={styles.opaqueBanner}>
|
||||
<AlertTriangle size={16} color={c.warning} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.opaqueText}>
|
||||
Your Sieve script contains advanced features. Edit raw or reset to visual builder.
|
||||
{t('settings.filters.opaque_warning', 'This script was edited outside the visual builder. Only raw Sieve editing is available.')}
|
||||
</Text>
|
||||
<View style={styles.opaqueActions}>
|
||||
<Pressable>
|
||||
<Text style={styles.linkText}>Open Sieve Editor</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.resetLink}>
|
||||
<RotateCcw size={12} color={c.error} />
|
||||
<Text style={styles.resetText}>Reset to visual</Text>
|
||||
<Pressable onPress={() => setShowSieveEditor(true)}>
|
||||
<Text style={styles.linkPrimary}>{t('settings.filters.open_sieve_editor', 'Open raw Sieve editor')}</Text>
|
||||
</Pressable>
|
||||
{showResetConfirm ? (
|
||||
<View style={styles.resetConfirmRow}>
|
||||
<Pressable onPress={handleResetToVisual}>
|
||||
<Text style={styles.linkDanger}>{t('settings.filters.confirm_reset', 'Reset')}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setShowResetConfirm(false)}>
|
||||
<Text style={styles.linkMuted}>{t('settings.filters.cancel', 'Cancel')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleResetToVisual} style={styles.resetLink}>
|
||||
<RotateCcw size={12} color={c.error} />
|
||||
<Text style={styles.linkDanger}>{t('settings.filters.reset_to_visual', 'Reset to visual builder')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !isOpaque && (
|
||||
<View style={styles.emptyState}>
|
||||
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>No rules yet.</Text>
|
||||
{!isOpaque && showVacationBanner && (
|
||||
<View style={styles.vacationBanner}>
|
||||
<View style={styles.vacationIcon}>
|
||||
<Palmtree size={16} color={c.success} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.vacationTitle}>{t('settings.filters.vacation_active', 'Vacation Responder is active')}</Text>
|
||||
<Text style={styles.vacationDesc}>{t('settings.filters.vacation_active_description', 'Auto-reply is enabled for incoming messages')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rules.length > 0 && (
|
||||
<View style={{ gap: 4 }}>
|
||||
{rules.map((rule) => (
|
||||
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
||||
<View style={styles.grip}>
|
||||
<GripVertical size={16} color={c.mutedForeground} />
|
||||
</View>
|
||||
{!isOpaque && rules.length === 0 && !showVacationBanner && (
|
||||
<View style={styles.emptyState}>
|
||||
<Filter size={40} color={c.mutedForeground} style={{ opacity: 0.4 }} />
|
||||
<Text style={styles.emptyText}>{t('settings.filters.no_rules', 'No filter rules')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ToggleSwitch checked={rule.enabled} onChange={() => toggle(rule.id)} />
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{rules.map((rule) => {
|
||||
const readonly = isReadonlyRule(rule);
|
||||
const editIdx = editableIds.indexOf(rule.id);
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.ruleName}>{rule.name}</Text>
|
||||
<Text style={styles.ruleSummary} numberOfLines={expandedView ? 0 : 1}>
|
||||
{rule.summary} → {rule.action}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{deleteId === rule.id ? (
|
||||
<View style={{ flexDirection: 'row', gap: 4 }}>
|
||||
<Button variant="destructive" size="sm" onPress={() => remove(rule.id)}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onPress={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
if (readonly) {
|
||||
const label = rule.originLabel || t('settings.filters.origin_external', 'External');
|
||||
const hasStructured = rule.origin === 'external' && rule.conditions.length > 0 && rule.actions.length > 0;
|
||||
return (
|
||||
<View key={rule.id} style={styles.ruleRow}>
|
||||
<Lock size={16} color={c.mutedForeground} style={{ marginTop: 2 }} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View style={styles.ruleNameRow}>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
|
||||
</View>
|
||||
{hasStructured ? (
|
||||
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
|
||||
{summarizeRule(rule, t)}
|
||||
</Text>
|
||||
) : rule.rawBlock ? (
|
||||
<Text style={styles.rawBlock} numberOfLines={expandedView ? undefined : 4}>
|
||||
{rule.rawBlock.trim()}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable style={styles.iconBtn} onPress={() => setDeleteId(rule.id)}>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={rule.id} style={[styles.ruleRow, !rule.enabled && styles.ruleDisabled]}>
|
||||
<View style={{ paddingTop: 2 }}>
|
||||
<ToggleSwitch checked={rule.enabled} onChange={() => handleToggle(rule.id)} />
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
onPress={() => { setEditingRule(rule); setShowRuleModal(true); }}
|
||||
>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<Text style={styles.ruleSummary} numberOfLines={expandedView ? undefined : 2}>
|
||||
{summarizeRule(rule, t)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.reorderCol}>
|
||||
<Pressable
|
||||
onPress={() => moveRule(rule.id, -1)}
|
||||
disabled={editIdx <= 0}
|
||||
hitSlop={6}
|
||||
style={[styles.reorderBtn, editIdx <= 0 && styles.reorderBtnDisabled]}
|
||||
>
|
||||
<ChevronUp size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => moveRule(rule.id, 1)}
|
||||
disabled={editIdx === editableIds.length - 1}
|
||||
hitSlop={6}
|
||||
style={[styles.reorderBtn, editIdx === editableIds.length - 1 && styles.reorderBtnDisabled]}
|
||||
>
|
||||
<ChevronDown size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={() => handleDelete(rule)} hitSlop={6} style={styles.deleteBtn}>
|
||||
<X size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
||||
<View style={styles.footerBtns}>
|
||||
{!isOpaque && (
|
||||
<Button variant="outline" size="sm" icon={<Plus size={14} color={c.text} />}>
|
||||
Add Rule
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Plus size={14} color={c.text} />}
|
||||
onPress={() => { setEditingRule(undefined); setShowRuleModal(true); }}
|
||||
>
|
||||
{t('settings.filters.add_rule', 'Add Rule')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" icon={<Code size={14} color={c.text} />}>
|
||||
Raw Editor
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Code size={14} color={c.text} />}
|
||||
onPress={() => setShowSieveEditor(true)}
|
||||
>
|
||||
{t('settings.filters.raw_editor', 'Raw Sieve Editor')}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={styles.expandedToggle}>
|
||||
<Text style={styles.expandedLabel}>Expanded view</Text>
|
||||
<ToggleSwitch checked={expandedView} onChange={(v) => update('filtersExpandedView', v)} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.footerRight}>
|
||||
{isSaving && <ActivityIndicator size="small" color={c.mutedForeground} />}
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<View style={styles.expandedToggle}>
|
||||
<Text style={styles.expandedLabel}>{t('settings.filters.expanded_view', 'Expanded view')}</Text>
|
||||
<ToggleSwitch checked={expandedView} onChange={(v) => updateSetting('filtersExpandedView', v)} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FilterRuleModal
|
||||
visible={showRuleModal}
|
||||
rule={editingRule}
|
||||
mailboxes={mailboxes}
|
||||
onSave={handleSaveRule}
|
||||
onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }}
|
||||
/>
|
||||
|
||||
<SieveEditorSheet
|
||||
visible={showSieveEditor}
|
||||
content={rawScript}
|
||||
onSave={handleSaveSieve}
|
||||
onClose={() => setShowSieveEditor(false)}
|
||||
onValidate={handleValidate}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xl },
|
||||
opaqueWarning: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
opaqueText: { ...typography.caption, color: '#fde68a' },
|
||||
opaqueActions: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.sm },
|
||||
linkText: { ...typography.caption, color: c.primary, fontWeight: '500' },
|
||||
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
resetText: { ...typography.caption, color: c.error, fontWeight: '500' },
|
||||
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
||||
emptyText: { ...typography.body, color: c.mutedForeground },
|
||||
ruleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
ruleDisabled: { opacity: 0.6 },
|
||||
grip: { paddingTop: 2 },
|
||||
ruleName: { ...typography.bodyMedium, color: c.text },
|
||||
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
iconBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
expandedLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
statusText: { ...typography.body, color: c.mutedForeground, paddingVertical: spacing.md },
|
||||
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.md },
|
||||
|
||||
opaqueBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
opaqueText: { ...typography.caption, color: '#fde68a' },
|
||||
opaqueActions: { flexDirection: 'row', gap: spacing.lg, marginTop: spacing.sm, flexWrap: 'wrap', alignItems: 'center' },
|
||||
resetConfirmRow: { flexDirection: 'row', gap: spacing.md, alignItems: 'center' },
|
||||
resetLink: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
linkPrimary: { ...typography.caption, color: c.primary, fontWeight: '500' },
|
||||
linkDanger: { ...typography.caption, color: c.error, fontWeight: '500' },
|
||||
linkMuted: { ...typography.caption, color: c.mutedForeground },
|
||||
|
||||
vacationBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.success,
|
||||
backgroundColor: c.successBg,
|
||||
},
|
||||
vacationIcon: {
|
||||
width: 32, height: 32, borderRadius: radius.full,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||
},
|
||||
vacationTitle: { ...typography.bodyMedium, color: c.success },
|
||||
vacationDesc: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
|
||||
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
|
||||
emptyText: { ...typography.body, color: c.mutedForeground },
|
||||
|
||||
ruleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
ruleDisabled: { opacity: 0.6 },
|
||||
ruleNameRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
ruleName: { ...typography.bodyMedium, color: c.text, flexShrink: 1 },
|
||||
ruleSummary: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
rawBlock: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
fontFamily: 'monospace',
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
|
||||
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
|
||||
reorderCol: { alignItems: 'center' },
|
||||
reorderBtn: { padding: 2 },
|
||||
reorderBtnDisabled: { opacity: 0.25 },
|
||||
deleteBtn: {
|
||||
width: 28, height: 28,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
footerBtns: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
footerRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
expandedToggle: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
expandedLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SettingsSection } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
@@ -43,7 +44,10 @@ type Editor =
|
||||
export function FolderSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
// Folder management (create/rename/delete) targets the user's own account;
|
||||
// shared (group account) folders are read-only here.
|
||||
const allMailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
|
||||
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore, type DateFormat, type TimeFormat } from '../../stores/settings-store';
|
||||
import { formatListDate } from '../../lib/date-format';
|
||||
import { SUPPORTED_LOCALES, detectDeviceLocale, type LocaleCode } from '../../i18n';
|
||||
|
||||
export function LanguageSettings() {
|
||||
@@ -12,15 +14,30 @@ export function LanguageSettings() {
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const override = useLocaleStore((s) => s.override);
|
||||
const setOverride = useLocaleStore((s) => s.setOverride);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const hydrated = useLocaleStore((s) => s.hydrated);
|
||||
const hydrate = useLocaleStore((s) => s.hydrate);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrateSettings = useSettingsStore((s) => s.hydrate);
|
||||
const dateFormat = useSettingsStore((s) => s.dateFormat);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
useEffect(() => { if (!settingsHydrated) void hydrateSettings(); }, [settingsHydrated, hydrateSettings]);
|
||||
|
||||
const deviceCode = detectDeviceLocale();
|
||||
const selected: LocaleCode | 'system' = override ?? 'system';
|
||||
|
||||
// Build live preview samples so the user sees what each format looks like.
|
||||
const now = new Date();
|
||||
const fmtOpts = { dateFormat, timeFormat, locale };
|
||||
const previewToday = formatListDate(now, fmtOpts);
|
||||
const previewWeek = formatListDate(new Date(now.getTime() - 3 * 86400000), fmtOpts);
|
||||
const previewOlder = formatListDate(new Date(now.getTime() - 40 * 86400000), fmtOpts);
|
||||
|
||||
const renderRow = (code: LocaleCode | 'system', label: string, sublabel?: string) => {
|
||||
const active = selected === code;
|
||||
return (
|
||||
@@ -42,21 +59,61 @@ export function LanguageSettings() {
|
||||
SUPPORTED_LOCALES.find((l) => l.code === deviceCode)?.label ?? 'English';
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.appearance.language.label', 'Language')}
|
||||
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.appearance.language.label', 'Language')}
|
||||
<View style={{ gap: spacing.xxxl }}>
|
||||
<SettingsSection
|
||||
title={t('settings.appearance.language.label', 'Language')}
|
||||
description={t('settings.appearance.language.description', 'Choose your preferred language')}
|
||||
noBorder
|
||||
/>
|
||||
<View style={styles.list}>
|
||||
{renderRow('system', `System default (${deviceLabel})`)}
|
||||
<View style={styles.divider} />
|
||||
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{renderRow('system', `System default (${deviceLabel})`)}
|
||||
<View style={styles.divider} />
|
||||
{SUPPORTED_LOCALES.map((l) => renderRow(l.code, l.label))}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.language_region.date_format.label', 'Date Format')}
|
||||
description={t('settings.language_region.date_format.description', 'How dates are shown in the email list')}
|
||||
>
|
||||
<RadioGroup
|
||||
value={dateFormat}
|
||||
onChange={(v) => update('dateFormat', v as DateFormat)}
|
||||
options={[
|
||||
{ value: 'smart', label: t('settings.language_region.date_format.smart', 'Smart (locale-aware)') },
|
||||
{ value: 'relative', label: t('settings.language_region.date_format.relative', 'Relative (1h ago, 2d ago)') },
|
||||
{ value: 'full', label: t('settings.language_region.date_format.full', 'Always full date') },
|
||||
]}
|
||||
/>
|
||||
<View style={styles.previewBox}>
|
||||
<View style={styles.previewRow}>
|
||||
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_today', 'Today:')}</Text>
|
||||
<Text style={styles.previewValue}>{previewToday}</Text>
|
||||
</View>
|
||||
<View style={styles.previewRow}>
|
||||
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_this_week', 'This week:')}</Text>
|
||||
<Text style={styles.previewValue}>{previewWeek}</Text>
|
||||
</View>
|
||||
<View style={styles.previewRow}>
|
||||
<Text style={styles.previewLabel}>{t('settings.language_region.date_format.preview_older', 'Older:')}</Text>
|
||||
<Text style={styles.previewValue}>{previewOlder}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.language_region.time_format.label', 'Time Format')}
|
||||
description={t('settings.language_region.time_format.description', 'Choose between 12-hour or 24-hour clock')}
|
||||
>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => update('timeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '24h', label: t('settings.language_region.time_format.24h', '24-hour') },
|
||||
{ value: '12h', label: t('settings.language_region.time_format.12h', '12-hour') },
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,5 +131,15 @@ function makeStyles(c: ThemePalette) {
|
||||
divider: { height: 1, backgroundColor: c.border, marginHorizontal: spacing.md },
|
||||
label: { ...typography.body, color: c.text },
|
||||
sublabel: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
previewBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
gap: 4,
|
||||
},
|
||||
previewRow: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.md },
|
||||
previewLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
previewValue: { ...typography.captionMedium, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
|
||||
import { SettingsSection, RadioGroup } from './settings-section';
|
||||
import { ArrowRight, ArrowLeft, MoveVertical } from 'lucide-react-native';
|
||||
import { SettingsSection, RadioGroup, Select } from './settings-section';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
normalizeBottomQuickActions,
|
||||
REPLY_QUICK_ACTIONS,
|
||||
ALL_QUICK_ACTIONS,
|
||||
type SwipeAction,
|
||||
type SwipeMode,
|
||||
type QuickAction,
|
||||
} from '../../stores/settings-store';
|
||||
|
||||
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
@@ -22,6 +30,24 @@ const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
|
||||
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
|
||||
];
|
||||
|
||||
const QUICK_ACTION_LABELS: Record<QuickAction, string> = {
|
||||
reply: 'Reply',
|
||||
replyAll: 'Reply All',
|
||||
forward: 'Forward',
|
||||
delete: 'Delete',
|
||||
archive: 'Archive',
|
||||
markUnread: 'Mark Read/Unread',
|
||||
star: 'Star',
|
||||
move: 'Move to folder',
|
||||
spam: 'Spam',
|
||||
tag: 'Tag',
|
||||
};
|
||||
|
||||
const QUICK_ACTION_OPTIONS = ALL_QUICK_ACTIONS.map((value) => ({
|
||||
value,
|
||||
label: QUICK_ACTION_LABELS[value],
|
||||
}));
|
||||
|
||||
export function LayoutSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -31,11 +57,27 @@ export function LayoutSettings() {
|
||||
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
|
||||
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
const hydrated = useSettingsStore((s) => s.hydrated);
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
|
||||
// Pick an action for a bar slot. If the chosen action already occupies
|
||||
// another slot, swap the two so the three slots stay unique.
|
||||
const setQuickActionSlot = (index: number, value: QuickAction) => {
|
||||
const next = [...bottomActions];
|
||||
const existing = next.indexOf(value);
|
||||
if (existing !== -1 && existing !== index) {
|
||||
next[existing] = next[index];
|
||||
}
|
||||
next[index] = value;
|
||||
update('bottomQuickActions', next);
|
||||
};
|
||||
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
|
||||
|
||||
return (
|
||||
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
@@ -81,6 +123,34 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
||||
<View style={styles.row}>
|
||||
<MoveVertical size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.rowLabel}>Reader quick actions</Text>
|
||||
</View>
|
||||
<Text style={styles.rowDescription}>
|
||||
The three buttons shown between Prev/Next at the bottom of an open
|
||||
email. Defaults to Reply, Reply All and Forward.
|
||||
</Text>
|
||||
{bottomActions.map((action, index) => (
|
||||
<View key={index} style={styles.slotRow}>
|
||||
<Text style={styles.slotLabel}>Slot {index + 1}</Text>
|
||||
<Select
|
||||
value={action}
|
||||
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
|
||||
options={QUICK_ACTION_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{relocated.length > 0 && (
|
||||
<Text style={styles.rowDescription}>
|
||||
{relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ')}
|
||||
{relocated.length === 1 ? ' is' : ' are'} moved to the top toolbar so
|
||||
you can still use {relocated.length === 1 ? 'it' : 'them'}.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -90,5 +160,13 @@ function makeStyles(c: ThemePalette) {
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
rowLabel: { ...typography.bodyMedium, color: c.text },
|
||||
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
slotRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
slotLabel: { ...typography.body, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
||||
|
||||
function MailLayoutPreview({ value }: { value: MailLayout }) {
|
||||
const c = useColors();
|
||||
@@ -83,6 +84,7 @@ export function ReadingSettings() {
|
||||
const showPreview = useSettingsStore((s) => s.showPreview);
|
||||
const mailLayout = useSettingsStore((s) => s.mailLayout);
|
||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
|
||||
@@ -102,7 +104,8 @@ export function ReadingSettings() {
|
||||
|
||||
const handleReorganizeArchive = async () => {
|
||||
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
|
||||
const archiveMailbox = mailboxes.find(
|
||||
// Reorganising runs against the user's own archive only.
|
||||
const archiveMailbox = ownMailboxes(mailboxes).find(
|
||||
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
|
||||
);
|
||||
if (!archiveMailbox) {
|
||||
@@ -129,7 +132,7 @@ export function ReadingSettings() {
|
||||
if (ids.length === 0) break;
|
||||
|
||||
const list = await getEmails(ids);
|
||||
const refreshed = useEmailStore.getState().mailboxes;
|
||||
const refreshed = ownMailboxes(useEmailStore.getState().mailboxes);
|
||||
await archiveEmails(
|
||||
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
|
||||
archiveMailbox.id,
|
||||
@@ -177,6 +180,7 @@ export function ReadingSettings() {
|
||||
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
||||
options={[
|
||||
{ value: 'trash', label: 'Move to Trash' },
|
||||
{ value: 'trash-and-read', label: 'Move to Trash and mark as read' },
|
||||
{ value: 'permanent', label: 'Permanently delete' },
|
||||
]}
|
||||
/>
|
||||
@@ -250,6 +254,10 @@ export function ReadingSettings() {
|
||||
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Include Group Inboxes" description="Also show group and shared inboxes in the unified All Inboxes view.">
|
||||
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label="Plain Text Mode" description="Compose and read in plain text only.">
|
||||
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
|
||||
</SettingItem>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Pure decision logic behind SwipeableRow's pan gesture.
|
||||
//
|
||||
// This lives outside the component for a reason beyond tidiness: the row's
|
||||
// PanResponder is created once and its handlers therefore close over whatever
|
||||
// props existed at mount. Taking the config as an argument makes staleness
|
||||
// structurally impossible here — there is nothing to capture — and leaves the
|
||||
// component with one job, keeping the current config somewhere the handlers
|
||||
// can reach.
|
||||
|
||||
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
|
||||
|
||||
// Instant mode: distance the user must drag past on release to fire the action.
|
||||
export const COMMIT_THRESHOLD = 96;
|
||||
// Reveal mode: drag past this on release to snap to the open (revealed) state.
|
||||
export const ACTIVATION_THRESHOLD = 32;
|
||||
// Reveal mode: width of the action band shown when revealed.
|
||||
export const REVEAL_WIDTH = 88;
|
||||
|
||||
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
|
||||
const MIN_DX_TO_CLAIM = 6;
|
||||
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
|
||||
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
|
||||
// A short, fast drag commits too, so the gesture doesn't demand the full
|
||||
// COMMIT_THRESHOLD when the user clearly flicks.
|
||||
const FLING_DX = 40;
|
||||
const FLING_VX = 0.5;
|
||||
|
||||
/**
|
||||
* The row's current swipe configuration.
|
||||
*
|
||||
* Note the naming: `rightAction` fires on a rightward (positive dx) drag and
|
||||
* its band sits at the *left* edge; `leftAction` is the mirror image.
|
||||
*/
|
||||
export interface SwipeConfig {
|
||||
leftAction: SwipeAction;
|
||||
rightAction: SwipeAction;
|
||||
mode: SwipeMode;
|
||||
}
|
||||
|
||||
/** The subset of RN's PanResponder gesture state this logic needs. */
|
||||
export interface SwipeGestureState {
|
||||
dx: number;
|
||||
dy: number;
|
||||
vx: number;
|
||||
}
|
||||
|
||||
/** Which side's action band is currently held open (reveal mode only). */
|
||||
export type OpenSide = 'left' | 'right' | null;
|
||||
|
||||
export type ReleaseOutcome =
|
||||
/** Reveal mode: snap open so the band can be tapped. */
|
||||
| { kind: 'open'; side: 'left' | 'right' }
|
||||
/** Reveal mode: snap shut. */
|
||||
| { kind: 'close' }
|
||||
/** Instant mode: run the action; `direction` is the sign to fly the row off by. */
|
||||
| { kind: 'fire'; action: SwipeAction; direction: 1 | -1 }
|
||||
/** Not far enough — spring back to rest without firing. */
|
||||
| { kind: 'settle' };
|
||||
|
||||
/**
|
||||
* Whether the pan should be claimed from whatever else might want it (the
|
||||
* list's vertical scroll, mainly). Declining leaves the gesture to the parent.
|
||||
*/
|
||||
export function shouldClaimGesture(
|
||||
g: SwipeGestureState,
|
||||
config: SwipeConfig,
|
||||
openSide: OpenSide,
|
||||
): boolean {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
// An already-open row claims any horizontal drag so the user can drag it
|
||||
// shut, even in the direction whose action is 'none'.
|
||||
if (config.mode === 'reveal' && openSide !== null) return true;
|
||||
if (g.dx > 0 && config.rightAction === 'none') return false;
|
||||
if (g.dx < 0 && config.leftAction === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Row translation for an in-progress drag, clamped to the mode's overshoot. */
|
||||
export function dragOffset(
|
||||
g: SwipeGestureState,
|
||||
config: SwipeConfig,
|
||||
openSide: OpenSide,
|
||||
): number {
|
||||
const reveal = config.mode === 'reveal';
|
||||
const overshoot = reveal ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const base = reveal ? restingOffset(openSide) : 0;
|
||||
return Math.max(-overshoot, Math.min(overshoot, base + g.dx));
|
||||
}
|
||||
|
||||
/** Where the row sits at rest, given which side is open. */
|
||||
export function restingOffset(openSide: OpenSide): number {
|
||||
if (openSide === 'right') return REVEAL_WIDTH;
|
||||
if (openSide === 'left') return -REVEAL_WIDTH;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** What releasing the drag here should do. */
|
||||
export function resolveRelease(
|
||||
g: SwipeGestureState,
|
||||
config: SwipeConfig,
|
||||
openSide: OpenSide,
|
||||
): ReleaseOutcome {
|
||||
if (config.mode === 'reveal') {
|
||||
const finalDx = restingOffset(openSide) + g.dx;
|
||||
if (finalDx > ACTIVATION_THRESHOLD && config.rightAction !== 'none') {
|
||||
return { kind: 'open', side: 'right' };
|
||||
}
|
||||
if (finalDx < -ACTIVATION_THRESHOLD && config.leftAction !== 'none') {
|
||||
return { kind: 'open', side: 'left' };
|
||||
}
|
||||
return { kind: 'close' };
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
const committedRight = g.dx >= COMMIT_THRESHOLD || (g.dx > FLING_DX && g.vx >= FLING_VX);
|
||||
if (committedRight && config.rightAction !== 'none') {
|
||||
return { kind: 'fire', action: config.rightAction, direction: 1 };
|
||||
}
|
||||
const committedLeft = g.dx <= -COMMIT_THRESHOLD || (g.dx < -FLING_DX && g.vx <= -FLING_VX);
|
||||
if (committedLeft && config.leftAction !== 'none') {
|
||||
return { kind: 'fire', action: config.leftAction, direction: -1 };
|
||||
}
|
||||
return { kind: 'settle' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions that visibly remove the row from the list, so the row should fly off
|
||||
* instead of snapping back. Toggle-style actions stay in place.
|
||||
*/
|
||||
export function exitsRow(action: SwipeAction): boolean {
|
||||
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
normalizeAllDayDuration,
|
||||
buildAllDayDuration,
|
||||
getCalendarColor,
|
||||
getEventStartDate,
|
||||
getEventEndDate,
|
||||
timePattern,
|
||||
layoutOverlappingEvents,
|
||||
CALENDAR_COLOR_PALETTE,
|
||||
} from '../calendar-utils';
|
||||
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
|
||||
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
|
||||
// floating `start` + duration instead of producing an Invalid Date, which
|
||||
// would silently drop the event from the day index and every view.
|
||||
it('falls back to start when utcStart is unparseable', () => {
|
||||
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
|
||||
expect(isNaN(d.getTime())).toBe(false);
|
||||
expect(d.getHours()).toBe(9);
|
||||
});
|
||||
|
||||
it('falls back to start + duration when utcEnd is unparseable', () => {
|
||||
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
|
||||
expect(isNaN(end.getTime())).toBe(false);
|
||||
const start = getEventStartDate(ev({}));
|
||||
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timePattern', () => {
|
||||
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
|
||||
expect(timePattern('12h')).toBe('h:mm a');
|
||||
expect(timePattern('24h')).toBe('HH:mm');
|
||||
expect(timePattern(undefined)).toBe('HH:mm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventTimeRange', () => {
|
||||
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
|
||||
const range = eventTimeRange(ev({
|
||||
|
||||
@@ -18,11 +18,22 @@ describe('buildInitialHtml', () => {
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('<p><br></p>');
|
||||
expect(html).toContain('Alice <a@b.com> wrote:');
|
||||
// Default header labels the sender by name only (matches webmail #295).
|
||||
expect(html).toMatch(/On .+, Alice wrote:/);
|
||||
expect(html).not.toContain('a@b.com');
|
||||
expect(html).toContain('<blockquote');
|
||||
expect(html).toContain('line one<br>line two');
|
||||
});
|
||||
|
||||
it('falls back to the email address when the sender has no name', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'a@b.com' },
|
||||
body: 'hi',
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('a@b.com wrote:');
|
||||
});
|
||||
|
||||
it('uses HTML body when present, stripping dangerous tags', () => {
|
||||
const html = buildInitialHtml('reply', {
|
||||
from: { email: 'x@y.com' },
|
||||
@@ -41,7 +52,7 @@ describe('buildInitialHtml', () => {
|
||||
receivedAt: '2026-04-27T10:00:00Z',
|
||||
});
|
||||
expect(html).toContain('Forwarded message');
|
||||
expect(html).toContain('From: Bob <b@c.com>');
|
||||
expect(html).toContain('From: Bob');
|
||||
expect(html).toContain('Subject: Original Subject');
|
||||
expect(html).toContain('forwarded body');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { formatListDate } from '../date-format';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('formatListDate', () => {
|
||||
it('returns empty string for an invalid date', () => {
|
||||
expect(formatListDate('not-a-date', { dateFormat: 'smart', timeFormat: '24h', locale: 'en' })).toBe('');
|
||||
});
|
||||
|
||||
it('relative: minutes / hours / days ago', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
const opts = { dateFormat: 'relative' as const, timeFormat: '24h' as const, locale: 'en' };
|
||||
expect(formatListDate(new Date(Date.now() - 5 * 60000), opts)).toBe('5m ago');
|
||||
expect(formatListDate(new Date(Date.now() - 3 * 3600000), opts)).toBe('3h ago');
|
||||
expect(formatListDate(new Date(Date.now() - 2 * 86400000), opts)).toBe('2d ago');
|
||||
});
|
||||
|
||||
it('relative: "Just now" under a minute', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
expect(
|
||||
formatListDate(new Date(Date.now() - 10_000), { dateFormat: 'relative', timeFormat: '24h', locale: 'en' }),
|
||||
).toBe('Just now');
|
||||
});
|
||||
|
||||
it('smart: same-day shows time only (24h, no AM/PM)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
const out = formatListDate(new Date(), { dateFormat: 'smart', timeFormat: '24h', locale: 'en' });
|
||||
expect(out).not.toMatch(/AM|PM/);
|
||||
expect(out).toMatch(/^\d{1,2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('smart: older than a week shows a numeric date', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-30T12:00:00Z'));
|
||||
const out = formatListDate(new Date('2026-01-01T09:00:00Z'), {
|
||||
dateFormat: 'smart',
|
||||
timeFormat: '24h',
|
||||
locale: 'en',
|
||||
});
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
|
||||
it('full: always includes the year', () => {
|
||||
const out = formatListDate('2026-04-28T15:31:00Z', { dateFormat: 'full', timeFormat: '24h', locale: 'en' });
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
DEFAULT_EMAIL_TEMPLATE,
|
||||
} from '../download-filename';
|
||||
|
||||
const SAMPLE = buildSampleEmail();
|
||||
|
||||
describe('emailExportFilename', () => {
|
||||
it('renders the default template and appends .eml', () => {
|
||||
const name = emailExportFilename(SAMPLE, DEFAULT_EMAIL_TEMPLATE);
|
||||
expect(name.endsWith('.eml')).toBe(true);
|
||||
expect(name).toContain('2026-05-22');
|
||||
expect(name).toContain('Alice');
|
||||
expect(name).toContain('Bob');
|
||||
});
|
||||
|
||||
it('falls back to "email.eml" when the template renders empty', () => {
|
||||
expect(emailExportFilename(SAMPLE, '')).toBe('email.eml');
|
||||
});
|
||||
|
||||
it('applies the lowercase + underscore transforms', () => {
|
||||
const name = emailExportFilename(SAMPLE, {
|
||||
template: '{from} {subject}',
|
||||
lowercase: true,
|
||||
spaceReplacement: 'underscore',
|
||||
});
|
||||
expect(name).toBe(name.toLowerCase());
|
||||
expect(name).not.toContain(' ');
|
||||
});
|
||||
|
||||
it('strips diacritics when requested', () => {
|
||||
const name = emailExportFilename(SAMPLE, {
|
||||
template: '{subject}',
|
||||
stripDiacritics: true,
|
||||
});
|
||||
// "Gerät" → "Gerat"
|
||||
expect(name).toContain('Gerat');
|
||||
expect(name).not.toContain('ä');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachmentDownloadFilename', () => {
|
||||
const attachment = { name: 'Rechnung 2026.pdf', type: 'application/pdf' };
|
||||
|
||||
it('keeps the original name with the {filename} template', () => {
|
||||
const name = attachmentDownloadFilename(SAMPLE, attachment, '{filename}');
|
||||
expect(name).toBe('Rechnung 2026.pdf');
|
||||
});
|
||||
|
||||
it('re-appends the extension when the template omits it', () => {
|
||||
const name = attachmentDownloadFilename(SAMPLE, attachment, '{date_short} {name}');
|
||||
expect(name.endsWith('.pdf')).toBe(true);
|
||||
expect(name).toContain('2026-05-22');
|
||||
});
|
||||
|
||||
it('falls back to the raw name when no email is provided', () => {
|
||||
const name = attachmentDownloadFilename(null, attachment);
|
||||
expect(name).toBe('Rechnung 2026.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildEditorHtml } from '../editor-html';
|
||||
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
||||
|
||||
function extractScript(html: string): string {
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
expect(m, 'page must contain an inline <script>').toBeTruthy();
|
||||
return m![1];
|
||||
}
|
||||
|
||||
describe('buildEditorHtml', () => {
|
||||
const base = { initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS };
|
||||
|
||||
// Regression for issue #11: backslash escapes inside the template literal
|
||||
// are cooked away (`\/` → `/`), which once emitted an invalid regex. That
|
||||
// SyntaxError killed the entire inline script, so the editor never posted
|
||||
// `change` messages and Send stayed disabled on Android/iOS.
|
||||
it('emits an inline script that parses as valid JavaScript', () => {
|
||||
for (const c of [LIGHT_COLORS, DARK_COLORS]) {
|
||||
const script = extractScript(buildEditorHtml({ ...base, c }));
|
||||
// new Function() parses without executing (script needs a DOM to run).
|
||||
expect(() => new Function(script)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits no cooked-away backslashes in the script', () => {
|
||||
// The page script is written escape-free on purpose; a backslash-free
|
||||
// source cannot silently lose escapes to template-literal cooking.
|
||||
const script = extractScript(buildEditorHtml(base));
|
||||
expect(script).not.toContain('\\');
|
||||
});
|
||||
|
||||
it('embeds initial content and placeholder as JSON strings', () => {
|
||||
const html = buildEditorHtml({
|
||||
...base,
|
||||
initialHtml: '<p>He said "hi" & left</p>',
|
||||
placeholder: 'Say "something"…',
|
||||
});
|
||||
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
|
||||
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
|
||||
});
|
||||
|
||||
it('keeps the editor contenteditable and the bridge object wiring', () => {
|
||||
const html = buildEditorHtml(base);
|
||||
expect(html).toContain('<div id="editor" contenteditable="true"');
|
||||
const script = extractScript(html);
|
||||
expect(script).toContain('window.__rne');
|
||||
expect(script).toContain('ReactNativeWebView.postMessage');
|
||||
});
|
||||
|
||||
// Regression for issue #9: sends must be able to read the live DOM back
|
||||
// instead of trusting async `change` messages that can lag or be lost.
|
||||
it('exposes the send-time getHtml readback on the bridge', () => {
|
||||
const script = extractScript(buildEditorHtml(base));
|
||||
expect(script).toContain('getHtml: function (id)');
|
||||
expect(script).toContain("post('htmlSnapshot', { id: id, html: editor.innerHTML })");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Platform.OS is flipped per test, so hoist a mutable object into the module
|
||||
// mock rather than using the fixed 'android' stub from test-setup.
|
||||
const { platform } = vi.hoisted(() => ({ platform: { OS: 'android' as string, Version: 33 } }));
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: platform,
|
||||
NativeModules: {},
|
||||
NativeEventEmitter: class {
|
||||
addListener() {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// A `File` that records both URL forms, so tests can assert which one each
|
||||
// handoff path was given: expo-sharing needs `file://`, an Intent needs
|
||||
// `content://`.
|
||||
vi.mock('expo-file-system', () => {
|
||||
class File {
|
||||
name: string;
|
||||
uri: string;
|
||||
contentUri: string;
|
||||
exists = true;
|
||||
constructor(dir: { uri: string }, name: string) {
|
||||
this.name = name;
|
||||
this.uri = `${dir.uri}${name}`;
|
||||
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
|
||||
}
|
||||
create() {}
|
||||
delete() {}
|
||||
write() {}
|
||||
static downloadFileAsync = vi.fn(async () => undefined);
|
||||
}
|
||||
return {
|
||||
File,
|
||||
Directory: class {},
|
||||
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('expo-intent-launcher', () => ({
|
||||
startActivityAsync: vi.fn(async () => ({ resultCode: -1 })),
|
||||
}));
|
||||
|
||||
vi.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: vi.fn(async () => true),
|
||||
shareAsync: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../client-cert', () => ({
|
||||
getClientCertAlias: vi.fn(async () => null),
|
||||
secureFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { authHeader: 'Bearer token' },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/blob', () => ({
|
||||
getDownloadUrl: vi.fn((blobId: string, name: string) => `https://mail.example.com/${blobId}/${name}`),
|
||||
}));
|
||||
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { shareAttachment, downloadAttachment } from '../email-export';
|
||||
|
||||
const VIEW = 'android.intent.action.VIEW';
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
|
||||
describe('shareAttachment (preview)', () => {
|
||||
beforeEach(() => {
|
||||
platform.OS = 'android';
|
||||
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
|
||||
vi.mocked(Sharing.shareAsync).mockClear();
|
||||
});
|
||||
|
||||
it('hands the file to a viewer app via a VIEW intent on Android', async () => {
|
||||
await shareAttachment('blob-1', 'report.pdf', 'application/pdf');
|
||||
|
||||
expect(IntentLauncher.startActivityAsync).toHaveBeenCalledWith(VIEW, {
|
||||
data: 'content://org.bulwarkmail.mobile.FileSystemFileProvider/report.pdf',
|
||||
type: 'application/pdf',
|
||||
flags: FLAG_GRANT_READ_URI_PERMISSION,
|
||||
});
|
||||
// The share sheet is what the direct handoff exists to avoid.
|
||||
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the share sheet when no app handles the type', async () => {
|
||||
vi.mocked(IntentLauncher.startActivityAsync).mockRejectedValue(
|
||||
new Error('No Activity found to handle Intent'),
|
||||
);
|
||||
|
||||
await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird');
|
||||
|
||||
// file:// — expo-sharing rejects content:// URLs outright.
|
||||
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/weird.xyz', {
|
||||
mimeType: 'application/x-weird',
|
||||
dialogTitle: 'weird.xyz',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a download failure instead of silently falling back', async () => {
|
||||
const { File } = await import('expo-file-system');
|
||||
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
|
||||
|
||||
await expect(shareAttachment('blob-3', 'gone.pdf', 'application/pdf')).rejects.toThrow(
|
||||
'Download failed: 404',
|
||||
);
|
||||
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the share sheet on iOS', async () => {
|
||||
platform.OS = 'ios';
|
||||
|
||||
await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg');
|
||||
|
||||
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
|
||||
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/photo.jpg', {
|
||||
mimeType: 'image/jpeg',
|
||||
dialogTitle: 'photo.jpg',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAttachment (save a copy)', () => {
|
||||
beforeEach(() => {
|
||||
platform.OS = 'android';
|
||||
vi.mocked(IntentLauncher.startActivityAsync).mockClear().mockResolvedValue({ resultCode: -1 });
|
||||
vi.mocked(Sharing.shareAsync).mockClear();
|
||||
});
|
||||
|
||||
it('keeps the share sheet on Android so the save dialog stays reachable', async () => {
|
||||
await downloadAttachment('blob-5', 'report.pdf', 'application/pdf');
|
||||
|
||||
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
|
||||
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
|
||||
mimeType: 'application/pdf',
|
||||
dialogTitle: 'report.pdf',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describeLoginError } from '../login-errors';
|
||||
|
||||
function named(name: string, message: string): Error {
|
||||
const err = new Error(message);
|
||||
err.name = name;
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('describeLoginError', () => {
|
||||
it('turns a rejected credential into an actionable message', () => {
|
||||
const copy = describeLoginError(named('AuthenticationError', 'Invalid credentials'));
|
||||
expect(copy.title).toBe("That didn't work");
|
||||
expect(copy.detail).toMatch(/app password/i);
|
||||
});
|
||||
|
||||
it('names the host it could not reach', () => {
|
||||
const copy = describeLoginError(named('NetworkError', 'Network request failed'), {
|
||||
serverUrl: 'https://mail.example.com',
|
||||
});
|
||||
expect(copy.title).toBe("Can't reach mail.example.com");
|
||||
});
|
||||
|
||||
it('falls back to a generic host label when the server is unknown', () => {
|
||||
const copy = describeLoginError(named('TypeError', 'Network request failed'));
|
||||
expect(copy.title).toBe("Can't reach the server");
|
||||
});
|
||||
|
||||
it('explains a 404 from session discovery as a wrong address', () => {
|
||||
const copy = describeLoginError(new Error('Session discovery failed: 404 Not Found'), {
|
||||
serverUrl: 'https://example.com',
|
||||
});
|
||||
expect(copy.title).toBe('No mail server at example.com');
|
||||
});
|
||||
|
||||
it('flags a certificate problem separately from a connection problem', () => {
|
||||
const copy = describeLoginError(new Error('SSL certificate has expired'), {
|
||||
serverUrl: 'https://mail.example.com',
|
||||
});
|
||||
expect(copy.title).toBe("Couldn't verify mail.example.com");
|
||||
});
|
||||
|
||||
it('explains an expired pairing code', () => {
|
||||
const copy = describeLoginError(new Error('Pairing code is invalid or has expired'));
|
||||
expect(copy.title).toBe('That code has expired');
|
||||
expect(copy.detail).toMatch(/fresh one/i);
|
||||
});
|
||||
|
||||
it('passes through an unrecognised message rather than inventing a cause', () => {
|
||||
const copy = describeLoginError(new Error('Teapot refused to brew'));
|
||||
expect(copy.title).toBe('Sign-in failed');
|
||||
expect(copy.detail).toBe('Teapot refused to brew');
|
||||
});
|
||||
|
||||
it('handles non-Error throws', () => {
|
||||
expect(describeLoginError(undefined).title).toBe('Sign-in failed');
|
||||
expect(describeLoginError('boom').detail).toBe('boom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildMailboxTree,
|
||||
flattenVisible,
|
||||
mailboxesForSiblingOf,
|
||||
ownMailboxes,
|
||||
SHARED_ACCOUNT_NODE_PREFIX,
|
||||
} from '../mailbox-tree';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
|
||||
const RIGHTS: Mailbox['myRights'] = {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
};
|
||||
|
||||
function own(id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: RIGHTS,
|
||||
accountId: 'acc-1',
|
||||
isShared: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function shared(
|
||||
accountId: string,
|
||||
accountName: string,
|
||||
rawId: string,
|
||||
name: string,
|
||||
extra: Partial<Mailbox> = {},
|
||||
): Mailbox {
|
||||
return {
|
||||
...own(`${accountId}:${rawId}`, name, extra),
|
||||
originalId: rawId,
|
||||
accountId,
|
||||
accountName,
|
||||
isShared: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildMailboxTree with shared accounts', () => {
|
||||
it('keeps own folders at the root and groups each shared account under its own node', () => {
|
||||
const tree = buildMailboxTree([
|
||||
own('inbox', 'Inbox', { role: 'inbox' }),
|
||||
own('sent', 'Sent', { role: 'sent' }),
|
||||
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox', unreadEmails: 3 }),
|
||||
shared('grp-1', 'Support', 'arch', 'Archive', { role: 'archive', unreadEmails: 1 }),
|
||||
]);
|
||||
|
||||
expect(tree.map((n) => n.id)).toEqual([
|
||||
'inbox',
|
||||
'sent',
|
||||
`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`,
|
||||
]);
|
||||
|
||||
const accountNode = tree[2];
|
||||
expect(accountNode.isAccountNode).toBe(true);
|
||||
expect(accountNode.name).toBe('Support');
|
||||
// The header rolls up its account's unread so a collapsed section still
|
||||
// signals waiting mail.
|
||||
expect(accountNode.unreadEmails).toBe(4);
|
||||
expect(accountNode.children.map((n) => n.id)).toEqual(['grp-1:inbox', 'grp-1:arch']);
|
||||
expect(accountNode.children.every((n) => n.depth === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('nests a shared account\'s subfolders under their own parent, not the user\'s', () => {
|
||||
const tree = buildMailboxTree([
|
||||
own('inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('grp-1', 'Support', 'sub', 'Escalations', { parentId: 'grp-1:inbox' }),
|
||||
]);
|
||||
|
||||
const accountNode = tree[1];
|
||||
expect(accountNode.children).toHaveLength(1);
|
||||
expect(accountNode.children[0].children.map((n) => n.name)).toEqual(['Escalations']);
|
||||
expect(accountNode.children[0].children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it('gives each shared account its own node', () => {
|
||||
const tree = buildMailboxTree([
|
||||
shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
]);
|
||||
|
||||
expect(tree.map((n) => n.name)).toEqual(['Sales', 'Support']);
|
||||
});
|
||||
|
||||
it('hides a collapsed shared account\'s folders', () => {
|
||||
const tree = buildMailboxTree([
|
||||
own('inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
]);
|
||||
|
||||
const collapsed = flattenVisible(tree, new Set());
|
||||
expect(collapsed.map((n) => n.id)).toEqual(['inbox', `${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]);
|
||||
|
||||
const expanded = flattenVisible(tree, new Set([`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]));
|
||||
expect(expanded.map((n) => n.id)).toEqual([
|
||||
'inbox',
|
||||
`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`,
|
||||
'grp-1:inbox',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('account scoping helpers', () => {
|
||||
const all = [
|
||||
own('inbox', 'Inbox', { role: 'inbox' }),
|
||||
own('trash', 'Trash', { role: 'trash' }),
|
||||
shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('grp-1', 'Support', 'trash', 'Trash', { role: 'trash' }),
|
||||
shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
];
|
||||
|
||||
it('ownMailboxes drops every shared folder', () => {
|
||||
expect(ownMailboxes(all).map((m) => m.id)).toEqual(['inbox', 'trash']);
|
||||
});
|
||||
|
||||
it('scopes to the owning shared account when the current folder is shared', () => {
|
||||
expect(mailboxesForSiblingOf(all, 'grp-1:inbox').map((m) => m.id))
|
||||
.toEqual(['grp-1:inbox', 'grp-1:trash']);
|
||||
});
|
||||
|
||||
it('scopes to the user\'s own folders for an own or unknown folder', () => {
|
||||
expect(mailboxesForSiblingOf(all, 'inbox').map((m) => m.id)).toEqual(['inbox', 'trash']);
|
||||
expect(mailboxesForSiblingOf(all, 'gone').map((m) => m.id)).toEqual(['inbox', 'trash']);
|
||||
expect(mailboxesForSiblingOf(all, null).map((m) => m.id)).toEqual(['inbox', 'trash']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockSecureFetch } = vi.hoisted(() => ({
|
||||
mockSecureFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../client-cert', () => ({
|
||||
secureFetch: mockSecureFetch,
|
||||
}));
|
||||
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth';
|
||||
|
||||
describe('oauth token refresh deduplication', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'same-refresh-token',
|
||||
expiresAt: 123456,
|
||||
tokenEndpoint: 'https://auth.example.com/token',
|
||||
clientId: 'client-id-123',
|
||||
};
|
||||
|
||||
// Set up a mock response that takes a brief moment to resolve
|
||||
mockSecureFetch.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'rotated-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
// Fire two refreshes concurrently
|
||||
const [res1, res2] = await Promise.all([
|
||||
refreshOAuthAccessToken(tokens),
|
||||
refreshOAuthAccessToken(tokens),
|
||||
]);
|
||||
|
||||
// Check that secureFetch was called exactly once
|
||||
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify both calls received the exact same updated token bundle
|
||||
expect(res1.accessToken).toBe('new-access-token');
|
||||
expect(res1.refreshToken).toBe('rotated-refresh-token');
|
||||
expect(res2.accessToken).toBe('new-access-token');
|
||||
expect(res2.refreshToken).toBe('rotated-refresh-token');
|
||||
|
||||
// Fire another refresh after the first completes (now the map should be empty for this token)
|
||||
mockSecureFetch.mockClear();
|
||||
const res3 = await refreshOAuthAccessToken(tokens);
|
||||
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
|
||||
expect(res3.accessToken).toBe('new-access-token');
|
||||
});
|
||||
|
||||
it('should propagate errors to all waiters if the HTTP request fails', async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'failing-refresh-token',
|
||||
expiresAt: 123456,
|
||||
tokenEndpoint: 'https://auth.example.com/token',
|
||||
clientId: 'client-id-123',
|
||||
};
|
||||
|
||||
mockSecureFetch.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
} as Response;
|
||||
});
|
||||
|
||||
// Fire two refreshes concurrently and check that both reject with the same error
|
||||
const promise1 = refreshOAuthAccessToken(tokens);
|
||||
const promise2 = refreshOAuthAccessToken(tokens);
|
||||
|
||||
await expect(promise1).rejects.toThrow('Token refresh failed: 400');
|
||||
await expect(promise2).rejects.toThrow('Token refresh failed: 400');
|
||||
|
||||
expect(mockSecureFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Provide the Android native FCM surface setupPushNotifications needs. The
|
||||
// global test-setup mocks react-native with an empty NativeModules, so override
|
||||
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
|
||||
// the runtime permission request).
|
||||
vi.mock('react-native', () => {
|
||||
class NativeEventEmitter {
|
||||
addListener() {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
}
|
||||
return {
|
||||
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
|
||||
NativeModules: {
|
||||
BulwarkFcm: {
|
||||
getToken: vi.fn(async () => 'fcm-token-xyz'),
|
||||
deleteToken: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
NativeEventEmitter,
|
||||
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
|
||||
}));
|
||||
|
||||
vi.mock('../../api/push', () => ({
|
||||
listPushSubscriptions: vi.fn(async () => []),
|
||||
createPushSubscription: vi.fn(async () => 'new-server-id'),
|
||||
verifyPushSubscription: vi.fn(async () => undefined),
|
||||
destroyPushSubscription: vi.fn(async () => undefined),
|
||||
updatePushSubscription: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
|
||||
import {
|
||||
listPushSubscriptions,
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
} from '../../api/push';
|
||||
import { generateAccountId } from '../account-utils';
|
||||
|
||||
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
|
||||
const RELAY = 'https://relay.example.com';
|
||||
|
||||
// State the fake relay reports for each foreign deviceClientId's /active probe.
|
||||
type RelayState = 'dead' | 'live' | 'unknown';
|
||||
|
||||
function installFetch(states: Record<string, RelayState>): void {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/register')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
|
||||
if (active) {
|
||||
const dcid = decodeURIComponent(active[1]);
|
||||
const state = states[dcid] ?? 'unknown';
|
||||
if (state === 'unknown') {
|
||||
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
|
||||
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
|
||||
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
|
||||
|
||||
function sub(id: string, deviceClientId: string) {
|
||||
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
|
||||
}
|
||||
|
||||
describe('setupPushNotifications leftover reaping', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await AsyncStorage.clear();
|
||||
// Pin our deviceClientId so we control which leftovers are "ours".
|
||||
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
|
||||
});
|
||||
|
||||
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
|
||||
listMock.mockResolvedValue([
|
||||
sub('own-old', OUR_DCID), // our own previous attempt -> reap
|
||||
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
|
||||
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
|
||||
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
|
||||
]);
|
||||
installFetch({
|
||||
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
|
||||
livelivelivelivelivelivelivelive: 'live',
|
||||
unknwunknwunknwunknwunknwunknwun: 'unknown',
|
||||
});
|
||||
|
||||
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
const reaped = destroyMock.mock.calls.map((c) => c[0]);
|
||||
expect(reaped).toContain('own-old');
|
||||
expect(reaped).toContain('foreign-dead');
|
||||
expect(reaped).not.toContain('foreign-live');
|
||||
expect(reaped).not.toContain('foreign-unknown');
|
||||
});
|
||||
|
||||
it('keeps foreign subs when the relay probe fails (network error)', async () => {
|
||||
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url.includes('/api/push/register')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
|
||||
}
|
||||
if (url.includes('/api/push/active/')) throw new Error('network down');
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
|
||||
});
|
||||
|
||||
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
installFetch({});
|
||||
|
||||
// Fire several overlapping setups, as App.tsx does while auth settles and
|
||||
// on FCM token refresh. Only one underlying JMAP subscription must be made.
|
||||
const results = await Promise.all([
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
setupPushNotifications({ relayBaseUrl: RELAY }),
|
||||
]);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,54 @@ describe('expandRecurringEvents - weekly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - interval > 1 (#13)', () => {
|
||||
it('expands an every-5-weeks rule', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'weekly', interval: 5 };
|
||||
const result = expandRecurringEvents(
|
||||
[ev({ recurrenceRules: [rule] })], // starts Mon 2026-04-06
|
||||
'2026-04-01T00:00:00',
|
||||
'2026-08-01T00:00:00',
|
||||
);
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2026-04-06T09:00:00',
|
||||
'2026-05-11T09:00:00',
|
||||
'2026-06-15T09:00:00',
|
||||
'2026-07-20T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands an every-5-weeks series started long before the visible range', () => {
|
||||
const event = ev({
|
||||
start: '2025-01-06T09:00:00', // Monday
|
||||
recurrenceRules: [{ frequency: 'weekly', interval: 5 }],
|
||||
});
|
||||
// Occurrences fall every 35 days from 2025-01-06 (…2026-04-06, 05-11,
|
||||
// 06-15, 07-20…) — only those inside the window are returned.
|
||||
const result = expandRecurringEvents([event], '2026-05-01T00:00:00', '2026-08-01T00:00:00');
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2026-05-11T09:00:00',
|
||||
'2026-06-15T09:00:00',
|
||||
'2026-07-20T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands an every-2-months rule', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', interval: 2 };
|
||||
const result = expandRecurringEvents(
|
||||
[ev({ recurrenceRules: [rule] })],
|
||||
'2026-04-01T00:00:00',
|
||||
'2026-12-31T00:00:00',
|
||||
);
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2026-04-06T09:00:00',
|
||||
'2026-06-06T09:00:00',
|
||||
'2026-08-06T09:00:00',
|
||||
'2026-10-06T09:00:00',
|
||||
'2026-12-06T09:00:00',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - monthly', () => {
|
||||
it('expands monthly on the 6th', () => {
|
||||
const rule: RecurrenceRule = { frequency: 'monthly', count: 3 };
|
||||
@@ -174,3 +222,77 @@ describe('expandRecurringEvents - range filtering', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - excludedRecurrenceRules', () => {
|
||||
it('removes occurrences generated by excluded rules', () => {
|
||||
const event = ev({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ frequency: 'daily' }],
|
||||
excludedRecurrenceRules: [{
|
||||
frequency: 'weekly',
|
||||
byDay: [{ day: 'tu' }],
|
||||
}],
|
||||
});
|
||||
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
|
||||
const result = expandRecurringEvents([event], '2025-01-06T00:00:00', '2025-01-13T00:00:00');
|
||||
const days = result.map((e) => e.start.substring(0, 10));
|
||||
expect(days).not.toContain('2025-01-07');
|
||||
expect(days).toContain('2025-01-06');
|
||||
expect(days).toContain('2025-01-08');
|
||||
expect(result).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - long-running series', () => {
|
||||
it('expands a daily series started years before the visible range', () => {
|
||||
const event = ev({
|
||||
start: '2022-01-03T09:00:00',
|
||||
recurrenceRules: [{ frequency: 'daily' }],
|
||||
});
|
||||
const result = expandRecurringEvents([event], '2025-06-02T00:00:00', '2025-06-05T00:00:00');
|
||||
expect(result.map((e) => e.start)).toEqual([
|
||||
'2025-06-02T09:00:00',
|
||||
'2025-06-03T09:00:00',
|
||||
'2025-06-04T09:00:00',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands a weekly series started years before the visible range', () => {
|
||||
const event = ev({
|
||||
start: '2020-01-06T09:00:00', // Monday
|
||||
recurrenceRules: [{ frequency: 'weekly' }],
|
||||
});
|
||||
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
const days = result.map((e) => e.start.substring(0, 10));
|
||||
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
|
||||
});
|
||||
|
||||
it('still respects count for old series (no fast-forward shortcut)', () => {
|
||||
const event = ev({
|
||||
start: '2025-01-06T09:00:00',
|
||||
recurrenceRules: [{ frequency: 'daily', count: 5 }],
|
||||
});
|
||||
// Range far after the 5 occurrences ran out
|
||||
const result = expandRecurringEvents([event], '2025-06-01T00:00:00', '2025-06-30T00:00:00');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandRecurringEvents - event timezone DST handling', () => {
|
||||
it('keeps wall time across a DST transition in the event timezone', () => {
|
||||
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
|
||||
const event = ev({
|
||||
start: '2025-03-03T10:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
utcStart: '2025-03-03T15:00:00Z',
|
||||
utcEnd: '2025-03-03T16:00:00Z',
|
||||
recurrenceRules: [{ frequency: 'weekly' }],
|
||||
});
|
||||
const result = expandRecurringEvents([event], '2025-03-03T00:00:00', '2025-03-17T00:00:00');
|
||||
const utcStarts = result.map((e) => e.utcStart);
|
||||
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
|
||||
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
|
||||
const utcEnds = result.map((e) => e.utcEnd);
|
||||
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockSecureFetch } = vi.hoisted(() => ({
|
||||
mockSecureFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../client-cert', () => ({
|
||||
secureFetch: mockSecureFetch,
|
||||
}));
|
||||
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
emailDomain,
|
||||
isEmailAddress,
|
||||
serverCandidates,
|
||||
probeJmapServer,
|
||||
discoverServerForEmail,
|
||||
} from '../server-discovery';
|
||||
|
||||
const SESSION_DOC = { capabilities: {}, apiUrl: '/jmap/', primaryAccounts: {} };
|
||||
|
||||
/**
|
||||
* Answers `status` for the listed base URLs and rejects everything else. A 200
|
||||
* yields a JMAP session document unless a body is given explicitly.
|
||||
*/
|
||||
function respondFor(statuses: Record<string, number | { status: number; body: unknown }>) {
|
||||
mockSecureFetch.mockImplementation(async (url: string) => {
|
||||
const base = url.replace(/\/\.well-known\/jmap$/, '');
|
||||
const entry = statuses[base];
|
||||
if (entry === undefined) throw new TypeError('Network request failed');
|
||||
const { status, body } =
|
||||
typeof entry === 'number' ? { status: entry, body: SESSION_DOC } : entry;
|
||||
return { status, json: async () => body } as Response;
|
||||
});
|
||||
}
|
||||
|
||||
describe('normalizeServerUrl', () => {
|
||||
it('adds https:// to a bare host', () => {
|
||||
expect(normalizeServerUrl('mail.example.com')).toBe('https://mail.example.com');
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(normalizeServerUrl('https://mail.example.com//')).toBe('https://mail.example.com');
|
||||
});
|
||||
|
||||
it('strips a pasted JMAP session endpoint', () => {
|
||||
expect(normalizeServerUrl('https://mail.example.com/.well-known/jmap')).toBe(
|
||||
'https://mail.example.com',
|
||||
);
|
||||
expect(normalizeServerUrl('mail.example.com/jmap')).toBe('https://mail.example.com');
|
||||
});
|
||||
|
||||
it('keeps an explicit http scheme and port', () => {
|
||||
expect(normalizeServerUrl('http://localhost:8080')).toBe('http://localhost:8080');
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(normalizeServerUrl('ftp://mail.example.com')).toBeNull();
|
||||
expect(normalizeServerUrl('bulwarkmail://connect')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects input that is not a host', () => {
|
||||
expect(normalizeServerUrl('')).toBeNull();
|
||||
expect(normalizeServerUrl(' ')).toBeNull();
|
||||
expect(normalizeServerUrl('not a host')).toBeNull();
|
||||
expect(normalizeServerUrl('ada@example.com')).toBeNull();
|
||||
// A single label can't be a public host — except localhost.
|
||||
expect(normalizeServerUrl('example')).toBeNull();
|
||||
expect(normalizeServerUrl('localhost')).toBe('https://localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emailDomain', () => {
|
||||
it('extracts and lowercases the domain', () => {
|
||||
expect(emailDomain('Ada@Example.COM')).toBe('example.com');
|
||||
});
|
||||
|
||||
it('rejects anything that is not an address', () => {
|
||||
expect(emailDomain('ada')).toBeNull();
|
||||
expect(emailDomain('ada@localhost')).toBeNull();
|
||||
expect(emailDomain('a@b@example.com')).toBeNull();
|
||||
expect(isEmailAddress('ada@example.com')).toBe(true);
|
||||
expect(isEmailAddress('ada')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverCandidates', () => {
|
||||
it('orders the bare domain ahead of the conventional subdomains', () => {
|
||||
expect(serverCandidates('example.com')).toEqual([
|
||||
'https://example.com',
|
||||
'https://mail.example.com',
|
||||
'https://webmail.example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeJmapServer', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('treats 200 as a hit', async () => {
|
||||
respondFor({ 'https://mail.example.com': 200 });
|
||||
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('treats 401 as a hit — an unauthenticated probe is meant to be rejected', async () => {
|
||||
respondFor({ 'https://mail.example.com': 401 });
|
||||
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('treats 404 and transport failures as a miss', async () => {
|
||||
respondFor({ 'https://mail.example.com': 404 });
|
||||
await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(false);
|
||||
await expect(probeJmapServer('https://nothing.example.com')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a 200 that is not a session document', async () => {
|
||||
// A catch-all web server or captive portal answering 200 for every path.
|
||||
respondFor({ 'https://example.com': { status: 200, body: '<!doctype html>' } });
|
||||
await expect(probeJmapServer('https://example.com')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a 200 whose body is not JSON at all', async () => {
|
||||
mockSecureFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <');
|
||||
},
|
||||
} as unknown as Response);
|
||||
await expect(probeJmapServer('https://example.com')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('gives up at the deadline instead of hanging', async () => {
|
||||
mockSecureFetch.mockImplementation(
|
||||
() => new Promise(() => {/* never settles */}),
|
||||
);
|
||||
await expect(probeJmapServer('https://slow.example.com', 20)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverServerForEmail', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns a known account server for the same domain without probing', async () => {
|
||||
respondFor({});
|
||||
const found = await discoverServerForEmail('ada@example.com', {
|
||||
knownServerUrls: ['https://mail.example.com'],
|
||||
});
|
||||
expect(found).toBe('https://mail.example.com');
|
||||
expect(mockSecureFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the bare domain when several hosts answer', async () => {
|
||||
respondFor({ 'https://example.com': 401, 'https://mail.example.com': 200 });
|
||||
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('falls back to mail.<domain> when the bare domain is not a JMAP server', async () => {
|
||||
respondFor({ 'https://example.com': 404, 'https://mail.example.com': 401 });
|
||||
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe(
|
||||
'https://mail.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a bare domain that only serves a marketing site', async () => {
|
||||
respondFor({
|
||||
'https://example.com': { status: 200, body: '<!doctype html>' },
|
||||
'https://mail.example.com': 200,
|
||||
});
|
||||
await expect(discoverServerForEmail('ada@example.com')).resolves.toBe(
|
||||
'https://mail.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when nothing answers, so the caller can ask', async () => {
|
||||
respondFor({});
|
||||
await expect(discoverServerForEmail('ada@example.org')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without touching the network for a non-address', async () => {
|
||||
respondFor({});
|
||||
await expect(discoverServerForEmail('ada')).resolves.toBeNull();
|
||||
expect(mockSecureFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
|
||||
export function getEventStartDate(
|
||||
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
|
||||
): Date {
|
||||
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
|
||||
return parseISO(source);
|
||||
// Prefer utcStart for timed events but fall back to start if utcStart is
|
||||
// missing or unparseable — a malformed utcStart used to surface as an
|
||||
// Invalid Date that silently dropped the event from every view (#316).
|
||||
if (!event.showWithoutTime && event.utcStart) {
|
||||
const utc = parseISO(event.utcStart);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
return parseISO(event.start);
|
||||
}
|
||||
|
||||
export function getEventEndDate(event: CalendarEvent): Date {
|
||||
if (!event.showWithoutTime && event.utcEnd) {
|
||||
return parseISO(event.utcEnd);
|
||||
const utc = parseISO(event.utcEnd);
|
||||
if (!isNaN(utc.getTime())) return utc;
|
||||
}
|
||||
const start = getEventStartDate(event);
|
||||
if (!event.duration) return start;
|
||||
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
|
||||
return subMilliseconds(end, 1);
|
||||
}
|
||||
|
||||
// ─── Time-of-day formatting ──────────────────────────────
|
||||
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
|
||||
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
|
||||
// thread the `calendarTimeFormat` setting through so the calendar renders
|
||||
// times in the same format the webmail does.
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
|
||||
export function timePattern(timeFormat: TimeFormat | undefined): string {
|
||||
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
|
||||
}
|
||||
|
||||
export interface EventTimeRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
@@ -435,12 +453,63 @@ export function getEventColor(
|
||||
event: Pick<CalendarEvent, 'calendarIds' | 'color'>,
|
||||
calendars: Calendar[],
|
||||
): string {
|
||||
if (event.color) return event.color;
|
||||
const calendarId = Object.keys(event.calendarIds || {})[0];
|
||||
const cal = calendars.find((c) => c.id === calendarId);
|
||||
// A local color override on a shared calendar wins over per-event colors,
|
||||
// so the whole shared calendar paints uniformly in the viewer's chosen hue.
|
||||
if (cal?.colorIsLocalOverride && cal.color) return cal.color;
|
||||
if (event.color) return event.color;
|
||||
return getCalendarColor(cal);
|
||||
}
|
||||
|
||||
// ─── Per-viewer colors for shared calendars (#345) ───────
|
||||
// Shared calendars are recolored locally only: the viewer usually can't write
|
||||
// the owner's calendar, and doing so would recolor it for everyone. Overrides
|
||||
// live in the settings store keyed by sharedCalendarColorKey().
|
||||
|
||||
/**
|
||||
* Stable key for a shared calendar's local color override. Built from the
|
||||
* owning JMAP account + the calendar id so it stays unique across accounts.
|
||||
*/
|
||||
export function sharedCalendarColorKey(
|
||||
cal: Pick<Calendar, 'id' | 'accountId'>,
|
||||
): string {
|
||||
return `${cal.accountId ?? ''}|${cal.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random palette color not present in `usedColors`. Once every palette
|
||||
* entry is taken, fall back to a random palette color (collisions are
|
||||
* unavoidable past CALENDAR_PALETTE.length calendars).
|
||||
*/
|
||||
export function pickUnusedCalendarColor(usedColors: Iterable<string>): string {
|
||||
const used = new Set<string>();
|
||||
for (const c of usedColors) {
|
||||
if (c) used.add(c.toLowerCase());
|
||||
}
|
||||
const available = CALENDAR_PALETTE.filter((c) => !used.has(c.toLowerCase()));
|
||||
const pool = available.length > 0 ? available : CALENDAR_PALETTE;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply each shared calendar's local color override (per-viewer recolor).
|
||||
* The override replaces the calendar's color and wins over per-event colors
|
||||
* via the `colorIsLocalOverride` flag (see getEventColor). Personal calendars
|
||||
* are passed through untouched.
|
||||
*/
|
||||
export function applySharedCalendarColors(
|
||||
calendars: Calendar[],
|
||||
overrides: Record<string, string>,
|
||||
): Calendar[] {
|
||||
return calendars.map((cal) => {
|
||||
if (!cal.isShared) return cal;
|
||||
const override = overrides[sharedCalendarColorKey(cal)];
|
||||
if (!override) return cal;
|
||||
return { ...cal, color: override, colorIsLocalOverride: true };
|
||||
});
|
||||
}
|
||||
|
||||
export function getPrimaryCalendarId(
|
||||
event: Pick<CalendarEvent, 'calendarIds'>,
|
||||
): string | undefined {
|
||||
|
||||
+45
-18
@@ -3,6 +3,7 @@
|
||||
// All values are HTML-escaped before insertion into the document.
|
||||
|
||||
import { escapeHtml, stripDangerousTags } from './email-html';
|
||||
import type { TimeFormat } from '../stores/settings-store';
|
||||
|
||||
export interface ReplyMeta {
|
||||
from?: { email?: string; name?: string };
|
||||
@@ -14,35 +15,62 @@ export interface ReplyMeta {
|
||||
receivedAt?: string;
|
||||
}
|
||||
|
||||
export interface QuoteHeaderOptions {
|
||||
timeFormat?: TimeFormat;
|
||||
locale?: string;
|
||||
unknownLabel?: string;
|
||||
}
|
||||
|
||||
function escapeForHtmlBody(text: string): string {
|
||||
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function senderLabel(from?: { email?: string; name?: string }): string {
|
||||
if (!from) return '';
|
||||
if (from.name && from.email) return `${from.name} <${from.email}>`;
|
||||
return from.name || from.email || '';
|
||||
// Webmail's default quote header labels the sender by display name when there
|
||||
// is one, falling back to the bare address — not "Name <addr>".
|
||||
function senderName(from?: { email?: string; name?: string }, unknownLabel = 'Unknown'): string {
|
||||
if (!from) return unknownLabel;
|
||||
return from.name || from.email || unknownLabel;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
// Mirror the webmail quote-header date: locale- and 12/24h-aware, with a short
|
||||
// weekday (lib/quote-header.ts → formatDateTime with weekday/year/month/day).
|
||||
function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString();
|
||||
const intlLocale = !locale || locale === 'en' ? 'en-US' : locale;
|
||||
return d.toLocaleString(intlLocale, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: timeFormat === '12h',
|
||||
});
|
||||
}
|
||||
|
||||
const BLOCKQUOTE_STYLE = 'margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex';
|
||||
|
||||
/**
|
||||
* Build the seed HTML for a new compose / reply / forward. Returns an empty
|
||||
* paragraph for plain compose (so the contenteditable has a caret target).
|
||||
*
|
||||
* The reply/forward quote header mirrors the webmail default header
|
||||
* (`lib/quote-header.ts`): a reply gets "On <date>, <sender> wrote:" above a
|
||||
* blockquoted body; a forward gets a From/Date/Subject block followed by the
|
||||
* original body (not blockquoted).
|
||||
*/
|
||||
export function buildInitialHtml(
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward',
|
||||
reply?: ReplyMeta | null,
|
||||
opts: QuoteHeaderOptions = {},
|
||||
): string {
|
||||
if (!reply || mode === 'compose') return '<p><br></p>';
|
||||
|
||||
const date = formatDate(reply.receivedAt);
|
||||
const fromStr = senderLabel(reply.from) || 'Unknown';
|
||||
const { timeFormat = '24h', locale, unknownLabel = 'Unknown' } = opts;
|
||||
const date = formatQuoteDate(reply.receivedAt, timeFormat, locale);
|
||||
const fromStr = senderName(reply.from, unknownLabel);
|
||||
const subject = reply.subject ?? '';
|
||||
|
||||
const quotedBody = reply.htmlBody
|
||||
@@ -54,27 +82,26 @@ export function buildInitialHtml(
|
||||
if (!quotedBody) return '<p><br></p>';
|
||||
|
||||
if (mode === 'forward') {
|
||||
// Forwarded message block (From/Date/Subject), body appended unquoted.
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>---------- Forwarded message ----------<br>',
|
||||
`From: ${escapeHtml(fromStr)}<br>`,
|
||||
date ? `Date: ${escapeHtml(date)}<br>` : '',
|
||||
subject ? `Subject: ${escapeHtml(subject)}<br>` : '',
|
||||
reply.to && reply.to.length
|
||||
? `To: ${escapeHtml(reply.to.map(senderLabel).filter(Boolean).join(', '))}<br>`
|
||||
: '',
|
||||
`Date: ${escapeHtml(date)}<br>`,
|
||||
`Subject: ${escapeHtml(subject)}<br><br>`,
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
quotedBody,
|
||||
].join('');
|
||||
}
|
||||
|
||||
// reply / replyAll
|
||||
const headerLine = date
|
||||
? `On ${escapeHtml(date)}, ${escapeHtml(fromStr)} wrote:`
|
||||
: `${escapeHtml(fromStr)} wrote:`;
|
||||
return [
|
||||
'<p><br></p>',
|
||||
'<div>',
|
||||
`On ${escapeHtml(date || 'a previous date')}, ${escapeHtml(fromStr)} wrote:`,
|
||||
'</div>',
|
||||
`<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedBody}</blockquote>`,
|
||||
`<div>${headerLine}<br></div>`,
|
||||
`<blockquote style="${BLOCKQUOTE_STYLE}">${quotedBody}</blockquote>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { DateFormat, TimeFormat } from '../stores/settings-store';
|
||||
|
||||
/**
|
||||
* Formats a received-at date for the email list. Mirrors the webmail
|
||||
* `formatDate` helper (lib/utils.ts) so both clients render list dates the
|
||||
* same way. The style is controlled by the `dateFormat` user setting:
|
||||
*
|
||||
* - `smart` (default) — locale-aware, age-bucketed:
|
||||
* today → time only ("15:31" or "3:31 PM")
|
||||
* last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
|
||||
* older → full locale date ("28.04.2026", "04/28/2026")
|
||||
* - `relative` — relative format ("1h ago", "2d ago").
|
||||
* - `full` — always the full locale date+time.
|
||||
*
|
||||
* `locale` is the language subtag from the locale store (e.g. "en", "de").
|
||||
*/
|
||||
export function formatListDate(
|
||||
date: Date | string,
|
||||
opts: { dateFormat: DateFormat; timeFormat: TimeFormat; locale: string },
|
||||
): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
|
||||
const { dateFormat, timeFormat } = opts;
|
||||
const localeRaw = opts.locale;
|
||||
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : 'en';
|
||||
// `en` alone resolves to en-US in Intl; everything else uses the language
|
||||
// subtag as-is and lets the runtime pick a sensible default region.
|
||||
const intlLocale = locale === 'en' ? 'en-US' : locale;
|
||||
const hour12 = timeFormat === '12h';
|
||||
|
||||
if (dateFormat === 'relative') {
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (dateFormat === 'full') {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
});
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
});
|
||||
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (isSameDay) return timeStr;
|
||||
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(intlLocale, { weekday: 'short' })
|
||||
.replace(/\.$/, '');
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
// Ported from the webmail `lib/download-filename.ts` so exported `.eml` files and
|
||||
// downloaded attachments get the same templated, cross-platform-safe filenames.
|
||||
|
||||
// Allow any Unicode letter or digit (so umlauts, accents, CJK survive) plus a
|
||||
// small set of safe punctuation. Everything else - emojis, RTL/zero-width
|
||||
// marks, control chars, and the filesystem-reserved `<>:"/\|?*` - collapses
|
||||
// to `_`. Keeps filenames usable across Android/iOS/desktop.
|
||||
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
|
||||
|
||||
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
|
||||
|
||||
export interface FilenameTransformOptions {
|
||||
spaceReplacement?: SpaceReplacement;
|
||||
lowercase?: boolean;
|
||||
stripDiacritics?: boolean;
|
||||
collapseSeparators?: boolean;
|
||||
}
|
||||
|
||||
export interface EmailFilenameOptions extends FilenameTransformOptions {
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
|
||||
spaceReplacement: 'keep',
|
||||
lowercase: false,
|
||||
stripDiacritics: false,
|
||||
collapseSeparators: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_EMAIL_TEMPLATE = '{date} ({from}-{to}) {subject}';
|
||||
export const DEFAULT_ATTACHMENT_TEMPLATE = '{filename}';
|
||||
|
||||
export const EMAIL_TOKENS: { token: string; description: string }[] = [
|
||||
{ token: 'date', description: 'Full date and time, e.g. 2026-05-22 14.05.33' },
|
||||
{ token: 'date_short', description: 'Date only, e.g. 2026-05-22' },
|
||||
{ token: 'time', description: 'Time only, e.g. 14.05.33' },
|
||||
{ token: 'year', description: '4-digit year' },
|
||||
{ token: 'month', description: '2-digit month' },
|
||||
{ token: 'day', description: '2-digit day' },
|
||||
{ token: 'from', description: 'Sender display name (falls back to email user part)' },
|
||||
{ token: 'from_email', description: 'Sender full email address' },
|
||||
{ token: 'from_name', description: 'Sender name only' },
|
||||
{ token: 'to', description: 'First recipient display name' },
|
||||
{ token: 'to_email', description: 'First recipient full email address' },
|
||||
{ token: 'to_name', description: 'First recipient name only' },
|
||||
{ token: 'subject', description: 'Email subject' },
|
||||
];
|
||||
|
||||
export const ATTACHMENT_TOKENS: { token: string; description: string }[] = [
|
||||
...EMAIL_TOKENS,
|
||||
{ token: 'filename', description: 'Original attachment filename including extension' },
|
||||
{ token: 'name', description: 'Attachment filename without extension' },
|
||||
{ token: 'ext', description: 'Attachment file extension without leading dot' },
|
||||
];
|
||||
|
||||
function sanitizePart(input: string, maxLen = 80): string {
|
||||
const cleaned = input
|
||||
.replace(SAFE_CHARS, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^[._-]+|[._-]+$/g, '');
|
||||
return cleaned.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
|
||||
let s = input;
|
||||
if (opts.stripDiacritics) {
|
||||
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
|
||||
// combining marks then leaves plain ASCII letters.
|
||||
s = s.normalize('NFD').replace(/\p{M}+/gu, '');
|
||||
}
|
||||
const repl = opts.spaceReplacement ?? 'keep';
|
||||
if (repl === 'underscore') s = s.replace(/ +/g, '_');
|
||||
else if (repl === 'dash') s = s.replace(/ +/g, '-');
|
||||
if (opts.collapseSeparators ?? true) {
|
||||
s = s.replace(/_+/g, '_').replace(/-+/g, '-').replace(/ +/g, ' ');
|
||||
}
|
||||
if (opts.lowercase) s = s.toLocaleLowerCase();
|
||||
return s.replace(/^[._\- ]+|[._\- ]+$/g, '');
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function dateParts(iso: string | null | undefined) {
|
||||
const d = iso ? new Date(iso) : new Date();
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return {
|
||||
date: '0000-00-00 00.00.00',
|
||||
date_short: '0000-00-00',
|
||||
time: '00.00.00',
|
||||
year: '0000',
|
||||
month: '00',
|
||||
day: '00',
|
||||
};
|
||||
}
|
||||
const year = String(d.getFullYear());
|
||||
const month = pad2(d.getMonth() + 1);
|
||||
const day = pad2(d.getDate());
|
||||
const time = `${pad2(d.getHours())}.${pad2(d.getMinutes())}.${pad2(d.getSeconds())}`;
|
||||
return {
|
||||
date: `${year}-${month}-${day} ${time}`,
|
||||
date_short: `${year}-${month}-${day}`,
|
||||
time,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
};
|
||||
}
|
||||
|
||||
function addrLabel(addr: { name?: string | null; email?: string } | undefined): {
|
||||
name: string;
|
||||
email: string;
|
||||
label: string;
|
||||
} {
|
||||
if (!addr) return { name: '', email: '', label: 'unknown' };
|
||||
const name = (addr.name && addr.name.trim()) || '';
|
||||
const email = addr.email || '';
|
||||
const label = name || email.split('@')[0] || email || 'unknown';
|
||||
return { name, email, label };
|
||||
}
|
||||
|
||||
export function emailVars(email: Email): Record<string, string> {
|
||||
const dp = dateParts(email.receivedAt || email.sentAt);
|
||||
const from = addrLabel(email.from?.[0]);
|
||||
const to = addrLabel(email.to?.[0]);
|
||||
return {
|
||||
...dp,
|
||||
from: from.label,
|
||||
from_email: from.email,
|
||||
from_name: from.name,
|
||||
to: to.label,
|
||||
to_email: to.email,
|
||||
to_name: to.name,
|
||||
subject: email.subject || 'no subject',
|
||||
};
|
||||
}
|
||||
|
||||
export interface AttachmentLike {
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
export function attachmentVars(email: Email, attachment: AttachmentLike): Record<string, string> {
|
||||
const filename = (attachment.name || 'attachment').trim();
|
||||
const dot = filename.lastIndexOf('.');
|
||||
const hasExt = dot > 0 && dot < filename.length - 1;
|
||||
const name = hasExt ? filename.slice(0, dot) : filename;
|
||||
const ext = hasExt ? filename.slice(dot + 1) : '';
|
||||
return {
|
||||
...emailVars(email),
|
||||
filename,
|
||||
name,
|
||||
ext,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRaw(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return '';
|
||||
return sanitizePart(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function emailExportFilename(
|
||||
email: Email,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const opts = typeof options === 'string' ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
|
||||
const rendered = renderRaw(template, emailVars(email));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || 'email';
|
||||
return `${stem}.eml`;
|
||||
}
|
||||
|
||||
export function attachmentDownloadFilename(
|
||||
email: Email | null | undefined,
|
||||
attachment: AttachmentLike,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const opts = typeof options === 'string' ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
if (!email) {
|
||||
const filename = (attachment.name || 'attachment').trim();
|
||||
const cleaned = sanitizePart(filename, 200) || 'attachment';
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const vars = attachmentVars(email, attachment);
|
||||
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return '';
|
||||
// Preserve dots in {filename} so the original extension survives the
|
||||
// sanitiser (it strips trailing dots otherwise).
|
||||
return key === 'filename' ? value.replace(SAFE_CHARS, '_') : sanitizePart(value);
|
||||
});
|
||||
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
|
||||
const cleaned = sanitizePart(rendered, 200) || 'attachment';
|
||||
if (templateMentionsExt) {
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
|
||||
const ext = vars.ext;
|
||||
if (!ext) return transformedStem;
|
||||
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
|
||||
return `${transformedStem}.${transformedExt}`;
|
||||
}
|
||||
|
||||
// Build a synthetic email for previewing templates in the settings UI.
|
||||
export function buildSampleEmail(): Email {
|
||||
const iso = '2026-05-22T14:05:33Z';
|
||||
return {
|
||||
id: 'sample-1',
|
||||
threadId: 'sample-thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 12345,
|
||||
receivedAt: iso,
|
||||
sentAt: iso,
|
||||
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
|
||||
to: [{ name: 'Bob Recipient', email: 'bob@example.com' }],
|
||||
cc: [],
|
||||
subject: 'Benachrichtigung von Ihrem Gerät',
|
||||
preview: '',
|
||||
hasAttachment: true,
|
||||
blobId: 'sample-blob',
|
||||
} as Email;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Builds the self-contained HTML document for the RichTextEditor WebView.
|
||||
// Kept as a pure module (no react-native imports) so tests can assert the
|
||||
// embedded page script stays syntactically valid JavaScript.
|
||||
//
|
||||
// IMPORTANT: the page script lives inside a JS template literal. Backslash
|
||||
// escapes are "cooked" by the template (`\/` becomes `/`, `\s` becomes `s`),
|
||||
// which once shipped an invalid regex that killed the whole inline script
|
||||
// with a SyntaxError (issue #11 — Send stayed disabled because the editor
|
||||
// never posted `change` messages). Never rely on a backslash reaching the
|
||||
// page verbatim; write escape-free code or double the backslash.
|
||||
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||
// user types, and the parent ScrollView handles overflow.
|
||||
export const MIN_EDITOR_HEIGHT = 220;
|
||||
|
||||
export function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
c: ThemePalette;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
overflow-y: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
#editor {
|
||||
outline: none;
|
||||
padding: 12px 16px 24px;
|
||||
min-height: ${MIN_EDITOR_HEIGHT}px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#editor[data-empty="true"]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${c.textMuted};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#editor p { margin: 0 0 12px 0; }
|
||||
#editor p:last-child { margin-bottom: 0; }
|
||||
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
|
||||
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
|
||||
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
|
||||
#editor li { margin-bottom: 4px; }
|
||||
#editor blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 3px solid ${c.border};
|
||||
color: ${c.textSecondary};
|
||||
}
|
||||
#editor a { color: ${c.primary}; text-decoration: underline; }
|
||||
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||
#editor pre, #editor code {
|
||||
background: ${c.surfaceActive};
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#editor pre { padding: 8px 12px; overflow-x: auto; }
|
||||
::selection { background: ${c.primary}33; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
var lastHtml = null;
|
||||
var lastHeight = 0;
|
||||
|
||||
function refreshEmpty() {
|
||||
var html = editor.innerHTML;
|
||||
var isEmpty =
|
||||
html === '' ||
|
||||
html === '<br>' ||
|
||||
html === '<p></p>' ||
|
||||
html === '<p><br></p>' ||
|
||||
editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0;
|
||||
editor.setAttribute('data-empty', isEmpty ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function post(type, payload) {
|
||||
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload }));
|
||||
}
|
||||
}
|
||||
|
||||
function reportHeight() {
|
||||
var h = Math.max(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
|
||||
if (Math.abs(h - lastHeight) < 2) return;
|
||||
lastHeight = h;
|
||||
post('height', h);
|
||||
}
|
||||
|
||||
function reportChange() {
|
||||
refreshEmpty();
|
||||
var html = editor.innerHTML;
|
||||
if (html === lastHtml) return;
|
||||
lastHtml = html;
|
||||
post('change', html);
|
||||
}
|
||||
|
||||
function reportSelection() {
|
||||
function active(name) {
|
||||
try { return document.queryCommandState(name); } catch (e) { return false; }
|
||||
}
|
||||
function inBlock(tag) {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return false;
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === tag) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
var state = {
|
||||
bold: active('bold'),
|
||||
italic: active('italic'),
|
||||
underline: active('underline'),
|
||||
strikeThrough: active('strikeThrough'),
|
||||
ul: active('insertUnorderedList'),
|
||||
ol: active('insertOrderedList'),
|
||||
blockquote: inBlock('BLOCKQUOTE'),
|
||||
h1: inBlock('H1'),
|
||||
h2: inBlock('H2'),
|
||||
alignLeft: active('justifyLeft'),
|
||||
alignCenter: active('justifyCenter'),
|
||||
alignRight: active('justifyRight'),
|
||||
link: inBlock('A'),
|
||||
};
|
||||
post('selection', state);
|
||||
}
|
||||
|
||||
// ── Initial content ────────────────────────────────────────────────
|
||||
var initial = ${initialJson};
|
||||
if (initial && typeof initial === 'string') editor.innerHTML = initial;
|
||||
refreshEmpty();
|
||||
lastHtml = editor.innerHTML;
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
reportChange();
|
||||
reportHeight();
|
||||
});
|
||||
editor.addEventListener('blur', function () { post('blur', null); });
|
||||
editor.addEventListener('focus', function () { post('focus', null); });
|
||||
document.addEventListener('selectionchange', function () {
|
||||
if (document.activeElement === editor) reportSelection();
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
try {
|
||||
if (command === 'formatBlock' && value) {
|
||||
// Toggle: if we're already in that block, switch to <p>.
|
||||
var sel = window.getSelection();
|
||||
if (sel && sel.rangeCount) {
|
||||
var node = sel.anchorNode;
|
||||
while (node && node !== editor) {
|
||||
if (node.nodeType === 1 && node.tagName === value.toUpperCase()) {
|
||||
document.execCommand('formatBlock', false, '<p>');
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>');
|
||||
} else {
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
} catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
setHtml: function (html) {
|
||||
editor.innerHTML = html || '';
|
||||
lastHtml = editor.innerHTML;
|
||||
refreshEmpty();
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertHtml: function (html) {
|
||||
editor.focus();
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
editor.focus();
|
||||
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||
// user can't accidentally (or via a malicious paste) ship a
|
||||
// javascript:/data: link to their recipient. Written without regex
|
||||
// backslash escapes — see the template-cooking warning at the top.
|
||||
var trimmed = (url || '').trim();
|
||||
var first = trimmed.charAt(0);
|
||||
var safeScheme = /^(https?:|mailto:|tel:|sms:)/i.test(trimmed)
|
||||
|| first === '/' || first === '?' || first === '#';
|
||||
if (!trimmed || !safeScheme) return;
|
||||
var sel = window.getSelection();
|
||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
if (hasSelection) {
|
||||
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||
if (a.getAttribute('href') === trimmed) {
|
||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var text = label && label.trim() ? label : trimmed;
|
||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var safeUrl = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
reportSelection();
|
||||
},
|
||||
unsetLink: function () {
|
||||
editor.focus();
|
||||
try { document.execCommand('unlink', false, null); } catch (e) {}
|
||||
reportChange();
|
||||
reportSelection();
|
||||
},
|
||||
insertImage: function (src, cid, alt) {
|
||||
editor.focus();
|
||||
var safeSrc = (src || '').replace(/"/g, '"');
|
||||
var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"');
|
||||
var safeCid = cid ? cid.replace(/"/g, '"') : '';
|
||||
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
|
||||
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||
reportChange();
|
||||
reportHeight();
|
||||
},
|
||||
// Send-time readback (issue #9): RN asks for the live DOM content instead
|
||||
// of trusting the last change message, which can lag typing or be lost
|
||||
// entirely if the bridge breaks. The id lets RN match reply to request.
|
||||
getHtml: function (id) {
|
||||
post('htmlSnapshot', { id: id, html: editor.innerHTML });
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
setTimeout(reportHeight, 200);
|
||||
setTimeout(reportHeight, 600);
|
||||
});
|
||||
if (window.ResizeObserver) {
|
||||
new ResizeObserver(reportHeight).observe(editor);
|
||||
}
|
||||
})();
|
||||
true;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
+92
-12
@@ -1,10 +1,41 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { getDownloadUrl } from '../api/blob';
|
||||
import type { Email } from '../api/types';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import {
|
||||
attachmentDownloadFilename,
|
||||
emailExportFilename,
|
||||
type EmailFilenameOptions,
|
||||
} from './download-filename';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
|
||||
// Read the user's filename template + transform preferences for exports.
|
||||
function emailFileOptions(): EmailFilenameOptions {
|
||||
const s = useSettingsStore.getState();
|
||||
return {
|
||||
template: s.emailExportTemplate,
|
||||
spaceReplacement: s.exportSpaceReplacement,
|
||||
lowercase: s.exportLowercase,
|
||||
stripDiacritics: s.exportStripDiacritics,
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentFileOptions(): EmailFilenameOptions {
|
||||
const s = useSettingsStore.getState();
|
||||
return {
|
||||
template: s.attachmentExportTemplate,
|
||||
spaceReplacement: s.exportSpaceReplacement,
|
||||
lowercase: s.exportLowercase,
|
||||
stripDiacritics: s.exportStripDiacritics,
|
||||
};
|
||||
}
|
||||
|
||||
function authHeader(): string {
|
||||
return jmapClient.authHeader;
|
||||
@@ -20,7 +51,32 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
|
||||
// expo-sharing only accepts `file://` URLs and rejects `content://` with
|
||||
// "Only local file URLs are supported". On Android it then wraps the file
|
||||
// itself with its bundled SharingFileProvider before launching the share
|
||||
// intent, so we must NOT pre-translate to `file.contentUri`.
|
||||
// intent, so every `Sharing.shareAsync` below gets `downloaded.uri` and must
|
||||
// NOT be pre-translated to `downloaded.contentUri`. `openWithViewer` is the
|
||||
// one exception: it assembles the intent itself, so there it's the reverse —
|
||||
// only a content URI is grantable to another app.
|
||||
|
||||
// Android-only: hand the file to whichever app owns the type (PDF viewer,
|
||||
// gallery, video player, ...) rather than to the share sheet. Returns false
|
||||
// when the handoff didn't happen so the caller can fall back to sharing —
|
||||
// most often because no installed app handles the MIME type, in which case
|
||||
// startActivityAsync rejects with ActivityNotFoundException.
|
||||
async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
|
||||
try {
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
// Read-only grant, scoped to the receiving app for the life of the
|
||||
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
|
||||
// uses startActivityForResult, and a new task cancels that result.
|
||||
data: file.contentUri,
|
||||
type: mimeType,
|
||||
flags: FLAG_GRANT_READ_URI_PERMISSION,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[attachments] no viewer for', mimeType, '- falling back to share:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Routes the download via the client-cert-aware native module when the user
|
||||
// has picked a cert, and via expo-file-system's native streaming downloader
|
||||
@@ -60,12 +116,21 @@ export async function shareAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
email?: Email | null,
|
||||
// Owning account for blobs shared by another principal (Files app).
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
const filename = email
|
||||
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
||||
: safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
|
||||
if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) {
|
||||
return;
|
||||
}
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
@@ -78,17 +143,23 @@ export async function shareAttachment(
|
||||
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
|
||||
// so on both platforms we land the file in the document directory and hand it
|
||||
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
|
||||
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
|
||||
// uses cache + share, which on most viewers opens directly without prompting.
|
||||
// surfaces the system save dialog. Unlike the 'preview' counterpart
|
||||
// (shareAttachment), this one keeps the share sheet on Android as well: "save
|
||||
// a copy" is a share-sheet destination, not something a viewer app handles.
|
||||
export async function downloadAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
email?: Email | null,
|
||||
// Owning account for blobs shared by another principal (Files app).
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const filename = safeAttachmentName(name, type);
|
||||
const filename = email
|
||||
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
||||
: safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.document, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
||||
const downloaded = await downloadInto(url, dest, Paths.document);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
@@ -99,8 +170,8 @@ export async function downloadAttachment(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
|
||||
export async function fetchRawEmail(blobId: string, accountId?: string): Promise<string> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId);
|
||||
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||
return r.text();
|
||||
@@ -111,9 +182,18 @@ function safeFilename(subject: string | undefined): string {
|
||||
return `${base}.eml`;
|
||||
}
|
||||
|
||||
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||
const dest = new File(Paths.cache, safeFilename(subject));
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||
export async function shareEmailEml(
|
||||
blobId: string,
|
||||
email?: Email | null,
|
||||
subjectFallback?: string,
|
||||
// Owning account when the message lives in a shared/group mailbox.
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const filename = email
|
||||
? emailExportFilename(email, emailFileOptions())
|
||||
: safeFilename(subjectFallback);
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822, accountId);
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
import { File } from 'expo-file-system';
|
||||
|
||||
// Mirrors the webmail `lib/eml-import.ts`: turn picked files into a flat list of
|
||||
// importable `.eml` messages, expanding `.zip` archives along the way. On RN the
|
||||
// picker hands us file URIs (file:// or content://) rather than File objects, so
|
||||
// we read bytes via expo-file-system and upload those.
|
||||
|
||||
export interface ImportableEml {
|
||||
name: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export const EML_IMPORT_TYPES = [
|
||||
'message/rfc822',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/octet-stream', // some pickers report .eml/.zip as octet-stream
|
||||
];
|
||||
|
||||
function isEmlName(name: string): boolean {
|
||||
return /\.eml$/i.test(name);
|
||||
}
|
||||
|
||||
function isZip(name: string, mime?: string): boolean {
|
||||
return /\.zip$/i.test(name) || mime === 'application/zip' || mime === 'application/x-zip-compressed';
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(bytes: Uint8Array): Promise<ImportableEml[]> {
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = await JSZip.loadAsync(bytes);
|
||||
const out: ImportableEml[] = [];
|
||||
for (const entry of Object.values(zip.files)) {
|
||||
if (entry.dir || !isEmlName(entry.name)) continue;
|
||||
const data = await entry.async('uint8array');
|
||||
out.push({ name: entry.name.split(/[\\/]/).pop() || entry.name, bytes: data });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a single picked file (by URI) into its importable `.eml` messages.
|
||||
* A `.eml` yields one entry; a `.zip` yields one per `.eml` it contains.
|
||||
*/
|
||||
export async function expandImportableEml(
|
||||
uri: string,
|
||||
name: string,
|
||||
mime?: string,
|
||||
): Promise<ImportableEml[]> {
|
||||
const bytes = await new File(uri).bytes();
|
||||
if (isZip(name, mime)) return extractEmlsFromZip(bytes);
|
||||
return [{ name, bytes }];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Sign-in failures reach the UI as raw strings from the JMAP client — "Session
|
||||
// discovery failed: 404 Not Found", "Network request failed". Those describe
|
||||
// what the code was doing, not what the person should do next. This maps the
|
||||
// ones we can recognise onto copy that names a likely cause and an action.
|
||||
//
|
||||
// Errors are matched by `name` and message text rather than `instanceof` so
|
||||
// this module stays free of the api/ and expo dependency graph.
|
||||
|
||||
export interface LoginErrorCopy {
|
||||
title: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface LoginErrorContext {
|
||||
/** Host shown in "can't reach X" copy. */
|
||||
serverUrl?: string | null;
|
||||
}
|
||||
|
||||
function messageOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : typeof err === 'string' ? err : '';
|
||||
}
|
||||
|
||||
function nameOf(err: unknown): string {
|
||||
return err instanceof Error ? err.name : '';
|
||||
}
|
||||
|
||||
function hostLabel(serverUrl: string | null | undefined): string {
|
||||
if (!serverUrl) return 'the server';
|
||||
const host = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
|
||||
return host || 'the server';
|
||||
}
|
||||
|
||||
export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy {
|
||||
const name = nameOf(err);
|
||||
const message = messageOf(err);
|
||||
const lower = message.toLowerCase();
|
||||
const host = hostLabel(context.serverUrl);
|
||||
|
||||
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
||||
return {
|
||||
title: "That didn't work",
|
||||
detail:
|
||||
'Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) {
|
||||
return {
|
||||
title: `Couldn't verify ${host}`,
|
||||
detail:
|
||||
"The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
|
||||
};
|
||||
}
|
||||
|
||||
// The endpoint answered, but it isn't a JMAP server — almost always a
|
||||
// mistyped host or a webmail that lives on a subpath.
|
||||
if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) {
|
||||
return {
|
||||
title: `No mail server at ${host}`,
|
||||
detail: 'Double-check the address, or scan a sign-in code from the webmail instead.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'NetworkError' ||
|
||||
name === 'TypeError' ||
|
||||
name === 'AbortError' ||
|
||||
lower.includes('network request failed') ||
|
||||
lower.includes('failed to fetch') ||
|
||||
lower.includes('timeout') ||
|
||||
lower.includes('timed out')
|
||||
) {
|
||||
return {
|
||||
title: `Can't reach ${host}`,
|
||||
detail: 'Check your connection and the server address, then try again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('pairing code')) {
|
||||
return {
|
||||
title: 'That code has expired',
|
||||
detail: 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('state mismatch')) {
|
||||
return {
|
||||
title: 'Sign-in was interrupted',
|
||||
detail: "The response didn't match the request we started. Try signing in again.",
|
||||
};
|
||||
}
|
||||
|
||||
if (lower.includes('maximum of') && lower.includes('accounts')) {
|
||||
return { title: message };
|
||||
}
|
||||
|
||||
// Unrecognised: show what we were told rather than inventing a cause.
|
||||
return {
|
||||
title: 'Sign-in failed',
|
||||
detail: message || 'Something went wrong. Try again.',
|
||||
};
|
||||
}
|
||||
+137
-8
@@ -3,6 +3,33 @@ import type { Mailbox } from '../api/types';
|
||||
export interface MailboxNode extends Mailbox {
|
||||
children: MailboxNode[];
|
||||
depth: number;
|
||||
/**
|
||||
* True for the virtual node that wraps a shared/group account's folders.
|
||||
* It has no server-side mailbox behind it — it can be expanded but never
|
||||
* selected or used as a move target.
|
||||
*/
|
||||
isAccountNode?: boolean;
|
||||
}
|
||||
|
||||
// Id prefix for the virtual per-shared-account node. Matches the webmail's
|
||||
// `shared-account-<accountId>` convention in [lib/utils.ts].
|
||||
export const SHARED_ACCOUNT_NODE_PREFIX = 'shared-account-';
|
||||
|
||||
/** The user's own folders — everything not owned by a shared/group account. */
|
||||
export function ownMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
return mailboxes.filter((m) => !m.isShared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders belonging to the same account as `mailboxId`. Role lookups (trash,
|
||||
* archive, junk…) must run inside one account: moving a message out of a
|
||||
* shared folder into the user's own Trash isn't a thing JMAP can express.
|
||||
* Falls back to the user's own folders when the id isn't known.
|
||||
*/
|
||||
export function mailboxesForSiblingOf(mailboxes: Mailbox[], mailboxId: string | null): Mailbox[] {
|
||||
const current = mailboxId ? mailboxes.find((m) => m.id === mailboxId) : undefined;
|
||||
if (!current?.isShared) return ownMailboxes(mailboxes);
|
||||
return mailboxes.filter((m) => m.isShared && m.accountId === current.accountId);
|
||||
}
|
||||
|
||||
// Matches `ROLE_PRIORITY` from [lib/utils.ts] in the webmail.
|
||||
@@ -53,7 +80,16 @@ function sortNodes(nodes: MailboxNode[]): void {
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
}
|
||||
|
||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
function recalcDepths(nodes: MailboxNode[], base: number): void {
|
||||
for (const n of nodes) {
|
||||
n.depth = base;
|
||||
if (n.children.length > 0) recalcDepths(n.children, base + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the root nodes for one account's folders (parent links only resolve
|
||||
// within an account — a shared folder can't nest under an own folder).
|
||||
function buildRoots(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
const deduped = deduplicate(mailboxes);
|
||||
const map = new Map<string, MailboxNode>();
|
||||
const roots: MailboxNode[] = [];
|
||||
@@ -69,16 +105,76 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
const recalcDepths = (nodes: MailboxNode[], base: number) => {
|
||||
for (const n of nodes) {
|
||||
n.depth = base;
|
||||
if (n.children.length > 0) recalcDepths(n.children, base + 1);
|
||||
}
|
||||
};
|
||||
const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Own folders first, then one collapsible node per shared/group account
|
||||
* holding that account's folders — the same shape the webmail sidebar uses
|
||||
* (GitHub #151), so a Stalwart group mailbox shows up as its own section
|
||||
* rather than being mixed into the user's folder list.
|
||||
*/
|
||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
const own = mailboxes.filter((m) => !m.isShared);
|
||||
const shared = mailboxes.filter((m) => m.isShared);
|
||||
|
||||
const roots = buildRoots(own);
|
||||
recalcDepths(roots, 0);
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
|
||||
if (shared.length === 0) return roots;
|
||||
|
||||
const byAccount = new Map<string, Mailbox[]>();
|
||||
for (const m of shared) {
|
||||
const accountId = m.accountId ?? 'unknown';
|
||||
const list = byAccount.get(accountId);
|
||||
if (list) list.push(m);
|
||||
else byAccount.set(accountId, [m]);
|
||||
}
|
||||
|
||||
const accountNodes: MailboxNode[] = [];
|
||||
for (const [accountId, accountMailboxes] of byAccount) {
|
||||
const accountRoots = buildRoots(accountMailboxes);
|
||||
// Children sit at depth 1 so the account node reads as their header.
|
||||
recalcDepths(accountRoots, 1);
|
||||
sortNodes(accountRoots);
|
||||
|
||||
const accountName = accountMailboxes[0]?.accountName || accountId;
|
||||
accountNodes.push({
|
||||
id: `${SHARED_ACCOUNT_NODE_PREFIX}${accountId}`,
|
||||
name: accountName,
|
||||
sortOrder: 1000,
|
||||
totalEmails: 0,
|
||||
// Roll the account's unread up to the header so a collapsed section
|
||||
// still shows there's something waiting.
|
||||
unreadEmails: accountMailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0),
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: ACCOUNT_NODE_RIGHTS,
|
||||
isSubscribed: true,
|
||||
accountId,
|
||||
accountName,
|
||||
isShared: true,
|
||||
isAccountNode: true,
|
||||
children: accountRoots,
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
accountNodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return [...roots, ...accountNodes];
|
||||
}
|
||||
|
||||
// Flatten the tree in traversal order, skipping children of collapsed nodes.
|
||||
@@ -96,3 +192,36 @@ export function flattenVisible(
|
||||
walk(nodes);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
const roleMatch = mailboxes.find((m) => m.role === 'trash');
|
||||
if (roleMatch) return roleMatch;
|
||||
|
||||
const names = ['trash', 'bin', 'deleted', 'deleted items', 'corbeille', 'messages supprimés', 'éléments supprimés', 'supprimé', 'supprimés'];
|
||||
return mailboxes.find((m) => {
|
||||
const lower = m.name.toLowerCase();
|
||||
return names.includes(lower) || names.some((n) => lower.includes(n));
|
||||
});
|
||||
}
|
||||
|
||||
export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
const roleMatch = mailboxes.find((m) => m.role === 'archive');
|
||||
if (roleMatch) return roleMatch;
|
||||
|
||||
const names = ['archive', 'archives', 'archived'];
|
||||
return mailboxes.find((m) => {
|
||||
const lower = m.name.toLowerCase();
|
||||
return names.includes(lower) || names.some((n) => lower.includes(n));
|
||||
});
|
||||
}
|
||||
|
||||
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
const roleMatch = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
if (roleMatch) return roleMatch;
|
||||
|
||||
const names = ['junk', 'spam', 'indésirables', 'indésirable', 'courrier indésirable'];
|
||||
return mailboxes.find((m) => {
|
||||
const lower = m.name.toLowerCase();
|
||||
return names.includes(lower) || names.some((n) => lower.includes(n));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Classify an error thrown by a JMAP call as a *transient* connectivity
|
||||
// failure (server unreachable, offline, session not yet up) versus a
|
||||
// *permanent* one (the server received the request and rejected it, e.g. a
|
||||
// 4xx). The offline outbox uses this to decide whether to keep an operation
|
||||
// queued for a later retry or to give up on it.
|
||||
//
|
||||
// We err on the side of "transient" only for signals we recognise as
|
||||
// connectivity-related; anything else (including bare 4xx/5xx responses) is
|
||||
// treated as permanent so a poison operation can't wedge the queue forever.
|
||||
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
|
||||
const TRANSIENT_NAMES = new Set([
|
||||
'NetworkError', // jmap-client transport wrapper
|
||||
'AbortError', // request aborted (e.g. app backgrounded)
|
||||
'TypeError', // RN fetch throws TypeError("Network request failed")
|
||||
]);
|
||||
|
||||
const TRANSIENT_MESSAGE_HINTS = [
|
||||
'network request failed',
|
||||
'network error',
|
||||
'failed to fetch',
|
||||
'not connected',
|
||||
'session expired', // token refresh will happen on the next live request
|
||||
'timeout',
|
||||
'timed out',
|
||||
'connection',
|
||||
];
|
||||
|
||||
export function isTransientNetworkError(err: unknown): boolean {
|
||||
// If the device itself reports offline, treat any failure as transient.
|
||||
if (!useNetworkStore.getState().online) return true;
|
||||
|
||||
if (!(err instanceof Error)) return false;
|
||||
if (TRANSIENT_NAMES.has(err.name)) return true;
|
||||
|
||||
const msg = err.message?.toLowerCase() ?? '';
|
||||
return TRANSIENT_MESSAGE_HINTS.some((hint) => msg.includes(hint));
|
||||
}
|
||||
+131
-30
@@ -123,6 +123,92 @@ export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffResu
|
||||
throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`);
|
||||
}
|
||||
|
||||
// QR login payloads. A QR scanned on the login screen either bootstraps the
|
||||
// server URL for the normal webmail handoff (`connect`), or carries a one-time
|
||||
// cross-device pairing code minted by an already-signed-in webmail (`pair`).
|
||||
// The payload never contains credentials — the `pair` code is redeemed for
|
||||
// tokens over the network, once.
|
||||
export type QrLoginPayload =
|
||||
| { kind: 'connect'; webmailUrl: string }
|
||||
| { kind: 'pair'; webmailUrl: string; code: string };
|
||||
|
||||
export function parseQrLoginPayload(raw: string): QrLoginPayload | null {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=...
|
||||
const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed);
|
||||
if (match) {
|
||||
const kind = match[1].toLowerCase();
|
||||
const params = new URLSearchParams(match[2]);
|
||||
const server = params.get('server');
|
||||
if (!server || !/^https?:\/\//i.test(server)) return null;
|
||||
if (kind === 'pair') {
|
||||
const code = params.get('code');
|
||||
if (!code) return null;
|
||||
return { kind: 'pair', webmailUrl: server, code };
|
||||
}
|
||||
return { kind: 'connect', webmailUrl: server };
|
||||
}
|
||||
|
||||
// A bare https URL is treated as a server-bootstrap target so admins can
|
||||
// hand out a plain webmail URL QR without the custom-scheme wrapper.
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { kind: 'connect', webmailUrl: trimmed };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cross-device pairing redemption. Posts the scanned code to the webmail that
|
||||
// minted it and maps the token bundle into the same shape the in-browser OAuth
|
||||
// handoff produces, so the caller can reuse the existing connectWithOAuth path.
|
||||
export async function redeemPairingCode(webmailUrl: string, code: string): Promise<HandoffResult> {
|
||||
const base = webmailUrl.replace(/\/+$/, '');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await secureFetch(`${base}/api/auth/pair/redeem`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ pairing_code: code }),
|
||||
});
|
||||
} catch {
|
||||
throw new HandoffError('Could not reach the server to complete pairing');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// The code was unknown, already used, or expired (server returns 400) —
|
||||
// or something else went wrong. Either way the user needs a fresh QR.
|
||||
throw new HandoffError('Pairing code is invalid or has expired');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
server_url?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_endpoint?: string;
|
||||
client_id?: string;
|
||||
};
|
||||
|
||||
if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) {
|
||||
throw new HandoffError('Pairing response missing token material');
|
||||
}
|
||||
|
||||
return {
|
||||
flow: 'oauth',
|
||||
serverUrl: data.server_url,
|
||||
tokens: {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? undefined,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: data.token_endpoint,
|
||||
clientId: data.client_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
|
||||
|
||||
// OAuth refresh — exchanges the refresh token at the original token endpoint
|
||||
// for a new access token. Returns the updated bundle so the caller can
|
||||
// persist it.
|
||||
@@ -130,37 +216,52 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
|
||||
if (!tokens.refreshToken) {
|
||||
throw new HandoffError('No refresh token available');
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HandoffError(`Token refresh failed: ${response.status}`);
|
||||
|
||||
const cacheKey = tokens.refreshToken;
|
||||
let promise = activeRefreshes.get(cacheKey);
|
||||
|
||||
if (!promise) {
|
||||
promise = (async () => {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokens.refreshToken!,
|
||||
client_id: tokens.clientId,
|
||||
});
|
||||
const response = await secureFetch(tokens.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HandoffError(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken!,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
} finally {
|
||||
activeRefreshes.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
activeRefreshes.set(cacheKey, promise);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
if (!data.access_token) {
|
||||
throw new HandoffError('Token refresh response missing access_token');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken,
|
||||
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
|
||||
tokenEndpoint: tokens.tokenEndpoint,
|
||||
clientId: tokens.clientId,
|
||||
};
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
@@ -30,6 +30,8 @@ const FETCH_CHUNK_FALLBACK = 25;
|
||||
|
||||
export interface RunOptions {
|
||||
days: number;
|
||||
/** Hard cap on the cache in megabytes; oldest mail is evicted to fit. */
|
||||
maxMB?: number;
|
||||
}
|
||||
|
||||
export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
@@ -77,6 +79,7 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
cache.setSyncState({ completed: skipped });
|
||||
|
||||
if (toFetch.length === 0) {
|
||||
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
finishedAt: Date.now(),
|
||||
@@ -131,6 +134,10 @@ export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
cache.setSyncState({ completed, fetched, bytes });
|
||||
}
|
||||
|
||||
// Trim the cache back under its size cap, shedding the oldest mail. Done
|
||||
// after fetching so we never evict something we're about to keep.
|
||||
if (opts.maxMB) await cache.evictToFit(opts.maxMB * 1024 * 1024);
|
||||
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
completed: total,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Single source of truth for "this feature only exists on some platforms", so
|
||||
// the UI, the stores and the native bridges all agree on what is reachable.
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
/**
|
||||
* In-app sideload updates: download the release APK from GitHub and hand it to
|
||||
* the system package installer (see `lib/install-update`).
|
||||
*
|
||||
* Android only. App Store Review guideline 2.5.2 forbids an iOS app from
|
||||
* downloading and installing executable code, and there is no iOS equivalent of
|
||||
* the installer intent anyway - iOS builds are updated through TestFlight or
|
||||
* the App Store. The whole updater surface (banner, settings pane, launch-time
|
||||
* check) is hidden when this is false.
|
||||
*/
|
||||
export const supportsSideloadUpdates = Platform.OS === 'android';
|
||||
@@ -217,6 +217,33 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
|
||||
* the relay positively reports it inactive - a record it knows about that has
|
||||
* never forwarded a push and isn't freshly registered. Every other outcome (the
|
||||
* relay doesn't recognise the id, an older relay without this endpoint, a
|
||||
* network blip, or a live subscription) returns false, so we never reap
|
||||
* anything we can't confirm is dead. This is what lets setup clear its own
|
||||
* abandoned attempts - and dead siblings left by reinstalls that regenerated
|
||||
* the deviceClientId - without disturbing another live device or the PWA that
|
||||
* shares the account.
|
||||
*/
|
||||
async function relayReportsDead(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { active?: unknown };
|
||||
return body.active === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
@@ -241,12 +268,36 @@ async function pollVerificationCode(
|
||||
throw new Error('Timed out waiting for PushVerification from JMAP server');
|
||||
}
|
||||
|
||||
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
|
||||
// effect as auth state settles during startup and again on every FCM token
|
||||
// refresh; because a failing attempt blocks for up to 75s polling for the
|
||||
// verification code, those re-fires overlap. Without this guard each
|
||||
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
|
||||
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
|
||||
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
|
||||
// PushVerification"). Callers share the first in-flight run instead.
|
||||
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* code, and finalise the subscription. Concurrent calls for the same account
|
||||
* are coalesced onto a single in-flight run.
|
||||
*/
|
||||
export async function setupPushNotifications(
|
||||
export function setupPushNotifications(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
|
||||
const existing = inFlightSetups.get(key);
|
||||
if (existing) return existing;
|
||||
const run = setupPushNotificationsInner(params).finally(() => {
|
||||
inFlightSetups.delete(key);
|
||||
});
|
||||
inFlightSetups.set(key, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function setupPushNotificationsInner(
|
||||
params: PushSetupParams,
|
||||
): Promise<PushSetupResult> {
|
||||
const native = getNative();
|
||||
@@ -306,19 +357,29 @@ export async function setupPushNotifications(
|
||||
await AsyncStorage.removeItem(subKey);
|
||||
}
|
||||
|
||||
// Reap any leftover Stalwart subscriptions still bound to this account's
|
||||
// deviceClientId. These pile up when a previous enable attempt failed
|
||||
// mid-flow (verify race, network blip, app killed). Stalwart per-account
|
||||
// rate-limits PushVerification posts, so leaving stragglers around blocks
|
||||
// the new one's verification - the symptom is a confusing "code does not
|
||||
// match". Only reap matches for THIS account's deviceClientId; other
|
||||
// accounts' subscriptions on this server (rare but possible if the same
|
||||
// JMAP server backs multiple accounts) must be left alone.
|
||||
const stragglers = existingSubs.filter(
|
||||
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
|
||||
);
|
||||
for (const s of stragglers) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
// Reap leftover Stalwart subscriptions that would otherwise starve the new
|
||||
// one's verification. Stalwart emits only one PushVerification per account
|
||||
// per ~60s and picks the oldest unverified subscription, so a single stale
|
||||
// straggler blocks every fresh attempt - the symptom is the confusing
|
||||
// "Timed out waiting for PushVerification" error. We can't read a
|
||||
// subscription's verified state or URL over JMAP (Stalwart hides both), only
|
||||
// its deviceClientId, so we decide what's safe to remove like this:
|
||||
// - same deviceClientId as ours: a previous attempt from THIS device,
|
||||
// always safe to reap.
|
||||
// - a different deviceClientId: could be another live device or the PWA on
|
||||
// this account. Ask the relay whether it's still alive and only reap the
|
||||
// ones it confirms are dead. Anything live - or anything the relay can't
|
||||
// vouch for (a different relay, a non-Bulwark client, a network blip) -
|
||||
// is left untouched.
|
||||
for (const s of existingSubs) {
|
||||
if (s.id === storedServerId) continue;
|
||||
if (s.deviceClientId === deviceClientId) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
|
||||
await destroyPushSubscription(s.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const serverAssignedId = await createPushSubscription({
|
||||
|
||||
+191
-27
@@ -11,7 +11,7 @@
|
||||
* on the client.
|
||||
*/
|
||||
|
||||
import { addDays, addMonths, addWeeks, addYears, format, parseISO } from 'date-fns';
|
||||
import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays, format, parseISO } from 'date-fns';
|
||||
import type { CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
|
||||
type NDay = { day: string; nthOfPeriod?: number };
|
||||
@@ -60,6 +60,10 @@ export function expandRecurringEvents(
|
||||
return result;
|
||||
}
|
||||
|
||||
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
||||
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
||||
}
|
||||
|
||||
function expandEvent(
|
||||
master: CalendarEvent,
|
||||
rangeStart: Date,
|
||||
@@ -73,13 +77,23 @@ function expandEvent(
|
||||
const occurrences: CalendarEvent[] = [];
|
||||
const seenDates = new Set<string>();
|
||||
|
||||
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
|
||||
// (iCalendar EXRULE) are removed from the recurrence set.
|
||||
const excludedDates = new Set<string>();
|
||||
for (const exRule of master.excludedRecurrenceRules || []) {
|
||||
// includeStartDate=false: "the series start is always an occurrence"
|
||||
// applies to recurrence rules, not to exclusion rules.
|
||||
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
|
||||
excludedDates.add(occurrenceDateKey(master, date));
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
|
||||
for (const date of dates) {
|
||||
const dateKey = master.showWithoutTime
|
||||
? format(date, 'yyyy-MM-dd')
|
||||
: date.toISOString();
|
||||
const dateKey = occurrenceDateKey(master, date);
|
||||
|
||||
if (excludedDates.has(dateKey)) continue;
|
||||
if (seenDates.has(dateKey)) continue;
|
||||
seenDates.add(dateKey);
|
||||
|
||||
@@ -104,9 +118,9 @@ function expandEvent(
|
||||
if (isNaN(overrideDate.getTime())) continue;
|
||||
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
|
||||
|
||||
const dateKey = master.showWithoutTime
|
||||
? format(overrideDate, 'yyyy-MM-dd')
|
||||
: overrideDate.toISOString();
|
||||
// Overrides take precedence over excludedRecurrenceRules (they re-add
|
||||
// a concrete instance), so only dedupe against already-generated dates.
|
||||
const dateKey = occurrenceDateKey(master, overrideDate);
|
||||
if (seenDates.has(dateKey)) continue;
|
||||
seenDates.add(dateKey);
|
||||
|
||||
@@ -116,6 +130,65 @@ function expandEvent(
|
||||
return occurrences;
|
||||
}
|
||||
|
||||
// Cache Intl formatters per IANA timezone id — constructing them is expensive
|
||||
// and expansion runs over hundreds of occurrences. `null` marks ids the
|
||||
// runtime rejected so we don't retry them.
|
||||
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
|
||||
|
||||
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
|
||||
let formatter = tzFormatterCache.get(timeZone);
|
||||
if (formatter === undefined) {
|
||||
try {
|
||||
formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
formatter = null;
|
||||
}
|
||||
tzFormatterCache.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
|
||||
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of formatter.formatToParts(new Date(instant))) {
|
||||
if (part.type !== 'literal') map[part.type] = part.value;
|
||||
}
|
||||
const hour = map.hour === '24' ? 0 : Number(map.hour);
|
||||
return Date.UTC(
|
||||
Number(map.year), Number(map.month) - 1, Number(map.day),
|
||||
hour, Number(map.minute), Number(map.second),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
|
||||
* and return the corresponding UTC instant. Two fixup iterations converge
|
||||
* for all real offsets, including across DST transitions.
|
||||
*/
|
||||
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
||||
const formatter = getTzFormatter(timeZone);
|
||||
if (!formatter) return null;
|
||||
const wallAsUtc = Date.UTC(
|
||||
wall.getFullYear(), wall.getMonth(), wall.getDate(),
|
||||
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
|
||||
);
|
||||
let guess = wallAsUtc;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
||||
}
|
||||
return new Date(guess);
|
||||
}
|
||||
|
||||
function createOccurrence(
|
||||
master: CalendarEvent,
|
||||
date: Date,
|
||||
@@ -130,16 +203,33 @@ function createOccurrence(
|
||||
// getEventEndDate return the occurrence times rather than the master's.
|
||||
let utcStart: string | undefined;
|
||||
let utcEnd: string | undefined;
|
||||
if (!master.showWithoutTime && master.utcStart && master.start) {
|
||||
const masterLocal = parseISO(master.start);
|
||||
const masterUtc = parseISO(master.utcStart);
|
||||
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
||||
utcStart = new Date(date.getTime() + offsetMs).toISOString();
|
||||
if (!master.showWithoutTime) {
|
||||
let durationMs: number | null = null;
|
||||
if (master.utcStart && master.utcEnd) {
|
||||
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
|
||||
if (!isNaN(ms)) durationMs = ms;
|
||||
}
|
||||
|
||||
if (master.utcEnd) {
|
||||
const masterUtcEnd = parseISO(master.utcEnd);
|
||||
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
|
||||
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
|
||||
// Convert the occurrence's wall time using the event's own timezone, so
|
||||
// occurrences on the other side of a DST transition in that zone keep
|
||||
// their wall time. Reusing the master's fixed UTC offset (the fallback
|
||||
// below) would shift them by the DST delta.
|
||||
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
|
||||
if (zoned) {
|
||||
utcStart = zoned.toISOString();
|
||||
if (durationMs !== null) {
|
||||
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
|
||||
}
|
||||
} else if (master.utcStart && master.start) {
|
||||
// Floating events (or an unrecognized timezone id): keep the master's
|
||||
// offset, which by definition doesn't vary.
|
||||
const masterLocal = parseISO(master.start);
|
||||
const masterUtc = parseISO(master.utcStart);
|
||||
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
||||
utcStart = new Date(date.getTime() + offsetMs).toISOString();
|
||||
if (durationMs !== null) {
|
||||
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +297,7 @@ function generateDates(
|
||||
rawRule: RecurrenceRule,
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
includeStartDate = true,
|
||||
): Date[] {
|
||||
const rule = addImplicitByX(rawRule, eventStart);
|
||||
const dates: Date[] = [];
|
||||
@@ -214,21 +305,34 @@ function generateDates(
|
||||
const countLimit = rule.count || Infinity;
|
||||
const until = rule.until ? parseISO(rule.until) : null;
|
||||
let totalCount = 0;
|
||||
let current = new Date(eventStart);
|
||||
// Without a `count` limit we can jump straight to the period containing
|
||||
// the visible range. With one, every occurrence since the series start
|
||||
// must be generated so it counts against `count`.
|
||||
let current = rule.count
|
||||
? new Date(eventStart)
|
||||
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
|
||||
|
||||
const maxIterations = 2000;
|
||||
// Cap on *emitted* (in-range) dates. This must not count occurrences
|
||||
// before rangeStart, otherwise a series started long ago (e.g. a daily
|
||||
// event from two years back) exhausts the budget before reaching the
|
||||
// visible range and silently renders nothing.
|
||||
const maxOccurrences = 500;
|
||||
let iterations = 0;
|
||||
|
||||
while (iterations++ < maxIterations) {
|
||||
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
||||
if (until && current > until) break;
|
||||
if (
|
||||
current >= rangeEnd
|
||||
&& rule.frequency !== 'yearly'
|
||||
&& rule.frequency !== 'monthly'
|
||||
&& rule.frequency !== 'weekly'
|
||||
) break;
|
||||
// Stop once no candidate in this or a later period can fall before
|
||||
// rangeEnd. Monthly/yearly candidates may precede the period anchor
|
||||
// within the same month/year, so floor the anchor before comparing.
|
||||
if (rule.frequency === 'monthly') {
|
||||
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
|
||||
} else if (rule.frequency === 'yearly') {
|
||||
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
|
||||
} else if (current >= rangeEnd) {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
|
||||
|
||||
@@ -238,7 +342,7 @@ function generateDates(
|
||||
|
||||
for (const d of filtered) {
|
||||
if (until && d > until) break;
|
||||
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
||||
|
||||
// Spec rule 4: eliminate dates before event start
|
||||
if (d < eventStart) continue;
|
||||
@@ -250,7 +354,7 @@ function generateDates(
|
||||
if (d >= rangeEnd) break;
|
||||
}
|
||||
|
||||
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
|
||||
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
||||
|
||||
current = advancePeriod(current, rule.frequency, interval);
|
||||
if (current <= eventStart && iterations === 1) {
|
||||
@@ -259,7 +363,7 @@ function generateDates(
|
||||
}
|
||||
|
||||
// Spec rule 1: the start date-time is ALWAYS the first occurrence.
|
||||
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
|
||||
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
|
||||
if (eventStart >= rangeStart && eventStart < rangeEnd) {
|
||||
if (!dates.some((d) => d.getTime() === eventStart.getTime())) {
|
||||
dates.unshift(eventStart);
|
||||
@@ -505,6 +609,66 @@ function advancePeriod(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fast-forward to the visible range ───────────────────
|
||||
// Jump to the period just before the visible range in O(1), so long-running
|
||||
// series don't burn the iteration budget on years of out-of-range periods.
|
||||
// Only valid for rules without `count` (counted rules must enumerate every
|
||||
// occurrence from the series start).
|
||||
function fastForwardToRange(
|
||||
eventStart: Date,
|
||||
frequency: RecurrenceRule['frequency'],
|
||||
interval: number,
|
||||
rangeStart: Date,
|
||||
): Date {
|
||||
if (eventStart >= rangeStart) return new Date(eventStart);
|
||||
|
||||
let periods: number;
|
||||
switch (frequency) {
|
||||
case 'secondly':
|
||||
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
|
||||
break;
|
||||
case 'minutely':
|
||||
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
|
||||
break;
|
||||
case 'hourly':
|
||||
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
|
||||
break;
|
||||
case 'daily':
|
||||
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
|
||||
break;
|
||||
case 'weekly':
|
||||
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
|
||||
break;
|
||||
case 'monthly': {
|
||||
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
|
||||
+ (rangeStart.getMonth() - eventStart.getMonth());
|
||||
periods = Math.floor(months / interval);
|
||||
break;
|
||||
}
|
||||
case 'yearly':
|
||||
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
|
||||
break;
|
||||
default:
|
||||
return new Date(eventStart);
|
||||
}
|
||||
|
||||
// Land one full period early so candidates inside the boundary period
|
||||
// (which can precede the period anchor) are still generated.
|
||||
periods = Math.max(0, periods - 1);
|
||||
if (periods === 0) return new Date(eventStart);
|
||||
|
||||
switch (frequency) {
|
||||
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
|
||||
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
|
||||
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
|
||||
case 'daily': return addDays(eventStart, periods * interval);
|
||||
case 'weekly': return addWeeks(eventStart, periods * interval);
|
||||
case 'monthly': return addMonths(eventStart, periods * interval);
|
||||
case 'yearly': return addYears(eventStart, periods * interval);
|
||||
default: return new Date(eventStart);
|
||||
}
|
||||
}
|
||||
|
||||
function expandByDayInMonth(
|
||||
year: number,
|
||||
month: number,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Custom recurrence rule helpers for the event editor. Mobile port of
|
||||
* webmail's components/calendar/recurrence-editor.tsx logic (rule detection,
|
||||
* construction, and the human-readable summary).
|
||||
*/
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import type { RecurrenceRule } from '../api/types';
|
||||
|
||||
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
export type MonthlyMode = 'day' | 'nth';
|
||||
export type EndsMode = 'never' | 'on' | 'after';
|
||||
|
||||
export const EDITOR_FREQUENCIES: EditorFrequency[] = ['daily', 'weekly', 'monthly', 'yearly'];
|
||||
|
||||
// JSCalendar weekday ids in display order (week starts Monday, matching the
|
||||
// firstDayOfWeek the built rules declare).
|
||||
export const WEEKDAYS: string[] = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
|
||||
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
|
||||
export const INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
||||
|
||||
export const UNIT_LABELS: Record<EditorFrequency, string> = {
|
||||
daily: 'days',
|
||||
weekly: 'weeks',
|
||||
monthly: 'months',
|
||||
yearly: 'years',
|
||||
};
|
||||
|
||||
// 2024-01-01 is a Monday — used to render weekday names via date-fns.
|
||||
export function weekdayName(day: string, style: 'long' | 'short' = 'long'): string {
|
||||
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
|
||||
return format(ref, style === 'long' ? 'EEEE' : 'EEE');
|
||||
}
|
||||
|
||||
export function monthName(month: number): string {
|
||||
return format(new Date(2024, month - 1, 1), 'LLLL');
|
||||
}
|
||||
|
||||
const NTH_LABELS: Record<number, string> = {
|
||||
1: 'first',
|
||||
2: 'second',
|
||||
3: 'third',
|
||||
4: 'fourth',
|
||||
[-1]: 'last',
|
||||
};
|
||||
|
||||
export function nthLabel(nth: number): string {
|
||||
return NTH_LABELS[nth] ?? String(nth);
|
||||
}
|
||||
|
||||
export function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an "nth weekday" pattern from a rule, accepting both the
|
||||
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
|
||||
*/
|
||||
export function getNthDay(rule: RecurrenceRule): { day: string; nth: number } | null {
|
||||
if (rule.byDay?.length === 1) {
|
||||
const nd = rule.byDay[0];
|
||||
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
|
||||
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
|
||||
* dropdown presets produce, i.e. it needs no custom editor to represent.
|
||||
*/
|
||||
export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean {
|
||||
return (
|
||||
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
|
||||
(!rule.interval || rule.interval === 1) &&
|
||||
!rule.byDay?.length &&
|
||||
!rule.byMonthDay?.length &&
|
||||
!rule.byMonth?.length &&
|
||||
!rule.byYearDay?.length &&
|
||||
!rule.byWeekNo?.length &&
|
||||
!rule.bySetPosition?.length &&
|
||||
!rule.count &&
|
||||
!rule.until
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable summary of a recurrence rule, e.g.
|
||||
* "Every 2 months on the third Thursday · 12 occurrences".
|
||||
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
||||
*/
|
||||
export function buildRecurrenceSummary(rule: RecurrenceRule): string | null {
|
||||
const interval = rule.interval || 1;
|
||||
let base: string;
|
||||
switch (rule.frequency) {
|
||||
case 'daily':
|
||||
base = interval > 1 ? `Every ${interval} days` : 'Daily';
|
||||
break;
|
||||
case 'weekly':
|
||||
base = interval > 1 ? `Every ${interval} weeks` : 'Weekly';
|
||||
break;
|
||||
case 'monthly':
|
||||
base = interval > 1 ? `Every ${interval} months` : 'Monthly';
|
||||
break;
|
||||
case 'yearly':
|
||||
base = interval > 1 ? `Every ${interval} years` : 'Yearly';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = [base];
|
||||
|
||||
if (rule.frequency === 'weekly' && rule.byDay?.length) {
|
||||
const days = rule.byDay
|
||||
.filter((d) => WEEKDAYS.includes(d.day))
|
||||
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
|
||||
.map((d) => weekdayName(d.day, 'short'))
|
||||
.join(', ');
|
||||
if (days) parts.push(`on ${days}`);
|
||||
}
|
||||
|
||||
if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
|
||||
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
|
||||
const m = parseInt(rule.byMonth[0], 10);
|
||||
if (m >= 1 && m <= 12) parts.push(`in ${monthName(m)}`);
|
||||
}
|
||||
const nthDay = getNthDay(rule);
|
||||
if (nthDay) {
|
||||
parts.push(`on the ${nthLabel(nthDay.nth)} ${weekdayName(nthDay.day)}`);
|
||||
} else if (rule.byMonthDay?.length) {
|
||||
parts.push(`on day ${rule.byMonthDay[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
let summary = parts.join(' ');
|
||||
if (rule.count) {
|
||||
summary += ` · ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`;
|
||||
} else if (rule.until) {
|
||||
const d = new Date(rule.until);
|
||||
if (!isNaN(d.getTime())) {
|
||||
summary += ` · until ${format(d, 'MMM d, yyyy')}`;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export interface RecurrenceEditorValue {
|
||||
frequency: EditorFrequency;
|
||||
interval: number;
|
||||
weekDays: string[];
|
||||
monthlyMode: MonthlyMode;
|
||||
monthDay: number;
|
||||
nth: number;
|
||||
nthDay: string;
|
||||
month: number;
|
||||
endsMode: EndsMode;
|
||||
untilDate: string; // yyyy-MM-dd
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Build the editor's initial state from an existing rule (or defaults). */
|
||||
export function editorValueFromRule(
|
||||
rule: RecurrenceRule | null,
|
||||
eventStart: Date,
|
||||
): RecurrenceEditorValue {
|
||||
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
||||
const nthDayInfo = rule ? getNthDay(rule) : null;
|
||||
|
||||
const weekDays = (() => {
|
||||
if (rule?.frequency === 'weekly' && rule.byDay?.length) {
|
||||
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
|
||||
if (days.length) return days;
|
||||
}
|
||||
return [startDay];
|
||||
})();
|
||||
|
||||
const monthFromRule = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
|
||||
const untilFromRule = (() => {
|
||||
if (rule?.until) {
|
||||
const d = new Date(rule.until);
|
||||
if (!isNaN(d.getTime())) return format(d, 'yyyy-MM-dd');
|
||||
}
|
||||
const inAYear = new Date(eventStart);
|
||||
inAYear.setFullYear(inAYear.getFullYear() + 1);
|
||||
return format(inAYear, 'yyyy-MM-dd');
|
||||
})();
|
||||
|
||||
return {
|
||||
frequency: rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
|
||||
? (rule.frequency as EditorFrequency)
|
||||
: 'weekly',
|
||||
interval: rule?.interval || 1,
|
||||
weekDays,
|
||||
monthlyMode: nthDayInfo ? 'nth' : 'day',
|
||||
monthDay: rule?.byMonthDay?.[0] && rule.byMonthDay[0] >= 1 && rule.byMonthDay[0] <= 31
|
||||
? rule.byMonthDay[0]
|
||||
: eventStart.getDate(),
|
||||
nth: nthDayInfo && (nthDayInfo.nth === -1 || (nthDayInfo.nth >= 1 && nthDayInfo.nth <= 4))
|
||||
? nthDayInfo.nth
|
||||
: Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1),
|
||||
nthDay: nthDayInfo && WEEKDAYS.includes(nthDayInfo.day) ? nthDayInfo.day : startDay,
|
||||
month: monthFromRule >= 1 && monthFromRule <= 12 ? monthFromRule : eventStart.getMonth() + 1,
|
||||
endsMode: rule?.count ? 'after' : rule?.until ? 'on' : 'never',
|
||||
untilDate: untilFromRule,
|
||||
count: rule?.count ?? 12,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the JSCalendar rule the editor state describes. */
|
||||
export function buildRuleFromEditorValue(
|
||||
value: RecurrenceEditorValue,
|
||||
eventStart: Date,
|
||||
): RecurrenceRule {
|
||||
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
||||
const built: RecurrenceRule = {
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: value.frequency,
|
||||
interval: Math.max(1, value.interval),
|
||||
rscale: 'gregorian',
|
||||
skip: 'omit',
|
||||
firstDayOfWeek: 'mo',
|
||||
};
|
||||
|
||||
if (value.endsMode === 'after') {
|
||||
built.count = Math.max(1, value.count);
|
||||
} else if (value.endsMode === 'on' && value.untilDate) {
|
||||
built.until = `${value.untilDate}T23:59:59`;
|
||||
}
|
||||
|
||||
if (value.frequency === 'weekly') {
|
||||
const days = value.weekDays.length ? value.weekDays : [startDay];
|
||||
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
|
||||
} else if (value.frequency === 'monthly' || value.frequency === 'yearly') {
|
||||
if (value.monthlyMode === 'nth') {
|
||||
built.byDay = [{ day: value.nthDay, nthOfPeriod: value.nth }];
|
||||
} else {
|
||||
built.byMonthDay = [Math.min(31, Math.max(1, value.monthDay))];
|
||||
}
|
||||
if (value.frequency === 'yearly') {
|
||||
built.byMonth = [String(value.month)];
|
||||
}
|
||||
}
|
||||
|
||||
return built;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Turn an email address into a JMAP server URL so the sign-in flow doesn't
|
||||
// have to open with a "Server URL" field nobody knows the answer to.
|
||||
//
|
||||
// The probe is deliberately cheap and short-lived: a GET of
|
||||
// `<base>/.well-known/jmap` against a handful of conventional hosts, all in
|
||||
// parallel, with a hard deadline. Discovery is an optimisation — if it comes
|
||||
// back empty the caller falls through to asking for the server by hand, so a
|
||||
// slow or hostile network must never be worse than typing it in.
|
||||
|
||||
import { secureFetch } from './client-cert';
|
||||
|
||||
export const DISCOVERY_TIMEOUT_MS = 2500;
|
||||
|
||||
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
|
||||
const EMAIL_RE = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/;
|
||||
|
||||
/**
|
||||
* Accept what people actually type — `mail.example.com`, `example.com/`,
|
||||
* `https://mail.example.com/.well-known/jmap` — and return the base URL the
|
||||
* JMAP client expects, or null if it can't be one.
|
||||
*/
|
||||
export function normalizeServerUrl(input: string): string | null {
|
||||
let value = input.trim();
|
||||
if (!value) return null;
|
||||
|
||||
const scheme = SCHEME_RE.exec(value);
|
||||
if (scheme) {
|
||||
const protocol = scheme[1].toLowerCase();
|
||||
if (protocol !== 'http' && protocol !== 'https') return null;
|
||||
value = `${protocol}://${value.slice(scheme[0].length)}`;
|
||||
} else {
|
||||
value = `https://${value}`;
|
||||
}
|
||||
|
||||
// Someone pasting the endpoint they found in the webmail's settings gets the
|
||||
// same result as someone typing the address they use in the browser.
|
||||
value = value
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/\.well-known\/jmap$/i, '')
|
||||
.replace(/\/jmap$/i, '')
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
const host = value.slice(value.indexOf('://') + 3).split('/')[0];
|
||||
if (!host || /[\s@]/.test(host)) return null;
|
||||
|
||||
const hostname = host.split(':')[0].toLowerCase();
|
||||
if (!hostname) return null;
|
||||
if (!hostname.includes('.') && hostname !== 'localhost') return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function emailDomain(email: string): string | null {
|
||||
const match = EMAIL_RE.exec(email.trim());
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function isEmailAddress(value: string): boolean {
|
||||
return emailDomain(value) !== null;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
return url.slice(url.indexOf('://') + 3).split('/')[0].split(':')[0].toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts worth probing for `domain`, most likely first. The order is also the
|
||||
* tie-break order when several respond.
|
||||
*/
|
||||
export function serverCandidates(domain: string): string[] {
|
||||
return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`];
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(fallback), ms);
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch(() => {
|
||||
clearTimeout(timer);
|
||||
resolve(fallback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeSession(body: unknown): boolean {
|
||||
if (!body || typeof body !== 'object') return false;
|
||||
return 'capabilities' in body || 'apiUrl' in body || 'primaryAccounts' in body;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `baseUrl` looks like a JMAP server.
|
||||
*
|
||||
* A 401 counts as a hit — an unauthenticated probe of Stalwart's session
|
||||
* endpoint is *supposed* to be rejected, and that rejection is proof the
|
||||
* endpoint is there. A 200, on the other hand, has to actually contain a
|
||||
* session document: a captive portal or a catch-all web server will happily
|
||||
* answer 200 with HTML for any path we ask for.
|
||||
*/
|
||||
export async function probeJmapServer(
|
||||
baseUrl: string,
|
||||
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const abortTimer = setTimeout(() => controller?.abort(), timeoutMs);
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller?.signal,
|
||||
});
|
||||
if (response.status === 401) return true;
|
||||
if (response.status !== 200) return false;
|
||||
return looksLikeSession(await response.json());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
// The abort signal doesn't reach the native client-certificate path, so
|
||||
// race the whole thing rather than trusting the request to give up.
|
||||
return await withTimeout(request, timeoutMs, false);
|
||||
} finally {
|
||||
clearTimeout(abortTimer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DiscoveryOptions {
|
||||
/** Servers of already-registered accounts, trusted without a probe. */
|
||||
knownServerUrls?: string[];
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best guess at the mail server for `email`, or null if nothing answered in
|
||||
* time. Callers treat null as "ask the user".
|
||||
*/
|
||||
export async function discoverServerForEmail(
|
||||
email: string,
|
||||
options: DiscoveryOptions = {},
|
||||
): Promise<string | null> {
|
||||
const domain = emailDomain(email);
|
||||
if (!domain) return null;
|
||||
|
||||
// A server we're already signed in to for this domain needs no probe, and
|
||||
// covers the common case of adding a second account on the same host.
|
||||
const known = (options.knownServerUrls ?? [])
|
||||
.map(normalizeServerUrl)
|
||||
.filter((url): url is string => url !== null);
|
||||
const alreadyKnown = known.find((url) => {
|
||||
const host = hostOf(url);
|
||||
return host === domain || host.endsWith(`.${domain}`);
|
||||
});
|
||||
if (alreadyKnown) return alreadyKnown;
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
|
||||
const candidates = serverCandidates(domain);
|
||||
const results = await Promise.all(
|
||||
candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)),
|
||||
);
|
||||
|
||||
// `find` keeps candidate order, so a bare domain wins over `mail.` even when
|
||||
// both answer.
|
||||
return results.find((url): url is string => url !== null) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parseScript } from '../parser';
|
||||
import { generateScript } from '../generator';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
function makeBulwarkRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'bw-1',
|
||||
name: 'Bulwark Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('external rule preservation (issue #201)', () => {
|
||||
describe('parser - external rule recognition', () => {
|
||||
it('parses a Roundcube-style rule with "# rule:[Name]" comment', () => {
|
||||
const script = `require ["fileinto"];\n\n# rule:[Archive Newsletters]\nif header :contains "List-Id" "news" {\n fileinto "Newsletters";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
const rule = result.rules[0];
|
||||
expect(rule.origin).toBe('external');
|
||||
expect(rule.originLabel).toBe('Roundcube');
|
||||
expect(rule.name).toBe('Archive Newsletters');
|
||||
expect(rule.conditions[0]).toMatchObject({
|
||||
field: 'header',
|
||||
comparator: 'contains',
|
||||
value: 'news',
|
||||
headerName: 'List-Id',
|
||||
});
|
||||
expect(rule.actions[0]).toEqual({ type: 'move', value: 'Newsletters' });
|
||||
});
|
||||
|
||||
it('labels rules near a Nextcloud marker comment', () => {
|
||||
const script = `require ["fileinto"];\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].originLabel).toBe('Nextcloud');
|
||||
});
|
||||
|
||||
it('falls back to "External" label when no known marker is present', () => {
|
||||
const script = `require ["fileinto"];\n\nif header :contains "From" "boss@corp.com" {\n fileinto "Important";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].originLabel).toBe('External');
|
||||
});
|
||||
|
||||
it('parses anyof/allof conditions in external rules', () => {
|
||||
const script = `require ["fileinto"];\n\nif anyof(header :contains "From" "a@x.com", header :contains "From" "b@x.com") {\n fileinto "VIP";\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].matchType).toBe('any');
|
||||
expect(result.rules[0].conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('parses negated conditions (not header :is)', () => {
|
||||
const script = `if not header :is "From" "spam@x.com" {\n keep;\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.rules[0].conditions[0]).toMatchObject({
|
||||
field: 'from',
|
||||
comparator: 'not_is',
|
||||
value: 'spam@x.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks unrecognized blocks as opaque but preserves their raw text', () => {
|
||||
const script = `require ["relational"];\n\nif header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {\n keep;\n}\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
const rule = result.rules[0];
|
||||
expect(rule.origin).toBe('opaque');
|
||||
expect(rule.rawBlock).toContain('if header :value');
|
||||
});
|
||||
|
||||
it('collects all external require tokens', () => {
|
||||
const script = `require ["fileinto", "imap4flags", "body"];\n\nif header :is "Subject" "hi" { fileinto "A"; }\n`;
|
||||
const result = parseScript(script);
|
||||
expect(result.externalRequires).toEqual(expect.arrayContaining(['fileinto', 'imap4flags', 'body']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser - mixed Bulwark + external', () => {
|
||||
it('returns Bulwark rules from metadata and external rules from the rest', () => {
|
||||
const bulwark = [makeBulwarkRule({ name: 'Bulwark A' })];
|
||||
const bulwarkScript = generateScript(bulwark);
|
||||
const mixedScript = `${bulwarkScript}\n# External appended by Nextcloud\nif header :contains "List-Id" "devs" {\n fileinto "Dev";\n}\n`;
|
||||
|
||||
const result = parseScript(mixedScript);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const bulwarkParsed = result.rules.filter(r => !r.origin || r.origin === 'bulwark');
|
||||
const externalParsed = result.rules.filter(r => r.origin === 'external');
|
||||
expect(bulwarkParsed).toHaveLength(1);
|
||||
expect(bulwarkParsed[0].name).toBe('Bulwark A');
|
||||
expect(externalParsed).toHaveLength(1);
|
||||
expect(externalParsed[0].originLabel).toBe('Nextcloud');
|
||||
});
|
||||
|
||||
it('does not return Bulwark-emitted if-blocks as external duplicates', () => {
|
||||
const bulwark = [makeBulwarkRule({ name: 'My Bulwark Rule' })];
|
||||
const script = generateScript(bulwark);
|
||||
const result = parseScript(script);
|
||||
|
||||
// Only the metadata-sourced rule, no duplicate "external" entry.
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not duplicate a Bulwark rule whose values contain literal braces', () => {
|
||||
// Regression for an issue where a value like "Report Domain: {x}" caused
|
||||
// parseIfBlockToRule's naive indexOf('{') to point inside the value
|
||||
// instead of at the body, so the if-block fell back to an opaque rule
|
||||
// that escaped the bulwark-name dedup filter.
|
||||
const bulwark = [
|
||||
makeBulwarkRule({
|
||||
name: 'DMARC reports',
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'subject', comparator: 'contains', value: 'Report Domain: mydomain.com' },
|
||||
{ field: 'subject', comparator: 'contains', value: 'Report Domain: {mydomain.com}' },
|
||||
],
|
||||
actions: [{ type: 'mark_read' }, { type: 'move', value: 'DMARC' }],
|
||||
}),
|
||||
];
|
||||
const script = generateScript(bulwark);
|
||||
const result = parseScript(script);
|
||||
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].origin).toBeUndefined();
|
||||
expect(result.rules[0].name).toBe('DMARC reports');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generator - external splice', () => {
|
||||
it('appends external rawBlocks verbatim after Bulwark-managed output', () => {
|
||||
const externalRule: FilterRule = {
|
||||
id: 'ext-0',
|
||||
name: 'External',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'x', headerName: 'List-Id' }],
|
||||
actions: [{ type: 'move', value: 'Lists' }],
|
||||
stopProcessing: false,
|
||||
origin: 'external',
|
||||
originLabel: 'Nextcloud',
|
||||
rawBlock: '# Nextcloud Mail\nif header :contains "List-Id" "x" {\n fileinto "Lists";\n}\n',
|
||||
};
|
||||
const rules: FilterRule[] = [makeBulwarkRule(), externalRule];
|
||||
const script = generateScript(rules);
|
||||
|
||||
expect(script).toContain('# Rule: Bulwark Rule');
|
||||
expect(script).toContain('# Nextcloud Mail');
|
||||
expect(script).toContain('# --- External rules (managed outside Bulwark) ---');
|
||||
const bulwarkIdx = script.indexOf('# Rule: Bulwark Rule');
|
||||
const externalIdx = script.indexOf('# Nextcloud Mail');
|
||||
expect(bulwarkIdx).toBeLessThan(externalIdx);
|
||||
});
|
||||
|
||||
it('unions external requires into the top-level require line', () => {
|
||||
const script = generateScript([makeBulwarkRule()], undefined, {
|
||||
externalRequires: ['fileinto', 'imap4flags', 'body'],
|
||||
});
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
||||
expect(requireLine).toContain('"fileinto"');
|
||||
expect(requireLine).toContain('"imap4flags"');
|
||||
expect(requireLine).toContain('"body"');
|
||||
});
|
||||
|
||||
it('strips origin/rawBlock/originLabel from Bulwark rules when writing metadata', () => {
|
||||
const bulwarkWithJunk: FilterRule = {
|
||||
...makeBulwarkRule(),
|
||||
origin: 'bulwark',
|
||||
originLabel: 'shouldnotbehere',
|
||||
rawBlock: 'shouldnotbehere',
|
||||
};
|
||||
const script = generateScript([bulwarkWithJunk]);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.rules[0]).not.toHaveProperty('origin');
|
||||
expect(metadata.rules[0]).not.toHaveProperty('originLabel');
|
||||
expect(metadata.rules[0]).not.toHaveProperty('rawBlock');
|
||||
});
|
||||
|
||||
it('never writes external rules into metadata', () => {
|
||||
const ext: FilterRule = {
|
||||
id: 'ext-0',
|
||||
name: 'Ext',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'is', value: 'x@y' }],
|
||||
actions: [{ type: 'keep' }],
|
||||
stopProcessing: false,
|
||||
origin: 'external',
|
||||
rawBlock: '# ext\nif header :is "From" "x@y" { keep; }',
|
||||
};
|
||||
const script = generateScript([makeBulwarkRule(), ext]);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.rules).toHaveLength(1);
|
||||
expect(metadata.rules[0].name).toBe('Bulwark Rule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixture: mixed-origins.sieve', () => {
|
||||
const fixture = readFileSync(
|
||||
join(__dirname, 'fixtures', 'mixed-origins.sieve'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
it('identifies Bulwark, Roundcube, Nextcloud, External, and opaque rules', () => {
|
||||
const result = parseScript(fixture);
|
||||
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.vacation).toBeUndefined();
|
||||
|
||||
const byOrigin = {
|
||||
bulwark: result.rules.filter(r => !r.origin || r.origin === 'bulwark'),
|
||||
external: result.rules.filter(r => r.origin === 'external'),
|
||||
opaque: result.rules.filter(r => r.origin === 'opaque'),
|
||||
};
|
||||
|
||||
expect(byOrigin.bulwark).toHaveLength(2);
|
||||
expect(byOrigin.external.length).toBeGreaterThanOrEqual(3);
|
||||
expect(byOrigin.opaque).toHaveLength(1);
|
||||
|
||||
const labels = byOrigin.external.map(r => r.originLabel);
|
||||
expect(labels).toContain('Roundcube');
|
||||
expect(labels).toContain('Nextcloud');
|
||||
expect(labels).toContain('External');
|
||||
|
||||
const opaqueRule = byOrigin.opaque[0];
|
||||
expect(opaqueRule.rawBlock).toContain(':comparator "i;ascii-numeric"');
|
||||
});
|
||||
|
||||
it('preserves unknown-Sieve content through save round-trip', () => {
|
||||
const parsed = parseScript(fixture);
|
||||
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
|
||||
// The unparseable construct must appear verbatim in the regenerated script.
|
||||
expect(regenerated).toContain(':comparator "i;ascii-numeric"');
|
||||
// Require tokens from the external content are preserved.
|
||||
expect(regenerated).toContain('"relational"');
|
||||
// Bulwark rules are still present.
|
||||
expect(regenerated).toContain('# Rule: Archive newsletters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves external rules through parse → generate → parse', () => {
|
||||
const initial = `require ["fileinto", "imap4flags"];\n\n# rule:[VIP]\nif header :contains "From" "boss@company.com" {\n fileinto "VIP";\n addflag "\\\\Flagged";\n}\n\n# Nextcloud Mail - begin\nif header :contains "Subject" "invoice" {\n fileinto "Finance";\n}\n# Nextcloud Mail - end\n`;
|
||||
|
||||
const firstParse = parseScript(initial);
|
||||
expect(firstParse.rules).toHaveLength(2);
|
||||
|
||||
const regenerated = generateScript(firstParse.rules, firstParse.vacation, {
|
||||
externalRequires: firstParse.externalRequires,
|
||||
});
|
||||
const secondParse = parseScript(regenerated);
|
||||
|
||||
expect(secondParse.rules).toHaveLength(2);
|
||||
const names = secondParse.rules.map(r => r.name).sort();
|
||||
expect(names).toContain('VIP');
|
||||
});
|
||||
|
||||
it('does not destroy external rules when Bulwark regenerates after an edit', () => {
|
||||
const initial = `${generateScript([makeBulwarkRule({ name: 'Mine' })])}\n# rule:[Untouchable]\nif header :is "X-Spam" "yes" {\n discard;\n}\n`;
|
||||
|
||||
const parsed = parseScript(initial);
|
||||
const externalBefore = parsed.rules.filter(r => r.origin === 'external');
|
||||
expect(externalBefore).toHaveLength(1);
|
||||
|
||||
// Simulate a user edit - update the Bulwark rule name
|
||||
const edited = parsed.rules.map(r => (r.origin === 'external' || r.origin === 'opaque' ? r : { ...r, name: 'Mine (edited)' }));
|
||||
const regenerated = generateScript(edited, parsed.vacation, { externalRequires: parsed.externalRequires });
|
||||
const reparsed = parseScript(regenerated);
|
||||
|
||||
const externalAfter = reparsed.rules.filter(r => r.origin === 'external');
|
||||
expect(externalAfter).toHaveLength(1);
|
||||
expect(externalAfter[0].name).toBe('Untouchable');
|
||||
expect(externalAfter[0].conditions[0]).toMatchObject({ field: 'header', headerName: 'X-Spam' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nextcloud Mail marker regions', () => {
|
||||
const NEXTCLOUD_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
|
||||
|
||||
const nextcloudScript = [
|
||||
NEXTCLOUD_MARKER,
|
||||
'require ["imap4flags", "fileinto"];',
|
||||
NEXTCLOUD_MARKER,
|
||||
'',
|
||||
NEXTCLOUD_MARKER,
|
||||
'# FILTER: [{"name":"Wetterwarnungen"}]',
|
||||
'if header :contains "From" "weather@x.com" {',
|
||||
' fileinto "Weather";',
|
||||
'}',
|
||||
'',
|
||||
'# PayPal',
|
||||
'if header :contains "From" "paypal.de" {',
|
||||
' addflag "$paypal";',
|
||||
'}',
|
||||
NEXTCLOUD_MARKER,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('collapses a Nextcloud marker-pair region into one opaque rule labeled "Nextcloud"', () => {
|
||||
const result = parseScript(nextcloudScript);
|
||||
const nextcloud = result.rules.filter(r => r.originLabel === 'Nextcloud');
|
||||
expect(nextcloud).toHaveLength(1);
|
||||
expect(nextcloud[0].origin).toBe('opaque');
|
||||
// Every marker that wrapped actual rule content survives in the rawBlock.
|
||||
const markerCount = (nextcloud[0].rawBlock || '').split(NEXTCLOUD_MARKER).length - 1;
|
||||
expect(markerCount).toBe(2);
|
||||
// Inner if-blocks are not exposed as separate external rules.
|
||||
expect(result.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('merges require tokens from inside Nextcloud regions into externalRequires', () => {
|
||||
const result = parseScript(nextcloudScript);
|
||||
expect(result.externalRequires).toEqual(expect.arrayContaining(['imap4flags', 'fileinto']));
|
||||
});
|
||||
|
||||
it('drops require-only Nextcloud regions (no separate opaque rule for them)', () => {
|
||||
const script = [
|
||||
NEXTCLOUD_MARKER,
|
||||
'require ["fileinto"];',
|
||||
NEXTCLOUD_MARKER,
|
||||
'',
|
||||
].join('\n');
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toHaveLength(0);
|
||||
expect(result.externalRequires).toContain('fileinto');
|
||||
});
|
||||
|
||||
it('round-trips a Nextcloud region verbatim through parse → generate → parse', () => {
|
||||
const bulwark = makeBulwarkRule({ name: 'Test' });
|
||||
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
|
||||
|
||||
const parsed = parseScript(initial);
|
||||
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
|
||||
// Both wrapping markers remain, in the right positions.
|
||||
const firstMarker = regenerated.indexOf(NEXTCLOUD_MARKER);
|
||||
const lastMarker = regenerated.lastIndexOf(NEXTCLOUD_MARKER);
|
||||
expect(firstMarker).toBeGreaterThanOrEqual(0);
|
||||
expect(lastMarker).toBeGreaterThan(firstMarker);
|
||||
expect(regenerated).toContain('# FILTER: [{"name":"Wetterwarnungen"}]');
|
||||
expect(regenerated).toContain('# PayPal');
|
||||
|
||||
const reparsed = parseScript(regenerated);
|
||||
expect(reparsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(1);
|
||||
expect(reparsed.rules.filter(r => r.originLabel === 'External')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not emit duplicate "External rules" headers across repeated saves', () => {
|
||||
const bulwark = makeBulwarkRule({ name: 'Test' });
|
||||
const initial = `${generateScript([bulwark])}\n${nextcloudScript}`;
|
||||
|
||||
let script = initial;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const parsed = parseScript(script);
|
||||
script = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
}
|
||||
|
||||
const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length;
|
||||
expect(headerCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* @metadata:begin
|
||||
{"version":1,"rules":[{"id":"bw-news","name":"Archive newsletters","enabled":true,"matchType":"any","conditions":[{"field":"header","comparator":"contains","value":"unsubscribe","headerName":"List-Unsubscribe"},{"field":"from","comparator":"contains","value":"newsletter@"}],"actions":[{"type":"move","value":"Newsletters"}],"stopProcessing":false},{"id":"bw-vip","name":"Flag VIP senders","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"is","value":"ceo@company.com"},{"field":"from","comparator":"is","value":"board@company.com"}],"actions":[{"type":"star"},{"type":"mark_read"}],"stopProcessing":false}]}
|
||||
@metadata:end */
|
||||
|
||||
require ["body", "copy", "fileinto", "imap4flags", "relational"];
|
||||
|
||||
# Rule: Archive newsletters
|
||||
if anyof(header :contains "List-Unsubscribe" "unsubscribe", header :contains "From" "newsletter@") {
|
||||
fileinto "Newsletters";
|
||||
}
|
||||
|
||||
# Rule: Flag VIP senders
|
||||
if anyof(header :is "From" "ceo@company.com", header :is "From" "board@company.com") {
|
||||
addflag "\\Flagged";
|
||||
addflag "\\Seen";
|
||||
}
|
||||
|
||||
# --- External rules (managed outside Bulwark) ---
|
||||
|
||||
# rule:[Finance - auto-file invoices]
|
||||
if allof(header :contains "From" "billing@", header :contains "Subject" "invoice") {
|
||||
fileinto :copy "Finance/Invoices";
|
||||
keep;
|
||||
}
|
||||
|
||||
# Nextcloud Mail - begin
|
||||
# Filter installed by Nextcloud Mail app
|
||||
if header :contains "Subject" "[Support]" {
|
||||
fileinto "Support";
|
||||
}
|
||||
# Nextcloud Mail - end
|
||||
|
||||
# A handwritten rule without a tool-specific marker.
|
||||
# Bulwark should recognize this as generic "External" and preserve it.
|
||||
if not header :is "X-Spam-Status" "No" {
|
||||
fileinto "Junk";
|
||||
}
|
||||
|
||||
# A rule using a Sieve construct Bulwark's visual editor does not understand.
|
||||
# It must survive round-trips verbatim, shown to the user as read-only.
|
||||
if header :value "ge" :comparator "i;ascii-numeric" "X-Priority" ["3"] {
|
||||
fileinto "LowPriority";
|
||||
stop;
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
name: 'Test Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('generateScript', () => {
|
||||
it('outputs metadata and no require for empty rules', () => {
|
||||
const script = generateScript([]);
|
||||
expect(script).toContain('/* @metadata:begin');
|
||||
expect(script).toContain('@metadata:end */');
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
|
||||
it('embeds compact metadata JSON', () => {
|
||||
const rules = [makeRule()];
|
||||
const script = generateScript(rules);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
expect(match).not.toBeNull();
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.version).toBe(1);
|
||||
expect(metadata.rules).toHaveLength(1);
|
||||
expect(metadata.rules[0].id).toBe('rule-1');
|
||||
});
|
||||
|
||||
it('generates single rule with from/contains', () => {
|
||||
const script = generateScript([makeRule()]);
|
||||
expect(script).toContain('# Rule: Test Rule');
|
||||
expect(script).toContain('if header :contains "From" "test@example.com"');
|
||||
expect(script).toContain('fileinto "Archive";');
|
||||
});
|
||||
|
||||
describe('condition fields', () => {
|
||||
it('maps to field to "To" header', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'to', comparator: 'contains', value: 'me@x.com' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "To" "me@x.com"');
|
||||
});
|
||||
|
||||
it('maps cc field to "Cc" header', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'cc', comparator: 'is', value: 'cc@x.com' }],
|
||||
})]);
|
||||
expect(script).toContain('header :is "Cc" "cc@x.com"');
|
||||
});
|
||||
|
||||
it('maps subject field to "Subject" header', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'hello' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "Subject" "hello"');
|
||||
});
|
||||
|
||||
it('maps header field with custom headerName', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-Custom' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "X-Custom" "test"');
|
||||
});
|
||||
|
||||
it('handles size greater_than', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
|
||||
})]);
|
||||
expect(script).toContain('size :over 1000000');
|
||||
});
|
||||
|
||||
it('handles size less_than', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'size', comparator: 'less_than', value: '500' }],
|
||||
})]);
|
||||
expect(script).toContain('size :under 500');
|
||||
});
|
||||
|
||||
it('handles body contains', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'body', comparator: 'contains', value: 'keyword' }],
|
||||
})]);
|
||||
expect(script).toContain('body :contains "keyword"');
|
||||
expect(script).toContain('"body"');
|
||||
});
|
||||
|
||||
it('handles body is', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'body', comparator: 'is', value: 'exact' }],
|
||||
})]);
|
||||
expect(script).toContain('body :is "exact"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparators', () => {
|
||||
it('generates not_contains with not wrapper', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'not_contains', value: 'spam' }],
|
||||
})]);
|
||||
expect(script).toContain('not header :contains "From" "spam"');
|
||||
});
|
||||
|
||||
it('generates not_is with not wrapper', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'not_is', value: 'bad@x.com' }],
|
||||
})]);
|
||||
expect(script).toContain('not header :is "From" "bad@x.com"');
|
||||
});
|
||||
|
||||
it('generates starts_with as :matches with trailing *', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'starts_with', value: 'Re:' }],
|
||||
})]);
|
||||
expect(script).toContain('header :matches "Subject" "Re:*"');
|
||||
});
|
||||
|
||||
it('generates ends_with as :matches with leading *', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'ends_with', value: 'urgent' }],
|
||||
})]);
|
||||
expect(script).toContain('header :matches "Subject" "*urgent"');
|
||||
});
|
||||
|
||||
it('generates matches as :matches', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'matches', value: '*@company.com' }],
|
||||
})]);
|
||||
expect(script).toContain('header :matches "From" "*@company.com"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('match types', () => {
|
||||
it('wraps multiple conditions with allof for matchType all', () => {
|
||||
const script = generateScript([makeRule({
|
||||
matchType: 'all',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'a' },
|
||||
{ field: 'subject', comparator: 'contains', value: 'b' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('allof(header :contains "From" "a", header :contains "Subject" "b")');
|
||||
});
|
||||
|
||||
it('wraps multiple conditions with anyof for matchType any', () => {
|
||||
const script = generateScript([makeRule({
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'x' },
|
||||
{ field: 'to', comparator: 'contains', value: 'y' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('anyof(header :contains "From" "x", header :contains "To" "y")');
|
||||
});
|
||||
|
||||
it('uses no wrapper for single condition', () => {
|
||||
const script = generateScript([makeRule()]);
|
||||
expect(script).not.toContain('allof');
|
||||
expect(script).not.toContain('anyof');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
it('generates move as fileinto', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'Spam' }] })]);
|
||||
expect(script).toContain('fileinto "Spam";');
|
||||
});
|
||||
|
||||
it('generates copy as fileinto :copy', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'Backup' }] })]);
|
||||
expect(script).toContain('fileinto :copy "Backup";');
|
||||
});
|
||||
|
||||
it('generates forward as redirect', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'forward', value: 'fwd@x.com' }] })]);
|
||||
expect(script).toContain('redirect "fwd@x.com";');
|
||||
});
|
||||
|
||||
it('generates mark_read as addflag \\Seen', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
});
|
||||
|
||||
it('generates star as addflag \\Flagged', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
|
||||
expect(script).toContain('addflag "\\\\Flagged";');
|
||||
});
|
||||
|
||||
it('generates add_label as addflag $label:Label', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'Important' }] })]);
|
||||
expect(script).toContain('addflag "$label:Important";');
|
||||
});
|
||||
|
||||
it('generates discard', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'discard' }] })]);
|
||||
expect(script).toContain('discard;');
|
||||
});
|
||||
|
||||
it('generates reject with message', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'Go away' }] })]);
|
||||
expect(script).toContain('reject "Go away";');
|
||||
});
|
||||
|
||||
it('generates keep', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'keep' }] })]);
|
||||
expect(script).toContain('keep;');
|
||||
});
|
||||
|
||||
it('generates stop', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'stop' }] })]);
|
||||
expect(script).toContain('stop;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopProcessing', () => {
|
||||
it('appends stop when stopProcessing is true', () => {
|
||||
const script = generateScript([makeRule({ stopProcessing: true })]);
|
||||
const ifBlock = script.slice(script.indexOf('if '));
|
||||
expect(ifBlock).toContain('stop;');
|
||||
});
|
||||
|
||||
it('does not duplicate stop if last action is stop', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'move', value: 'X' }, { type: 'stop' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not append stop after discard', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'discard' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toBeNull();
|
||||
});
|
||||
|
||||
it('does not append stop after reject', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'reject', value: 'No' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled rules', () => {
|
||||
it('excludes disabled rules from Sieve code', () => {
|
||||
const script = generateScript([makeRule({ enabled: false, name: 'Hidden' })]);
|
||||
expect(script).not.toContain('# Rule: Hidden');
|
||||
expect(script).not.toContain('if header');
|
||||
});
|
||||
|
||||
it('preserves disabled rules in metadata', () => {
|
||||
const rules = [makeRule({ enabled: false })];
|
||||
const script = generateScript(rules);
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
const metadata = JSON.parse(match![1]);
|
||||
expect(metadata.rules[0].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles mixed enabled and disabled rules', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', name: 'Active', enabled: true }),
|
||||
makeRule({ id: '2', name: 'Inactive', enabled: false }),
|
||||
makeRule({ id: '3', name: 'Also Active', enabled: true }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
expect(script).toContain('# Rule: Active');
|
||||
expect(script).not.toContain('# Rule: Inactive');
|
||||
expect(script).toContain('# Rule: Also Active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('require extensions', () => {
|
||||
it('includes fileinto for move', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'move', value: 'X' }] })]);
|
||||
expect(script).toContain('"fileinto"');
|
||||
});
|
||||
|
||||
it('includes fileinto and copy for copy action', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'copy', value: 'X' }] })]);
|
||||
expect(script).toContain('"copy"');
|
||||
expect(script).toContain('"fileinto"');
|
||||
});
|
||||
|
||||
it('includes imap4flags for mark_read', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'mark_read' }] })]);
|
||||
expect(script).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('includes imap4flags for star', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'star' }] })]);
|
||||
expect(script).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('includes imap4flags for add_label', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'X' }] })]);
|
||||
expect(script).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('includes reject for reject action', () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'reject', value: 'No' }] })]);
|
||||
expect(script).toContain('"reject"');
|
||||
});
|
||||
|
||||
it('includes body extension for body conditions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'body', comparator: 'contains', value: 'test' }],
|
||||
})]);
|
||||
expect(script).toContain('"body"');
|
||||
});
|
||||
|
||||
it('deduplicates extensions', () => {
|
||||
const script = generateScript([
|
||||
makeRule({ id: '1', actions: [{ type: 'star' }] }),
|
||||
makeRule({ id: '2', actions: [{ type: 'mark_read' }] }),
|
||||
]);
|
||||
const matches = script.match(/"imap4flags"/g);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('only considers enabled rules for requires', () => {
|
||||
const script = generateScript([
|
||||
makeRule({ id: '1', enabled: false, actions: [{ type: 'reject', value: 'X' }] }),
|
||||
makeRule({ id: '2', enabled: true, actions: [{ type: 'move', value: 'Y' }] }),
|
||||
]);
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'));
|
||||
expect(requireLine).not.toContain('reject');
|
||||
expect(requireLine).toContain('fileinto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaping', () => {
|
||||
it('escapes double quotes in values', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello"' }],
|
||||
})]);
|
||||
expect(script).toContain('say \\"hello\\"');
|
||||
});
|
||||
|
||||
it('escapes backslashes in values', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'path\\to\\file' }],
|
||||
})]);
|
||||
expect(script).toContain('path\\\\to\\\\file');
|
||||
});
|
||||
|
||||
it('escapes folder names in actions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'move', value: 'My "Folder"' }],
|
||||
})]);
|
||||
expect(script).toContain('fileinto "My \\"Folder\\"";');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves rules through generate → parse cycle', () => {
|
||||
const rules: FilterRule[] = [
|
||||
makeRule({ id: '1', name: 'Rule A', enabled: true }),
|
||||
makeRule({ id: '2', name: 'Rule B', enabled: false }),
|
||||
makeRule({
|
||||
id: '3',
|
||||
name: 'Complex',
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'contains', value: 'boss' },
|
||||
{ field: 'subject', comparator: 'starts_with', value: 'URGENT' },
|
||||
],
|
||||
actions: [{ type: 'star' }, { type: 'mark_read' }],
|
||||
stopProcessing: true,
|
||||
}),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
it('generates multiple rules in order', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', name: 'First', actions: [{ type: 'move', value: 'A' }] }),
|
||||
makeRule({ id: '2', name: 'Second', actions: [{ type: 'move', value: 'B' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const firstIdx = script.indexOf('# Rule: First');
|
||||
const secondIdx = script.indexOf('# Rule: Second');
|
||||
expect(firstIdx).toBeLessThan(secondIdx);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('skips rules with empty conditions', () => {
|
||||
const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]);
|
||||
expect(script).not.toContain('# Rule: Empty');
|
||||
expect(script).not.toContain('if ');
|
||||
});
|
||||
|
||||
it('skips rules with empty actions', () => {
|
||||
const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]);
|
||||
expect(script).not.toContain('# Rule: NoAction');
|
||||
expect(script).not.toContain('if ');
|
||||
});
|
||||
|
||||
it('uses X-Unknown for header field without headerName', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "X-Unknown" "test"');
|
||||
});
|
||||
|
||||
it('handles all enabled rules with different extensions combined', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }),
|
||||
makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }),
|
||||
makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
||||
expect(requireLine).toContain('"fileinto"');
|
||||
expect(requireLine).toContain('"reject"');
|
||||
expect(requireLine).toContain('"body"');
|
||||
expect(requireLine).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('generates no require line when only keep/discard/stop/forward actions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }],
|
||||
})]);
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
|
||||
it('handles multiple actions on same rule', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [
|
||||
{ type: 'move', value: 'Folder' },
|
||||
{ type: 'mark_read' },
|
||||
{ type: 'star' },
|
||||
{ type: 'stop' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('fileinto "Folder";');
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
expect(script).toContain('addflag "\\\\Flagged";');
|
||||
expect(script).toContain('stop;');
|
||||
});
|
||||
|
||||
it('generates valid script for all-disabled rules', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', enabled: false }),
|
||||
makeRule({ id: '2', enabled: false }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
expect(script).toContain('@metadata:begin');
|
||||
expect(script).not.toContain('if ');
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseScript } from '../parser';
|
||||
import { generateScript } from '../generator';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
function makeRule(overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
name: 'Test Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseScript', () => {
|
||||
it('extracts rules from valid metadata', () => {
|
||||
const rules = [makeRule()];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('parses external rules when no Bulwark metadata is present', () => {
|
||||
const result = parseScript('require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }');
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toHaveLength(1);
|
||||
expect(result.rules[0].origin).toBe('external');
|
||||
expect(result.rules[0].conditions[0]).toMatchObject({ field: 'from', comparator: 'contains', value: 'x' });
|
||||
expect(result.rules[0].actions[0]).toEqual({ type: 'move', value: 'Y' });
|
||||
expect(result.externalRequires).toContain('fileinto');
|
||||
});
|
||||
|
||||
it('returns isOpaque for corrupted JSON', () => {
|
||||
const script = '/* @metadata:begin\n{not valid json\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for version mismatch', () => {
|
||||
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for empty metadata block', () => {
|
||||
const script = '/* @metadata:begin\n\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for missing rules array', () => {
|
||||
const script = '/* @metadata:begin\n{"version":1}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for invalid rule objects', () => {
|
||||
const script = '/* @metadata:begin\n{"version":1,"rules":[{"id":"x"}]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles metadata with extra whitespace', () => {
|
||||
const rules = [makeRule()];
|
||||
const json = JSON.stringify({ version: 1, rules });
|
||||
const script = `/* @metadata:begin\n ${json} \n@metadata:end */\n\nrequire ["fileinto"];`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('handles script with only metadata block', () => {
|
||||
const rules = [makeRule({ enabled: false })];
|
||||
const json = JSON.stringify({ version: 1, rules });
|
||||
const script = `/* @metadata:begin\n${json}\n@metadata:end */`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('returns isOpaque for missing end marker', () => {
|
||||
const script = '/* @metadata:begin\n{"version":1,"rules":[]}';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty string as an empty, editable script (not opaque)', () => {
|
||||
const result = parseScript('');
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('preserves complex rules through generate → parse', () => {
|
||||
const rules: FilterRule[] = [
|
||||
makeRule({ id: '1', name: 'Newsletter', enabled: true, stopProcessing: true }),
|
||||
makeRule({
|
||||
id: '2',
|
||||
name: 'VIP',
|
||||
matchType: 'any',
|
||||
conditions: [
|
||||
{ field: 'from', comparator: 'is', value: 'boss@company.com' },
|
||||
{ field: 'from', comparator: 'is', value: 'ceo@company.com' },
|
||||
],
|
||||
actions: [{ type: 'star' }, { type: 'mark_read' }],
|
||||
}),
|
||||
makeRule({ id: '3', name: 'Disabled', enabled: false }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves rules with special characters', () => {
|
||||
const rules = [makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: 'say "hello" \\ world' }],
|
||||
actions: [{ type: 'move', value: 'My "Folder"' }],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves all action types', () => {
|
||||
const rules = [makeRule({
|
||||
actions: [
|
||||
{ type: 'move', value: 'Folder' },
|
||||
{ type: 'copy', value: 'Backup' },
|
||||
{ type: 'forward', value: 'fwd@x.com' },
|
||||
{ type: 'mark_read' },
|
||||
{ type: 'star' },
|
||||
{ type: 'add_label', value: 'Tag' },
|
||||
{ type: 'keep' },
|
||||
],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation edge cases', () => {
|
||||
it('returns isOpaque when rule has non-string id', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when rule has non-boolean enabled', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when rule has invalid matchType', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when condition missing value', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when action missing type', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid empty rules array', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves all comparator types through round-trip', () => {
|
||||
const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const;
|
||||
const rules = comparators.map((comparator, i) => makeRule({
|
||||
id: `r${i}`,
|
||||
name: `Rule ${comparator}`,
|
||||
conditions: [{ field: 'from', comparator, value: 'test' }],
|
||||
}));
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves size comparators through round-trip', () => {
|
||||
const rules = [
|
||||
makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }),
|
||||
makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves header field with custom headerName', () => {
|
||||
const rules = [makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
FilterRule,
|
||||
FilterCondition,
|
||||
FilterAction,
|
||||
FilterMetadata,
|
||||
VacationSieveConfig,
|
||||
} from './types';
|
||||
|
||||
// Ported from the webmail's lib/sieve/generator.ts. Emits an RFC 5228 Sieve
|
||||
// script with a leading `@metadata` JSON comment that lets the parser round-trip
|
||||
// the visual rules losslessly. Keep this in sync with the web generator so a
|
||||
// script saved on one client reads back cleanly on the other.
|
||||
|
||||
const HEADER_MAP: Record<string, string> = {
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
cc: 'Cc',
|
||||
subject: 'Subject',
|
||||
};
|
||||
|
||||
function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
if (field === 'size') {
|
||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||
return `size ${op} ${value}`;
|
||||
}
|
||||
|
||||
if (field === 'body') {
|
||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
||||
return `body ${matchType} "${escapeString(value)}"`;
|
||||
}
|
||||
|
||||
const headerName = field === 'header'
|
||||
? (condition.headerName || 'X-Unknown')
|
||||
: HEADER_MAP[field];
|
||||
|
||||
const escaped = escapeString(value);
|
||||
|
||||
switch (comparator) {
|
||||
case 'contains':
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
case 'not_contains':
|
||||
return `not header :contains "${headerName}" "${escaped}"`;
|
||||
case 'is':
|
||||
return `header :is "${headerName}" "${escaped}"`;
|
||||
case 'not_is':
|
||||
return `not header :is "${headerName}" "${escaped}"`;
|
||||
case 'starts_with':
|
||||
return `header :matches "${headerName}" "${escaped}*"`;
|
||||
case 'ends_with':
|
||||
return `header :matches "${headerName}" "*${escaped}"`;
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" "${escaped}"`;
|
||||
default:
|
||||
return `header :contains "${headerName}" "${escaped}"`;
|
||||
}
|
||||
}
|
||||
|
||||
function generateActions(actions: FilterAction[]): string[] {
|
||||
return actions.map(action => {
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
return `fileinto "${escapeString(action.value || '')}";`;
|
||||
case 'copy':
|
||||
return `fileinto :copy "${escapeString(action.value || '')}";`;
|
||||
case 'forward':
|
||||
return `redirect "${escapeString(action.value || '')}";`;
|
||||
case 'mark_read':
|
||||
return 'addflag "\\\\Seen";';
|
||||
case 'star':
|
||||
return 'addflag "\\\\Flagged";';
|
||||
case 'add_label':
|
||||
return `addflag "$label:${escapeString(action.value || '')}";`;
|
||||
case 'discard':
|
||||
return 'discard;';
|
||||
case 'reject':
|
||||
return `reject "${escapeString(action.value || '')}";`;
|
||||
case 'keep':
|
||||
return 'keep;';
|
||||
case 'stop':
|
||||
return 'stop;';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): string[] {
|
||||
const extensions = new Set<string>();
|
||||
const enabledRules = rules.filter(r => r.enabled);
|
||||
|
||||
if (vacation?.isEnabled) {
|
||||
extensions.add('vacation');
|
||||
}
|
||||
|
||||
for (const rule of enabledRules) {
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
switch (action.type) {
|
||||
case 'move':
|
||||
extensions.add('fileinto');
|
||||
break;
|
||||
case 'copy':
|
||||
extensions.add('fileinto');
|
||||
extensions.add('copy');
|
||||
break;
|
||||
case 'mark_read':
|
||||
case 'star':
|
||||
case 'add_label':
|
||||
extensions.add('imap4flags');
|
||||
break;
|
||||
case 'reject':
|
||||
extensions.add('reject');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...extensions];
|
||||
}
|
||||
|
||||
function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'originLabel' | 'rawBlock'> {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
enabled: r.enabled,
|
||||
matchType: r.matchType,
|
||||
conditions: r.conditions,
|
||||
actions: r.actions,
|
||||
stopProcessing: r.stopProcessing,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
/**
|
||||
* Require extensions used by external (non-Bulwark) rules that we must
|
||||
* preserve in the top-level `require` directive. Duplicates with Bulwark's
|
||||
* own requires are deduplicated.
|
||||
*/
|
||||
externalRequires?: string[];
|
||||
}
|
||||
|
||||
export function generateScript(
|
||||
rules: FilterRule[],
|
||||
vacation?: VacationSieveConfig,
|
||||
options: GenerateOptions = {},
|
||||
): string {
|
||||
// Partition rules by origin. Treat missing origin as 'bulwark' for back-compat.
|
||||
const bulwarkRules: FilterRule[] = [];
|
||||
const externalRules: FilterRule[] = [];
|
||||
for (const r of rules) {
|
||||
if (r.origin && r.origin !== 'bulwark') externalRules.push(r);
|
||||
else bulwarkRules.push(r);
|
||||
}
|
||||
|
||||
const metadata: FilterMetadata = {
|
||||
version: 1,
|
||||
rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[],
|
||||
};
|
||||
if (vacation?.isEnabled) {
|
||||
metadata.vacation = vacation;
|
||||
}
|
||||
const metadataJson = JSON.stringify(metadata);
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('/* @metadata:begin');
|
||||
lines.push(metadataJson);
|
||||
lines.push('@metadata:end */');
|
||||
lines.push('');
|
||||
|
||||
const bulwarkRequires = computeRequires(bulwarkRules, vacation);
|
||||
const externalRequires = options.externalRequires ?? [];
|
||||
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
|
||||
|
||||
if (allRequires.length > 0) {
|
||||
lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`);
|
||||
}
|
||||
|
||||
if (vacation?.isEnabled) {
|
||||
lines.push('');
|
||||
lines.push('# Vacation auto-reply');
|
||||
const vacationParts: string[] = [];
|
||||
if (vacation.subject) {
|
||||
vacationParts.push(`:subject "${escapeString(vacation.subject)}"`);
|
||||
}
|
||||
vacationParts.push(`"${escapeString(vacation.textBody || '')}"`);
|
||||
lines.push(`vacation ${vacationParts.join(' ')};`);
|
||||
}
|
||||
|
||||
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
|
||||
|
||||
for (const rule of enabledBulwarkRules) {
|
||||
if (rule.conditions.length === 0 || rule.actions.length === 0) {
|
||||
// Empty conditions/actions can't form a valid `if`; the rule modal
|
||||
// prevents this, but skip defensively rather than emit broken Sieve.
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`# Rule: ${rule.name}`);
|
||||
|
||||
const conditions = rule.conditions.map(generateCondition);
|
||||
let conditionStr: string;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
conditionStr = 'true';
|
||||
} else if (conditions.length === 1) {
|
||||
conditionStr = conditions[0];
|
||||
} else {
|
||||
const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof';
|
||||
conditionStr = `${wrapper}(${conditions.join(', ')})`;
|
||||
}
|
||||
|
||||
const actionLines = generateActions(rule.actions);
|
||||
|
||||
if (rule.stopProcessing) {
|
||||
const lastAction = rule.actions[rule.actions.length - 1];
|
||||
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
|
||||
actionLines.push('stop;');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`if ${conditionStr} {`);
|
||||
for (const actionLine of actionLines) {
|
||||
lines.push(` ${actionLine}`);
|
||||
}
|
||||
lines.push('}');
|
||||
}
|
||||
|
||||
// Append preserved external rules verbatim. Each rawBlock already carries its
|
||||
// own leading comments and trailing whitespace from the source script.
|
||||
if (externalRules.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('# --- External rules (managed outside Bulwark) ---');
|
||||
for (const ext of externalRules) {
|
||||
if (!ext.rawBlock) continue;
|
||||
lines.push(ext.rawBlock.replace(/\s+$/, ''));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
import type {
|
||||
FilterAction,
|
||||
FilterCondition,
|
||||
FilterComparator,
|
||||
FilterConditionField,
|
||||
FilterMetadata,
|
||||
FilterRule,
|
||||
VacationSieveConfig,
|
||||
} from './types';
|
||||
|
||||
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
|
||||
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
|
||||
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
|
||||
// are best-effort parsed into read-only "external" rules or preserved opaquely.
|
||||
|
||||
export interface ParseResult {
|
||||
rules: FilterRule[];
|
||||
isOpaque: boolean;
|
||||
vacation?: VacationSieveConfig;
|
||||
externalRequires: string[];
|
||||
}
|
||||
|
||||
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
|
||||
|
||||
const METADATA_BEGIN = '/* @metadata:begin';
|
||||
const METADATA_END = '@metadata:end */';
|
||||
|
||||
const NEXTCLOUD_BLOCK_MARKER = "### Nextcloud Mail: Filters ### DON'T EDIT ###";
|
||||
|
||||
const BULWARK_EXTERNAL_HEADER_RE =
|
||||
/^[ \t]*#[ \t]*---[ \t]*External rules \(managed outside Bulwark\)[ \t]*---[ \t]*\r?\n/m;
|
||||
|
||||
const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
|
||||
from: 'from',
|
||||
to: 'to',
|
||||
cc: 'cc',
|
||||
subject: 'subject',
|
||||
};
|
||||
|
||||
function isValidCondition(c: unknown): boolean {
|
||||
if (!c || typeof c !== 'object') return false;
|
||||
const cond = c as Record<string, unknown>;
|
||||
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
|
||||
}
|
||||
|
||||
function isValidAction(a: unknown): boolean {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const act = a as Record<string, unknown>;
|
||||
return typeof act.type === 'string';
|
||||
}
|
||||
|
||||
function isValidRule(rule: unknown): rule is FilterRule {
|
||||
if (!rule || typeof rule !== 'object') return false;
|
||||
const r = rule as Record<string, unknown>;
|
||||
if (
|
||||
typeof r.id !== 'string' ||
|
||||
typeof r.name !== 'string' ||
|
||||
typeof r.enabled !== 'boolean' ||
|
||||
(r.matchType !== 'all' && r.matchType !== 'any') ||
|
||||
!Array.isArray(r.conditions) ||
|
||||
!Array.isArray(r.actions) ||
|
||||
typeof r.stopProcessing !== 'boolean'
|
||||
) return false;
|
||||
|
||||
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Stalwart-generated vacation-only scripts (no metadata).
|
||||
*/
|
||||
function detectVacationOnlyScript(content: string): ParseResult | null {
|
||||
if (!/\bvacation\b/.test(content)) return null;
|
||||
|
||||
const stripped = content
|
||||
.replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '')
|
||||
.replace(/#[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
|
||||
const structural = stripped.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
||||
|
||||
if (/\b(?:if|elsif|else)\b/.test(structural)) return null;
|
||||
if (!/\bvacation\b/.test(structural)) return null;
|
||||
|
||||
const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/);
|
||||
const subject = subjectMatch ? unescapeSieveString(subjectMatch[1]) : '';
|
||||
|
||||
let textBody = '';
|
||||
const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\r\n]*\r?\n\r?\n([\s\S]*?)"[\s\S]*?;/);
|
||||
if (mimeBodyMatch) {
|
||||
textBody = mimeBodyMatch[1].trim();
|
||||
} else {
|
||||
const allQuoted = [...stripped.matchAll(/"((?:[^"\\]|\\.)*)"/g)];
|
||||
const last = allQuoted[allQuoted.length - 1];
|
||||
if (last) textBody = unescapeSieveString(last[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
rules: [],
|
||||
isOpaque: false,
|
||||
vacation: { isEnabled: true, subject, textBody },
|
||||
externalRequires: [],
|
||||
};
|
||||
}
|
||||
|
||||
function unescapeSieveString(s: string): string {
|
||||
return s.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
function skipStringLit(s: string, i: number): number {
|
||||
i++;
|
||||
while (i < s.length) {
|
||||
if (s[i] === '\\') { i += 2; continue; }
|
||||
if (s[i] === '"') return i + 1;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipHashComment(s: string, i: number): number {
|
||||
while (i < s.length && s[i] !== '\n') i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipBlockComment(s: string, i: number): number {
|
||||
const end = s.indexOf('*/', i + 2);
|
||||
return end === -1 ? s.length : end + 2;
|
||||
}
|
||||
|
||||
function skipStatement(s: string, i: number): number {
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
if (c === ';') return i + 1;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipBalanced(s: string, i: number, open: string, close: string): number {
|
||||
let depth = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
if (c === open) { depth++; i++; continue; }
|
||||
if (c === close) {
|
||||
depth--;
|
||||
i++;
|
||||
if (depth === 0) return i;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function skipIfStatement(s: string, i: number): number {
|
||||
// positioned after 'if' keyword; skip through condition expression and body braces
|
||||
while (i < s.length && s[i] !== '{') {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '(') { i = skipBalanced(s, i, '(', ')'); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
i++;
|
||||
}
|
||||
if (i >= s.length) return i;
|
||||
return skipBalanced(s, i, '{', '}');
|
||||
}
|
||||
|
||||
interface TopBlock {
|
||||
kind: 'require' | 'if' | 'vacation' | 'other';
|
||||
raw: string; // from start-of-leading-text to end of statement
|
||||
statement: string; // the statement itself (no leading comments/whitespace)
|
||||
startIdx: number;
|
||||
endIdx: number;
|
||||
}
|
||||
|
||||
function scanTopLevel(content: string): TopBlock[] {
|
||||
const blocks: TopBlock[] = [];
|
||||
let i = 0;
|
||||
let segmentStart = 0;
|
||||
|
||||
const consume = (kind: TopBlock['kind'], stmtStart: number, stmtEnd: number) => {
|
||||
blocks.push({
|
||||
kind,
|
||||
raw: content.slice(segmentStart, stmtEnd),
|
||||
statement: content.slice(stmtStart, stmtEnd),
|
||||
startIdx: segmentStart,
|
||||
endIdx: stmtEnd,
|
||||
});
|
||||
segmentStart = stmtEnd;
|
||||
};
|
||||
|
||||
while (i < content.length) {
|
||||
// Skip whitespace
|
||||
while (i < content.length && /\s/.test(content[i])) i++;
|
||||
if (i >= content.length) break;
|
||||
|
||||
const c = content[i];
|
||||
|
||||
// Comments (stay attached to next block as leading text)
|
||||
if (c === '#') { i = skipHashComment(content, i); continue; }
|
||||
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
|
||||
|
||||
// Identifier
|
||||
const m = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(content.slice(i));
|
||||
if (!m) { i++; continue; }
|
||||
|
||||
const ident = m[0];
|
||||
const stmtStart = i;
|
||||
i += ident.length;
|
||||
|
||||
if (ident === 'require') {
|
||||
i = skipStatement(content, i);
|
||||
consume('require', stmtStart, i);
|
||||
} else if (ident === 'if') {
|
||||
i = skipIfStatement(content, i);
|
||||
consume('if', stmtStart, i);
|
||||
} else if (ident === 'vacation') {
|
||||
i = skipStatement(content, i);
|
||||
consume('vacation', stmtStart, i);
|
||||
} else {
|
||||
i = skipStatement(content, i);
|
||||
consume('other', stmtStart, i);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractRequireTokens(stmt: string): string[] {
|
||||
const mList = /require\s+\[([\s\S]*?)\]\s*;/.exec(stmt);
|
||||
if (mList) return [...mList[1].matchAll(/"([^"]+)"/g)].map(x => x[1]);
|
||||
const mSingle = /require\s+"([^"]+)"\s*;/.exec(stmt);
|
||||
return mSingle ? [mSingle[1]] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last contiguous block of comments immediately preceding a
|
||||
* statement - comments separated from the statement by a blank line are not
|
||||
* considered its leading commentary (they likely belong to the previous
|
||||
* block, e.g. a trailing "# Nextcloud Mail - end" marker).
|
||||
*/
|
||||
function lastCommentChunk(leading: string): string {
|
||||
const parts = leading.split(/\r?\n\s*\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||
return parts.length ? parts[parts.length - 1] : '';
|
||||
}
|
||||
|
||||
function detectOriginLabel(leading: string): string {
|
||||
const chunk = lastCommentChunk(leading);
|
||||
const lower = chunk.toLowerCase();
|
||||
if (/rule:\s*\[/i.test(chunk) || /roundcube|managesieve/.test(lower)) return 'Roundcube';
|
||||
if (/nextcloud/.test(lower)) return 'Nextcloud';
|
||||
if (/horde|ingo/.test(lower)) return 'Horde';
|
||||
if (/kolab/.test(lower)) return 'Kolab';
|
||||
if (/dovecot/.test(lower)) return 'Dovecot';
|
||||
if (/thunderbird/.test(lower)) return 'Thunderbird';
|
||||
return 'External';
|
||||
}
|
||||
|
||||
function extractName(leading: string, fallback: string): string {
|
||||
// Roundcube: "# rule:[Name]"
|
||||
const rc = leading.match(/#\s*rule:\s*\[([^\]]+)\]/i);
|
||||
if (rc) return rc[1].trim();
|
||||
|
||||
// "# Rule: Name"
|
||||
const rr = leading.match(/#\s*Rule:\s*(.+?)\s*$/mi);
|
||||
if (rr) return rr[1].trim();
|
||||
|
||||
// Last non-empty trimmed comment line
|
||||
const lines = leading.split('\n').map(l => l.replace(/^\s*#\s*/, '').trim()).filter(Boolean);
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && last.length <= 80 && !/^\/\*|\*\/$/.test(last)) return last;
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function splitTopLevelComma(s: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
|
||||
if (c === ')' || c === ']' || c === '}') { depth--; i++; continue; }
|
||||
if (c === ',' && depth === 0) {
|
||||
parts.push(s.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
parts.push(s.slice(start));
|
||||
return parts.map(p => p.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function splitStatements(body: string): string[] {
|
||||
const stmts: string[] = [];
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
while (i < body.length) {
|
||||
const c = body[i];
|
||||
if (c === '"') { i = skipStringLit(body, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(body, i); continue; }
|
||||
if (c === '/' && body[i + 1] === '*') { i = skipBlockComment(body, i); continue; }
|
||||
if (c === ';') {
|
||||
stmts.push(body.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const tail = body.slice(start).trim();
|
||||
if (tail) stmts.push(tail);
|
||||
return stmts.map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeHeaderName(name: string): { field: FilterConditionField; headerName?: string } {
|
||||
const lc = name.toLowerCase();
|
||||
if (FIELD_FROM_HEADER[lc]) return { field: FIELD_FROM_HEADER[lc] };
|
||||
return { field: 'header', headerName: name };
|
||||
}
|
||||
|
||||
function parseAtom(raw: string): FilterCondition | null {
|
||||
let s = raw.trim();
|
||||
let negated = false;
|
||||
|
||||
if (/^not\b/.test(s)) {
|
||||
negated = true;
|
||||
s = s.replace(/^not\s*/, '').trim();
|
||||
if (s.startsWith('(') && s.endsWith(')')) {
|
||||
s = s.slice(1, -1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawValue] = m;
|
||||
const value = unescapeSieveString(rawValue);
|
||||
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||
|
||||
let comparator: FilterComparator;
|
||||
if (tag === 'contains') {
|
||||
comparator = negated ? 'not_contains' : 'contains';
|
||||
} else if (tag === 'is') {
|
||||
comparator = negated ? 'not_is' : 'is';
|
||||
} else {
|
||||
// :matches - distinguish starts_with / ends_with / matches
|
||||
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
|
||||
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
|
||||
comparator = 'starts_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
if (starPositions.length === 1 && starPositions[0] === 0) {
|
||||
comparator = 'ends_with';
|
||||
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
comparator = 'matches';
|
||||
}
|
||||
|
||||
const cond: FilterCondition = { field, comparator, value };
|
||||
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
|
||||
return cond;
|
||||
}
|
||||
|
||||
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
|
||||
}
|
||||
|
||||
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||
if (m) {
|
||||
return { field: 'size', comparator: m[1] === 'over' ? 'greater_than' : 'less_than', value: m[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCondition(raw: string): { matchType: 'all' | 'any'; conditions: FilterCondition[] } | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
|
||||
const allMatch = /^allof\s*\(([\s\S]*)\)$/.exec(s);
|
||||
const anyMatch = /^anyof\s*\(([\s\S]*)\)$/.exec(s);
|
||||
let matchType: 'all' | 'any' = 'all';
|
||||
let inner: string;
|
||||
if (allMatch) { matchType = 'all'; inner = allMatch[1]; }
|
||||
else if (anyMatch) { matchType = 'any'; inner = anyMatch[1]; }
|
||||
else inner = s;
|
||||
|
||||
const parts = splitTopLevelComma(inner);
|
||||
const conditions: FilterCondition[] = [];
|
||||
for (const part of parts) {
|
||||
const atom = parseAtom(part);
|
||||
if (!atom) return null;
|
||||
conditions.push(atom);
|
||||
}
|
||||
return { matchType, conditions };
|
||||
}
|
||||
|
||||
function parseAction(raw: string): FilterAction | null {
|
||||
const s = raw.trim();
|
||||
|
||||
let m = /^fileinto\s+:copy\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'copy', value: unescapeSieveString(m[1]) };
|
||||
|
||||
m = /^fileinto\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'move', value: unescapeSieveString(m[1]) };
|
||||
|
||||
m = /^redirect\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'forward', value: unescapeSieveString(m[1]) };
|
||||
|
||||
m = /^addflag\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) {
|
||||
const flag = unescapeSieveString(m[1]);
|
||||
if (flag === '\\Seen') return { type: 'mark_read' };
|
||||
if (flag === '\\Flagged') return { type: 'star' };
|
||||
if (flag.startsWith('$label:')) return { type: 'add_label', value: flag.slice('$label:'.length) };
|
||||
return null;
|
||||
}
|
||||
|
||||
m = /^reject\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
|
||||
if (m) return { type: 'reject', value: unescapeSieveString(m[1]) };
|
||||
|
||||
if (/^discard$/.test(s)) return { type: 'discard' };
|
||||
if (/^keep$/.test(s)) return { type: 'keep' };
|
||||
if (/^stop$/.test(s)) return { type: 'stop' };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findBodyOpenBrace(s: string): number {
|
||||
// Locate the first `{` that introduces the if-block body, skipping over
|
||||
// string literals and comments. A naive indexOf('{') would otherwise pick
|
||||
// up braces inside condition values (e.g. `:contains "{foo}"`).
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === '"') { i = skipStringLit(s, i); continue; }
|
||||
if (c === '#') { i = skipHashComment(s, i); continue; }
|
||||
if (c === '/' && s[i + 1] === '*') { i = skipBlockComment(s, i); continue; }
|
||||
if (c === '{') return i;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseIfBlockToRule(block: TopBlock, idPrefix: string, index: number): FilterRule | null {
|
||||
const stmt = block.statement;
|
||||
const afterIf = stmt.replace(/^if\s+/, '');
|
||||
const braceIdx = findBodyOpenBrace(afterIf);
|
||||
// scanTopLevel's skipIfStatement uses balanced-brace scanning, so the
|
||||
// statement always terminates at the matching `}`.
|
||||
const lastBraceIdx = afterIf.length - 1;
|
||||
if (braceIdx === -1 || afterIf[lastBraceIdx] !== '}' || lastBraceIdx < braceIdx) return null;
|
||||
|
||||
const condStr = afterIf.slice(0, braceIdx).trim();
|
||||
const bodyStr = afterIf.slice(braceIdx + 1, lastBraceIdx).trim();
|
||||
|
||||
const cond = parseCondition(condStr);
|
||||
if (!cond || cond.conditions.length === 0) return null;
|
||||
|
||||
const actionStmts = splitStatements(bodyStr);
|
||||
const actions: FilterAction[] = [];
|
||||
for (const st of actionStmts) {
|
||||
const a = parseAction(st);
|
||||
if (!a) return null;
|
||||
actions.push(a);
|
||||
}
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
let stopProcessing = false;
|
||||
if (actions.length > 0 && actions[actions.length - 1].type === 'stop') {
|
||||
const hasNonStop = actions.some(a => a.type !== 'stop');
|
||||
if (hasNonStop) {
|
||||
stopProcessing = true;
|
||||
actions.pop();
|
||||
}
|
||||
}
|
||||
|
||||
const leading = block.raw.slice(0, block.statement ? block.raw.length - block.statement.length : 0);
|
||||
const originLabel = detectOriginLabel(leading);
|
||||
const name = extractName(leading, `Rule ${index + 1}`);
|
||||
|
||||
return {
|
||||
id: `${idPrefix}-${index}`,
|
||||
name,
|
||||
enabled: true,
|
||||
matchType: cond.matchType,
|
||||
conditions: cond.conditions,
|
||||
actions,
|
||||
stopProcessing,
|
||||
origin: 'external',
|
||||
originLabel,
|
||||
rawBlock: block.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpaqueRule(block: TopBlock, idPrefix: string, index: number): FilterRule {
|
||||
const leading = block.raw.slice(0, block.raw.length - block.statement.length);
|
||||
const originLabel = detectOriginLabel(leading);
|
||||
const name = extractName(leading, `External rule ${index + 1}`);
|
||||
return {
|
||||
id: `${idPrefix}-${index}`,
|
||||
name,
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
stopProcessing: false,
|
||||
origin: 'opaque',
|
||||
originLabel,
|
||||
rawBlock: block.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nextcloud Mail wraps its managed filter region with a pair of
|
||||
* `### Nextcloud Mail: Filters ### DON'T EDIT ###` markers and typically
|
||||
* emits two such regions - one enclosing its own `require [...]` line and
|
||||
* another enclosing the if-blocks it generates from its `# FILTER: [...]`
|
||||
* JSON comments. Parsing the interior blocks individually loses the outer
|
||||
* markers (causing later rules to fall back to "External") and mis-attaches
|
||||
* them to neighboring blocks on round-trip.
|
||||
*
|
||||
* Treat each marker-pair as one opaque external rule so the whole region
|
||||
* round-trips verbatim, merge any require tokens into the top-level list,
|
||||
* and drop regions that carry nothing but a require (their extensions are
|
||||
* already represented in the merged require line).
|
||||
*/
|
||||
function extractNextcloudRegions(content: string): {
|
||||
cleaned: string;
|
||||
rules: FilterRule[];
|
||||
requires: string[];
|
||||
} {
|
||||
const marker = NEXTCLOUD_BLOCK_MARKER;
|
||||
const positions: number[] = [];
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(marker, searchFrom);
|
||||
if (idx === -1) break;
|
||||
const atLineStart = idx === 0 || content[idx - 1] === '\n';
|
||||
if (atLineStart) positions.push(idx);
|
||||
searchFrom = idx + marker.length;
|
||||
}
|
||||
|
||||
if (positions.length < 2) {
|
||||
return { cleaned: content, rules: [], requires: [] };
|
||||
}
|
||||
|
||||
const rules: FilterRule[] = [];
|
||||
const requires: string[] = [];
|
||||
const markerRe = new RegExp(escapeRegex(marker), 'g');
|
||||
|
||||
let cleaned = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (let i = 0; i + 1 < positions.length; i += 2) {
|
||||
const start = positions[i];
|
||||
const closeStart = positions[i + 1];
|
||||
const lineEnd = content.indexOf('\n', closeStart + marker.length);
|
||||
const end = lineEnd === -1 ? content.length : lineEnd + 1;
|
||||
|
||||
cleaned += content.slice(cursor, start);
|
||||
const raw = content.slice(start, end);
|
||||
|
||||
for (const m of raw.matchAll(/require\s+\[([\s\S]*?)\]\s*;/g)) {
|
||||
for (const tok of m[1].matchAll(/"([^"]+)"/g)) {
|
||||
if (!requires.includes(tok[1])) requires.push(tok[1]);
|
||||
}
|
||||
}
|
||||
const singleReq = /require\s+"([^"]+)"\s*;/.exec(raw);
|
||||
if (singleReq && !requires.includes(singleReq[1])) requires.push(singleReq[1]);
|
||||
|
||||
const stripped = raw
|
||||
.replace(markerRe, '')
|
||||
.replace(/require\s+\[[\s\S]*?\]\s*;/g, '')
|
||||
.replace(/require\s+"[^"]+"\s*;/g, '')
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/#[^\n]*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim();
|
||||
|
||||
if (stripped.length > 0) {
|
||||
rules.push({
|
||||
id: `nextcloud-${rules.length}`,
|
||||
name: 'Nextcloud Mail filters',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
stopProcessing: false,
|
||||
origin: 'opaque',
|
||||
originLabel: 'Nextcloud',
|
||||
rawBlock: raw,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
cleaned += content.slice(cursor);
|
||||
return { cleaned, rules, requires };
|
||||
}
|
||||
|
||||
function stripBulwarkExternalHeader(content: string): string {
|
||||
return content.replace(BULWARK_EXTERNAL_HEADER_RE, '');
|
||||
}
|
||||
|
||||
function parseExternalRules(
|
||||
content: string,
|
||||
idPrefix: string,
|
||||
): { rules: FilterRule[]; externalRequires: string[]; hasContent: boolean } {
|
||||
const blocks = scanTopLevel(content);
|
||||
const rules: FilterRule[] = [];
|
||||
const externalRequires: string[] = [];
|
||||
let index = 0;
|
||||
let sawAnyStatement = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'require') {
|
||||
sawAnyStatement = true;
|
||||
for (const tok of extractRequireTokens(block.statement)) {
|
||||
if (!externalRequires.includes(tok)) externalRequires.push(tok);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.kind === 'if') {
|
||||
sawAnyStatement = true;
|
||||
const rule = parseIfBlockToRule(block, idPrefix, index);
|
||||
rules.push(rule ?? makeOpaqueRule(block, idPrefix, index));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// vacation/other: treat as opaque preserved block
|
||||
sawAnyStatement = true;
|
||||
rules.push(makeOpaqueRule(block, idPrefix, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return { rules, externalRequires, hasContent: sawAnyStatement };
|
||||
}
|
||||
|
||||
export function parseScript(content: string): ParseResult {
|
||||
const beginIdx = content.indexOf(METADATA_BEGIN);
|
||||
|
||||
if (beginIdx !== -1) {
|
||||
const endIdx = content.indexOf(METADATA_END, beginIdx);
|
||||
if (endIdx === -1) return OPAQUE;
|
||||
|
||||
const jsonStart = beginIdx + METADATA_BEGIN.length;
|
||||
const jsonStr = content.slice(jsonStart, endIdx).trim();
|
||||
|
||||
let metadata: FilterMetadata;
|
||||
try {
|
||||
metadata = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
console.warn('[filters] Failed to parse Sieve metadata JSON:', e);
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
if (!metadata || metadata.version !== 1) return OPAQUE;
|
||||
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
||||
|
||||
for (const rule of metadata.rules) {
|
||||
if (!isValidRule(rule)) return OPAQUE;
|
||||
}
|
||||
|
||||
// Scan the portion AFTER the metadata block for external rules. A prior
|
||||
// Bulwark save may have emitted its "External rules" header here; strip
|
||||
// it so it does not get re-attached to the first external rule's rawBlock
|
||||
// and written out twice on the next save.
|
||||
const afterMetadata = stripBulwarkExternalHeader(
|
||||
content.slice(endIdx + METADATA_END.length),
|
||||
);
|
||||
const nextcloud = extractNextcloudRegions(afterMetadata);
|
||||
const external = parseExternalRules(nextcloud.cleaned, 'ext');
|
||||
|
||||
// Parsed bulwark rules intentionally omit an explicit `origin` field so
|
||||
// round-trip equality with metadata-only callers holds. Absence of origin
|
||||
// is treated as 'bulwark' everywhere downstream.
|
||||
const bulwarkRules: FilterRule[] = metadata.rules;
|
||||
|
||||
const externalRequires = [
|
||||
...external.externalRequires,
|
||||
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
|
||||
];
|
||||
|
||||
// Drop any external "rules" that are really the bulwark-managed if-blocks or vacation.
|
||||
// Recognizable by the leading comment "# Rule: <name>" or "# Vacation auto-reply".
|
||||
// This applies regardless of whether the block parsed as a structured rule or
|
||||
// fell back to opaque - a Bulwark-emitted block may fail to round-trip cleanly
|
||||
// (e.g. a value with literal braces) but the `# Rule: <name>` marker still
|
||||
// identifies it as ours.
|
||||
const filteredExternal = external.rules.filter(r => {
|
||||
const raw = r.rawBlock || '';
|
||||
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
|
||||
if (match) {
|
||||
const name = match[1].trim();
|
||||
if (bulwarkRules.some(b => b.name === name)) return false;
|
||||
}
|
||||
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
rules: [...bulwarkRules, ...nextcloud.rules, ...filteredExternal],
|
||||
isOpaque: false,
|
||||
vacation: metadata.vacation,
|
||||
externalRequires,
|
||||
};
|
||||
}
|
||||
|
||||
// No metadata - check vacation-only first
|
||||
const vacationOnly = detectVacationOnlyScript(content);
|
||||
if (vacationOnly) return vacationOnly;
|
||||
|
||||
// Extract Nextcloud-managed marker regions first so their interior is not
|
||||
// parsed as a series of loose if-blocks (which would lose the outer markers
|
||||
// and mis-label later blocks as generic "External").
|
||||
const nextcloud = extractNextcloudRegions(content);
|
||||
const external = parseExternalRules(nextcloud.cleaned, 'ext');
|
||||
|
||||
const allRules = [...nextcloud.rules, ...external.rules];
|
||||
const allRequires = [
|
||||
...external.externalRequires,
|
||||
...nextcloud.requires.filter(r => !external.externalRequires.includes(r)),
|
||||
];
|
||||
|
||||
if (!external.hasContent && allRules.length === 0) {
|
||||
// Entirely empty or whitespace/comments only - treat as empty, editable.
|
||||
// Preserve any require tokens lifted out of Nextcloud marker regions so
|
||||
// they can be re-emitted in the top-level require line.
|
||||
return { rules: [], isOpaque: false, externalRequires: allRequires };
|
||||
}
|
||||
|
||||
// If at least one block parsed into a structured rule, expose them as external.
|
||||
const anyParsed = external.rules.some(r => r.origin === 'external');
|
||||
if (anyParsed || allRules.length > 0) {
|
||||
return { rules: allRules, isOpaque: false, externalRequires: allRequires };
|
||||
}
|
||||
|
||||
return OPAQUE;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Sieve filter data model. Ported from the webmail's lib/jmap/sieve-types.ts so
|
||||
// the generated scripts round-trip byte-for-byte between the two clients (a
|
||||
// rule authored on the web reads back as a structured rule on mobile and vice
|
||||
// versa, because both embed the same `@metadata` JSON block).
|
||||
|
||||
export interface SieveScript {
|
||||
id: string;
|
||||
name: string;
|
||||
blobId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SieveCapabilities {
|
||||
implementation: string;
|
||||
maxSizeScript: number;
|
||||
sieveExtensions: string[];
|
||||
notificationMethods: string[];
|
||||
externalLists: string[];
|
||||
}
|
||||
|
||||
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
|
||||
|
||||
export type FilterComparator =
|
||||
| 'contains' | 'not_contains'
|
||||
| 'is' | 'not_is'
|
||||
| 'starts_with' | 'ends_with'
|
||||
| 'matches'
|
||||
| 'greater_than' | 'less_than';
|
||||
|
||||
export type FilterActionType =
|
||||
| 'move' | 'copy' | 'forward'
|
||||
| 'mark_read' | 'star' | 'add_label'
|
||||
| 'discard' | 'reject' | 'keep' | 'stop';
|
||||
|
||||
export interface FilterCondition {
|
||||
field: FilterConditionField;
|
||||
comparator: FilterComparator;
|
||||
value: string;
|
||||
headerName?: string;
|
||||
}
|
||||
|
||||
export interface FilterAction {
|
||||
type: FilterActionType;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type FilterOrigin = 'bulwark' | 'external' | 'opaque';
|
||||
|
||||
export interface FilterRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
matchType: 'all' | 'any';
|
||||
conditions: FilterCondition[];
|
||||
actions: FilterAction[];
|
||||
stopProcessing: boolean;
|
||||
origin?: FilterOrigin;
|
||||
originLabel?: string;
|
||||
rawBlock?: string;
|
||||
}
|
||||
|
||||
export interface VacationSieveConfig {
|
||||
isEnabled: boolean;
|
||||
subject: string;
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
export interface FilterMetadata {
|
||||
version: 1;
|
||||
rules: FilterRule[];
|
||||
vacation?: VacationSieveConfig;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// TOTP enrolment helper. Mirrors the webmail's use of `otpauth` (Settings →
|
||||
// Security → Two-Factor) but without pulling in a native crypto dependency:
|
||||
// we only need to mint a fresh shared secret and the `otpauth://` enrolment
|
||||
// URL. The 6-digit code the user types is verified server-side by Stalwart
|
||||
// (x:AccountPassword/set with otpAuth.otpCode), so we never run HMAC-SHA1 on
|
||||
// device — that keeps this file dependency-free.
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
// 20 bytes = 160 bits, matching the webmail's `new OTPAuth.Secret({ size: 20 })`
|
||||
// and the SHA-1 HMAC block expectation of virtually every authenticator app.
|
||||
const SECRET_BYTES = 20;
|
||||
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
const c = (globalThis as {
|
||||
crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array; randomUUID?: () => string };
|
||||
}).crypto;
|
||||
|
||||
// Best: a real CSPRNG (web / a polyfilled RN).
|
||||
if (c?.getRandomValues) {
|
||||
try {
|
||||
c.getRandomValues(bytes);
|
||||
return bytes;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Good enough: derive bytes from crypto.randomUUID, which Hermes exposes
|
||||
// even when getRandomValues isn't polyfilled (~122 bits of entropy each).
|
||||
if (c?.randomUUID) {
|
||||
try {
|
||||
let hex = '';
|
||||
while (hex.length < length * 2) hex += c.randomUUID().replace(/-/g, '');
|
||||
for (let i = 0; i < length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return bytes;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: Math.random. Same trade-off the rest of the app accepts in
|
||||
// lib/uuid.ts and lib/oauth.ts; the secret is one-off and server-validated.
|
||||
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// RFC 4648 base32, no padding — the form authenticator apps expect in the
|
||||
// `secret` query parameter.
|
||||
function base32Encode(bytes: Uint8Array): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
value = (value << 8) | bytes[i];
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface TotpEnrolment {
|
||||
// Base32 secret, shown to the user for manual entry.
|
||||
secret: string;
|
||||
// Same secret grouped in fours for easier manual typing.
|
||||
secretFormatted: string;
|
||||
// otpauth:// URL — feeds the "Add to authenticator app" deep link and the
|
||||
// value sent to the server as otpUrl.
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function generateTotpEnrolment(accountLabel: string): TotpEnrolment {
|
||||
const secret = base32Encode(randomBytes(SECRET_BYTES));
|
||||
const issuer = 'Stalwart';
|
||||
const label = accountLabel || 'account';
|
||||
const params = new URLSearchParams({
|
||||
secret,
|
||||
issuer,
|
||||
algorithm: 'SHA1',
|
||||
digits: '6',
|
||||
period: '30',
|
||||
});
|
||||
// Label is conventionally "Issuer:account"; both label path and issuer param
|
||||
// are encoded so authenticators that read either render a sensible name.
|
||||
const url = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?${params.toString()}`;
|
||||
const secretFormatted = secret.replace(/(.{4})/g, '$1 ').trim();
|
||||
return { secret, secretFormatted, url };
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import type { EmailAddress } from '../api/types';
|
||||
|
||||
export type RootStackParamList = {
|
||||
MainTabs: undefined;
|
||||
EmailThread: { emailId: string; threadId: string; subject?: string };
|
||||
EmailSource: { emailId: string; blobId: string; subject?: string };
|
||||
EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string };
|
||||
EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string };
|
||||
Compose:
|
||||
| {
|
||||
mode?: 'reply' | 'replyAll' | 'forward';
|
||||
@@ -13,6 +13,7 @@ export type RootStackParamList = {
|
||||
cc?: EmailAddress[];
|
||||
subject: string;
|
||||
body?: string;
|
||||
receivedAt?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
};
|
||||
|
||||
+100
-10
@@ -52,9 +52,13 @@ import { TasksSheet } from '../components/calendar/TasksSheet';
|
||||
import { ICalImportSheet } from '../components/calendar/ICalImportSheet';
|
||||
import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet';
|
||||
import {
|
||||
applySharedCalendarColors,
|
||||
buildEventDayIndex,
|
||||
eventsOnDayFromIndex,
|
||||
pickUnusedCalendarColor,
|
||||
sharedCalendarColorKey,
|
||||
type EventDayIndex,
|
||||
type TimeFormat,
|
||||
} from '../lib/calendar-utils';
|
||||
import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
@@ -117,6 +121,9 @@ export default function CalendarScreen() {
|
||||
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
|
||||
const showBirthdayCalendar = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
const contacts = useContactsStore((s) => s.contacts);
|
||||
|
||||
const [currentDate, setCurrentDate] = React.useState(new Date());
|
||||
@@ -154,7 +161,9 @@ export default function CalendarScreen() {
|
||||
const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete);
|
||||
const deleteTask = useCalendarStore((s) => s.deleteTask);
|
||||
const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility);
|
||||
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||
const storeCalendars = useCalendarStore((s) => s.calendars);
|
||||
const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds);
|
||||
const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds);
|
||||
const loading = useCalendarStore((s) => s.loading);
|
||||
const error = useCalendarStore((s) => s.error);
|
||||
@@ -169,15 +178,69 @@ export default function CalendarScreen() {
|
||||
return generateBirthdayEvents(contacts, after.toISOString(), before.toISOString());
|
||||
}, [showBirthdayCalendar, contacts, viewMode, currentDate, calendarFirstDayOfWeek]);
|
||||
|
||||
// Per-viewer recolor (#345): shared calendars get the viewer's local color
|
||||
// override applied before anything renders. Personal calendars pass through.
|
||||
const displayCalendars = React.useMemo(
|
||||
() => applySharedCalendarColors(storeCalendars, sharedCalendarColors),
|
||||
[storeCalendars, sharedCalendarColors],
|
||||
);
|
||||
|
||||
// Auto-assign a random, not-yet-used palette color to any freshly shared
|
||||
// calendar so multiple shared calendars don't collide on one color. Runs
|
||||
// once per calendar (guarded by the presence of an existing key), and the
|
||||
// user can still overwrite it from the sidebar.
|
||||
React.useEffect(() => {
|
||||
const missing = storeCalendars.filter(
|
||||
(cal) => cal.isShared && !sharedCalendarColors[sharedCalendarColorKey(cal)],
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
// Seed "used" with personal calendar colors plus already-assigned shared
|
||||
// overrides so the picks stay distinct from what's already on screen.
|
||||
const used = new Set<string>();
|
||||
for (const cal of storeCalendars) {
|
||||
if (!cal.isShared && cal.color) used.add(cal.color.toLowerCase());
|
||||
}
|
||||
for (const color of Object.values(sharedCalendarColors)) {
|
||||
if (color) used.add(color.toLowerCase());
|
||||
}
|
||||
for (const cal of missing) {
|
||||
const color = pickUnusedCalendarColor(used);
|
||||
used.add(color.toLowerCase());
|
||||
setSharedCalendarColor(sharedCalendarColorKey(cal), color);
|
||||
}
|
||||
}, [storeCalendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||
|
||||
const allCalendars = React.useMemo(
|
||||
() => (showBirthdayCalendar ? [...storeCalendars, createBirthdayCalendar()] : storeCalendars),
|
||||
[storeCalendars, showBirthdayCalendar],
|
||||
() => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars),
|
||||
[displayCalendars, showBirthdayCalendar],
|
||||
);
|
||||
const allEvents = React.useMemo(
|
||||
() => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents),
|
||||
[storeEvents, birthdayEvents],
|
||||
);
|
||||
|
||||
// VTODO-only task lists (Todoist imports, per-project Thunderbird task
|
||||
// lists) are sibling CalDAV collections that Calendar/get returns alongside
|
||||
// event calendars. Their contents surface in the Tasks sheet, so keep them
|
||||
// out of the calendar drawer and out of the event/import calendar pickers —
|
||||
// listing them as calendars just produces duplicate/confusing entries. (#28)
|
||||
const eventCalendars = React.useMemo(() => {
|
||||
if (taskOnlyCalendarIds.length === 0) return allCalendars;
|
||||
const taskOnly = new Set(taskOnlyCalendarIds);
|
||||
return allCalendars.filter((cal) => !taskOnly.has(cal.id));
|
||||
}, [allCalendars, taskOnlyCalendarIds]);
|
||||
|
||||
// The Tasks sheet keeps the full list but with task lists sorted first, so
|
||||
// its create-picker (which defaults to the first writable calendar) targets
|
||||
// a dedicated task list when the account has one.
|
||||
const taskSheetCalendars = React.useMemo(() => {
|
||||
if (taskOnlyCalendarIds.length === 0) return allCalendars;
|
||||
const taskOnly = new Set(taskOnlyCalendarIds);
|
||||
return [...allCalendars].sort(
|
||||
(a, b) => Number(taskOnly.has(b.id)) - Number(taskOnly.has(a.id)),
|
||||
);
|
||||
}, [allCalendars, taskOnlyCalendarIds]);
|
||||
|
||||
const calendars = React.useMemo(
|
||||
() => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)),
|
||||
[allCalendars, hiddenCalendarIds],
|
||||
@@ -252,25 +315,34 @@ export default function CalendarScreen() {
|
||||
[],
|
||||
);
|
||||
|
||||
// A recurring series member is a master (has recurrenceRules) or an expanded
|
||||
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
|
||||
// we must NOT key off originalId here, since shared/group events now carry it
|
||||
// for id-namespacing even when they aren't recurring.
|
||||
const isRecurringSeriesMember = React.useCallback(
|
||||
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'edit', event });
|
||||
} else {
|
||||
openEditDirect(event);
|
||||
}
|
||||
}, [openEditDirect, isReadOnlyEvent]);
|
||||
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
|
||||
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
|
||||
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
|
||||
setDetailEvent(null);
|
||||
if (event.recurrenceRules?.length || event.originalId) {
|
||||
if (isRecurringSeriesMember(event)) {
|
||||
setPendingAction({ kind: 'delete', event });
|
||||
} else {
|
||||
void deleteEvent(event.id);
|
||||
}
|
||||
}, [deleteEvent, isReadOnlyEvent]);
|
||||
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]);
|
||||
|
||||
const handleScopeSelect = React.useCallback(
|
||||
async (scope: RecurrenceEditScope) => {
|
||||
@@ -420,6 +492,7 @@ export default function CalendarScreen() {
|
||||
events={events}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectEvent={setDetailEvent}
|
||||
/>
|
||||
)}
|
||||
@@ -436,6 +509,7 @@ export default function CalendarScreen() {
|
||||
date={selectedDate}
|
||||
eventsByDay={eventsByDay}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onSelectEvent={setDetailEvent}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
@@ -447,6 +521,7 @@ export default function CalendarScreen() {
|
||||
<EventDetailSheet
|
||||
event={detailEvent}
|
||||
calendars={calendars}
|
||||
timeFormat={calendarTimeFormat}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
@@ -469,7 +544,7 @@ export default function CalendarScreen() {
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
calendars={eventCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
@@ -485,18 +560,25 @@ export default function CalendarScreen() {
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
calendars={eventCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
onImport={() => { setSidebarVisible(false); setImportVisible(true); }}
|
||||
onManageSubscriptions={() => { setSidebarVisible(false); setSubscriptionsVisible(true); }}
|
||||
onSetDefault={(cal) => { void setDefaultCalendar(cal.id); }}
|
||||
onSetColor={(cal, color) => setSharedCalendarColor(sharedCalendarColorKey(cal), color)}
|
||||
onResetColor={(cal) => {
|
||||
// Drop the local override; the auto-assign effect picks a fresh
|
||||
// unused color (so it never reverts to a collision).
|
||||
removeSharedCalendarColor(sharedCalendarColorKey(cal));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TasksSheet
|
||||
visible={tasksVisible}
|
||||
tasks={tasks}
|
||||
calendars={allCalendars}
|
||||
calendars={taskSheetCalendars}
|
||||
onClose={() => setTasksVisible(false)}
|
||||
onCreate={createTask}
|
||||
onToggle={toggleTaskComplete}
|
||||
@@ -505,7 +587,12 @@ export default function CalendarScreen() {
|
||||
|
||||
<ICalImportSheet
|
||||
visible={importVisible}
|
||||
calendars={storeCalendars}
|
||||
// Import batch-creates against the primary account, so only offer the
|
||||
// user's own event calendars (no shared calendars, no task lists) as
|
||||
// targets.
|
||||
calendars={eventCalendars.filter(
|
||||
(cal) => !cal.isShared && cal.id !== BIRTHDAY_CALENDAR_ID,
|
||||
)}
|
||||
onClose={() => setImportVisible(false)}
|
||||
onImport={importEvents}
|
||||
/>
|
||||
@@ -522,6 +609,7 @@ function DayEventList({
|
||||
date,
|
||||
eventsByDay,
|
||||
calendars,
|
||||
timeFormat,
|
||||
onSelectEvent,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
@@ -529,6 +617,7 @@ function DayEventList({
|
||||
date: Date;
|
||||
eventsByDay: EventDayIndex;
|
||||
calendars: Calendar[];
|
||||
timeFormat?: TimeFormat;
|
||||
onSelectEvent?: (event: CalendarEvent) => void;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -565,6 +654,7 @@ function DayEventList({
|
||||
key={event.id}
|
||||
event={event}
|
||||
calendars={calendars}
|
||||
timeFormat={timeFormat}
|
||||
onPress={onSelectEvent}
|
||||
/>
|
||||
))}
|
||||
|
||||
+138
-37
@@ -17,15 +17,17 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { File as FsFile } from 'expo-file-system';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button } from '../components';
|
||||
import { Button, IdentitySheet } from '../components';
|
||||
import RichTextEditor, {
|
||||
type RichTextEditorHandle,
|
||||
type RichTextSelectionState,
|
||||
} from '../components/RichTextEditor';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { isGroup } from '../lib/contact-utils';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -69,7 +71,16 @@ function parseRecipients(input: string): Recipient[] {
|
||||
.split(/[,;\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((email) => ({ name: '', email }));
|
||||
.map((s) => {
|
||||
const match = s.match(/^(.*?)\s*<([^\s@]+@[^\s@]+\.[^\s@]+)>$/);
|
||||
if (match) {
|
||||
return {
|
||||
name: match[1].replace(/^["']|["']$/g, '').trim(),
|
||||
email: match[2].trim(),
|
||||
};
|
||||
}
|
||||
return { name: '', email: s };
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
@@ -113,10 +124,11 @@ function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove
|
||||
}
|
||||
|
||||
function SuggestionList({
|
||||
suggestions, onPick,
|
||||
suggestions, onPick, onPressIn,
|
||||
}: {
|
||||
suggestions: Array<{ contact: ContactCard; name: string; email: string }>;
|
||||
onPick: (s: { name: string; email: string }) => void;
|
||||
onPressIn?: () => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -127,6 +139,7 @@ function SuggestionList({
|
||||
return (
|
||||
<Pressable
|
||||
key={`${s.contact.id}-${s.email}-${i}`}
|
||||
onPressIn={onPressIn}
|
||||
onPress={() => onPick(s)}
|
||||
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
||||
>
|
||||
@@ -212,6 +225,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const insets = useSafeAreaInsets();
|
||||
// Track the visible keyboard obstruction so the format bar stays above it.
|
||||
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
|
||||
@@ -249,8 +264,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const mode = route.params?.mode ?? 'compose';
|
||||
const prefillTo = route.params?.prefillTo;
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
// Always the user's own Sent — composing on behalf of a shared account
|
||||
// isn't supported, so a group account's Sent must never be picked up here.
|
||||
const sentMailbox = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'sent'),
|
||||
() => ownMailboxes(mailboxes).find((m) => m.role === 'sent'),
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
@@ -258,6 +275,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const [identityError, setIdentityError] = React.useState<string | null>(null);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
|
||||
const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false);
|
||||
const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false);
|
||||
// Custom date/time picker stage. iOS shows one 'datetime' spinner; Android
|
||||
// can only show one field at a time, so we walk date → time.
|
||||
@@ -317,10 +335,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
cc: replyTo.cc,
|
||||
subject: replyTo.subject,
|
||||
body: replyTo.body,
|
||||
receivedAt: replyTo.receivedAt,
|
||||
}
|
||||
: null,
|
||||
{ timeFormat, locale, unknownLabel: t('common.unknown', 'Unknown') },
|
||||
),
|
||||
[mode, replyTo],
|
||||
[mode, replyTo, timeFormat, locale, t],
|
||||
);
|
||||
|
||||
const [toRecipients, setToRecipients] = React.useState<Recipient[]>(initialTo);
|
||||
@@ -339,6 +359,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
});
|
||||
|
||||
const editorRef = React.useRef<RichTextEditorHandle>(null);
|
||||
const isPickingSuggestion = React.useRef(false);
|
||||
// Track inline-image placeholders that haven't yet been rewritten to cid:
|
||||
// until send time. Maps cid → blobId/type/name/size.
|
||||
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
|
||||
@@ -371,6 +392,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
}, [suggestionQuery, individuals, alreadySelected]);
|
||||
|
||||
const pickSuggestion = (s: { name: string; email: string }) => {
|
||||
isPickingSuggestion.current = true;
|
||||
const recipient: Recipient = { name: s.name, email: s.email };
|
||||
if (activeField === 'cc') {
|
||||
setCcRecipients((prev) => [...prev, recipient]);
|
||||
@@ -401,34 +423,35 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// original message (matches webmail's reply-identity behaviour).
|
||||
React.useEffect(() => {
|
||||
if (selectedIdentityId || identities.length === 0) return;
|
||||
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
|
||||
const defaultIdentity = identities.find(
|
||||
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
|
||||
) ?? identities[0];
|
||||
|
||||
if (autoSelectReplyIdentity && replyTo) {
|
||||
const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])];
|
||||
const candidateAddrs = new Set(
|
||||
candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[],
|
||||
);
|
||||
const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase()));
|
||||
setSelectedIdentityId((matched ?? identities[0]).id);
|
||||
setSelectedIdentityId((matched ?? defaultIdentity).id);
|
||||
} else {
|
||||
setSelectedIdentityId(identities[0].id);
|
||||
setSelectedIdentityId(defaultIdentity.id);
|
||||
}
|
||||
}, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]);
|
||||
|
||||
const primaryIdentity =
|
||||
identities.find((i) => i.id === selectedIdentityId) ?? identities[0];
|
||||
const primaryIdentity = React.useMemo(() => {
|
||||
if (identities.length === 0) return null;
|
||||
const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username;
|
||||
const defaultIdentity = identities.find(
|
||||
(i) => i.email.toLowerCase() === activeEmail?.toLowerCase()
|
||||
) ?? identities[0];
|
||||
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
|
||||
}, [identities, selectedIdentityId]);
|
||||
|
||||
const openIdentityPicker = () => {
|
||||
if (identities.length <= 1) return;
|
||||
Alert.alert(
|
||||
t('email_composer.from', 'From'),
|
||||
undefined,
|
||||
[
|
||||
...identities.map((i) => ({
|
||||
text: i.name ? `${i.name} <${i.email}>` : i.email,
|
||||
onPress: () => setSelectedIdentityId(i.id),
|
||||
})),
|
||||
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const },
|
||||
],
|
||||
);
|
||||
setIdentitySheetOpen(true);
|
||||
};
|
||||
|
||||
const commitTyped = () => {
|
||||
@@ -444,11 +467,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const extraTo = parseRecipients(toInput);
|
||||
const extraCc = parseRecipients(ccInput);
|
||||
if (extraTo.length) {
|
||||
setToRecipients((prev) => [...prev, ...extraTo]);
|
||||
setToRecipients((prev) => {
|
||||
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
|
||||
const unique: Recipient[] = [];
|
||||
for (const r of extraTo) {
|
||||
const emailLower = r.email.toLowerCase();
|
||||
if (!existing.has(emailLower)) {
|
||||
existing.add(emailLower);
|
||||
unique.push(r);
|
||||
}
|
||||
}
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setToInput('');
|
||||
}
|
||||
if (extraCc.length) {
|
||||
setCcRecipients((prev) => [...prev, ...extraCc]);
|
||||
setCcRecipients((prev) => {
|
||||
const existing = new Set(prev.map((r) => r.email.toLowerCase()));
|
||||
const unique: Recipient[] = [];
|
||||
for (const r of extraCc) {
|
||||
const emailLower = r.email.toLowerCase();
|
||||
if (!existing.has(emailLower)) {
|
||||
existing.add(emailLower);
|
||||
unique.push(r);
|
||||
}
|
||||
}
|
||||
return [...prev, ...unique];
|
||||
});
|
||||
setCcInput('');
|
||||
}
|
||||
};
|
||||
@@ -716,13 +761,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// Catch the classic "I forgot the attachment" footgun. Only file attachments
|
||||
// count - inline images don't satisfy the user's intent of attaching a file.
|
||||
// Returns true if it's safe to proceed, false if the user cancelled.
|
||||
const passesAttachmentReminder = async (): Promise<boolean> => {
|
||||
const passesAttachmentReminder = async (html: string): Promise<boolean> => {
|
||||
if (!attachmentReminderEnabled) return true;
|
||||
const hasFileAttachment = attachments.some(
|
||||
(a) => !a.inline && a.blobId && !a.error,
|
||||
);
|
||||
if (hasFileAttachment) return true;
|
||||
const haystack = `${subject}\n${htmlToPlainText(bodyHtml)}`.toLowerCase();
|
||||
const haystack = `${subject}\n${htmlToPlainText(html)}`.toLowerCase();
|
||||
const matchedKeyword = attachmentReminderKeywords.find((kw) => {
|
||||
const k = kw.trim().toLowerCase();
|
||||
if (!k) return false;
|
||||
@@ -836,12 +881,33 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
const performSend = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||
if (!canSend || !primaryIdentity || !sentMailbox) return;
|
||||
if (!(await passesAttachmentReminder())) return;
|
||||
|
||||
// Read the body straight from the editor DOM at send time. The `change`
|
||||
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
||||
// here once shipped replies with the typed text silently missing when the
|
||||
// page script had died (issue #9). If the editor doesn't answer we abort
|
||||
// loudly rather than send possibly-stale content.
|
||||
let liveBodyHtml: string;
|
||||
try {
|
||||
liveBodyHtml = (await editorRef.current?.getHtml()) ?? bodyHtml;
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t('email_composer.send_failed', 'Send failed'),
|
||||
t(
|
||||
'email_composer.editor_unavailable',
|
||||
'Could not read the message content. Copy your text, then close and reopen the composer.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (liveBodyHtml !== bodyHtml) setBodyHtml(liveBodyHtml);
|
||||
|
||||
if (!(await passesAttachmentReminder(liveBodyHtml))) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
|
||||
|
||||
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(bodyHtml);
|
||||
const { html: rewrittenHtml, usedCids } = rewriteInlineImages(liveBodyHtml);
|
||||
// Belt-and-suspenders sanitization: the editor uses execCommand which
|
||||
// can preserve pasted <script>/<style>/etc. Strip them before sending.
|
||||
const safeHtml = stripDangerousTags(rewrittenHtml);
|
||||
@@ -972,7 +1038,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardShouldPersistTaps="always"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.fieldRow}>
|
||||
@@ -989,7 +1055,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldRow}>
|
||||
<View style={[styles.fieldRow, { zIndex: 5 }]}>
|
||||
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
|
||||
<View style={styles.recipientField}>
|
||||
{toRecipients.map((r, i) => (
|
||||
@@ -1007,8 +1073,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onChangeText={setToInput}
|
||||
onFocus={() => setActiveField('to')}
|
||||
onBlur={() => {
|
||||
addTyped();
|
||||
setTimeout(() => setActiveField((f) => (f === 'to' ? null : f)), 200);
|
||||
setTimeout(() => {
|
||||
if (!isPickingSuggestion.current) {
|
||||
addTyped();
|
||||
}
|
||||
setActiveField((f) => (f === 'to' ? null : f));
|
||||
isPickingSuggestion.current = false;
|
||||
}, 200);
|
||||
}}
|
||||
onSubmitEditing={addTyped}
|
||||
keyboardType="email-address"
|
||||
@@ -1017,7 +1088,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
/>
|
||||
</View>
|
||||
{activeField === 'to' && suggestions.length > 0 && (
|
||||
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onPick={pickSuggestion}
|
||||
onPressIn={() => {
|
||||
isPickingSuggestion.current = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!ccVisible && (
|
||||
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
|
||||
@@ -1027,7 +1104,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
|
||||
{ccVisible && (
|
||||
<View style={styles.fieldRow}>
|
||||
<View style={[styles.fieldRow, { zIndex: 4 }]}>
|
||||
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
|
||||
<View style={styles.recipientField}>
|
||||
{ccRecipients.map((r, i) => (
|
||||
@@ -1045,8 +1122,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onChangeText={setCcInput}
|
||||
onFocus={() => setActiveField('cc')}
|
||||
onBlur={() => {
|
||||
addTyped();
|
||||
setTimeout(() => setActiveField((f) => (f === 'cc' ? null : f)), 200);
|
||||
setTimeout(() => {
|
||||
if (!isPickingSuggestion.current) {
|
||||
addTyped();
|
||||
}
|
||||
setActiveField((f) => (f === 'cc' ? null : f));
|
||||
isPickingSuggestion.current = false;
|
||||
}, 200);
|
||||
}}
|
||||
onSubmitEditing={addTyped}
|
||||
keyboardType="email-address"
|
||||
@@ -1055,7 +1137,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
/>
|
||||
</View>
|
||||
{activeField === 'cc' && suggestions.length > 0 && (
|
||||
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
|
||||
<SuggestionList
|
||||
suggestions={suggestions}
|
||||
onPick={pickSuggestion}
|
||||
onPressIn={() => {
|
||||
isPickingSuggestion.current = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -1289,6 +1377,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onChange={onCustomPickerChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IdentitySheet
|
||||
visible={identitySheetOpen}
|
||||
onClose={() => setIdentitySheetOpen(false)}
|
||||
identities={identities}
|
||||
selectedIdentityId={selectedIdentityId}
|
||||
onPick={(identity) => setSelectedIdentityId(identity.id)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1328,6 +1424,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
gap: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
fieldLabel: {
|
||||
...typography.body,
|
||||
@@ -1370,12 +1467,16 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingVertical: 6,
|
||||
},
|
||||
suggestionBox: {
|
||||
marginTop: spacing.xs,
|
||||
marginLeft: 60,
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: spacing.lg + 56 + spacing.md,
|
||||
right: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.borderLight,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.card,
|
||||
zIndex: 20,
|
||||
elevation: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
suggestionRow: {
|
||||
|
||||
+139
-31
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback } from 'react-native';
|
||||
import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import {
|
||||
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
|
||||
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
|
||||
Archive, FolderInput, Tag,
|
||||
Archive, FolderInput, Tag, Import,
|
||||
} from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -21,8 +22,12 @@ import { useNetworkStore } from '../stores/network-store';
|
||||
import { useEmailStore, type EmailFilters } from '../stores/email-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import { useKeywordsStore } from '../stores/keywords-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import {
|
||||
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
||||
} from '../lib/mailbox-tree';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
function getSenderName(email: Email): string {
|
||||
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
|
||||
}
|
||||
@@ -43,20 +48,9 @@ function isPinned(email: Email): boolean {
|
||||
return !!email.keywords?.$important;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHr = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMin < 1) return 'now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay === 1) return 'Yesterday';
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
// Height of the sender line when the avatar is hidden (extra-compact density),
|
||||
// used to anchor the unread dot on that first line.
|
||||
const UNREAD_DOT_TEXT_LINE = 20;
|
||||
|
||||
const EmailRow = React.memo(function EmailRow({
|
||||
item,
|
||||
@@ -79,6 +73,10 @@ const EmailRow = React.memo(function EmailRow({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dyn = useTypography();
|
||||
const density = useDensity();
|
||||
// Read the date-rendering prefs here so each row re-renders when they change.
|
||||
const dateFormat = useSettingsStore((s) => s.dateFormat);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const senderName = getSenderName(item);
|
||||
const senderEmail = getSenderEmail(item);
|
||||
const unread = isUnread(item);
|
||||
@@ -99,6 +97,14 @@ const EmailRow = React.memo(function EmailRow({
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
>
|
||||
{unread && (
|
||||
<View
|
||||
style={[
|
||||
styles.unreadDot,
|
||||
{ top: density.rowPaddingVertical + (density.showAvatar ? componentSizes.avatarMd : UNREAD_DOT_TEXT_LINE) / 2 - 4 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<View style={styles.rowCheckboxWrap}>
|
||||
{selected ? (
|
||||
@@ -133,7 +139,7 @@ const EmailRow = React.memo(function EmailRow({
|
||||
<Text style={styles.threadBadgeText}>{threadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
|
||||
<Text style={[styles.emailDate, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -171,6 +177,7 @@ interface EmailListScreenProps {
|
||||
export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { t } = useLocaleStore();
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = React.useState(false);
|
||||
const emails = useEmailStore((s) => s.emails);
|
||||
@@ -184,6 +191,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
const selectMailbox = useEmailStore((s) => s.selectMailbox);
|
||||
const loadMoreEmails = useEmailStore((s) => s.loadMoreEmails);
|
||||
const refreshEmails = useEmailStore((s) => s.refreshEmails);
|
||||
const importEmails = useEmailStore((s) => s.importEmails);
|
||||
const setSearchQuery = useEmailStore((s) => s.setSearchQuery);
|
||||
const setFilters = useEmailStore((s) => s.setFilters);
|
||||
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
|
||||
@@ -237,19 +245,70 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
return counts;
|
||||
}, [emails, disableThreading]);
|
||||
|
||||
// Role folders must come from the account the open folder belongs to: a
|
||||
// shared (group account) message can only be filed into that account's
|
||||
// Archive/Trash/Junk, never the user's own.
|
||||
const scopedMailboxes = React.useMemo(
|
||||
() => mailboxesForSiblingOf(mailboxes, currentMailboxId),
|
||||
[mailboxes, currentMailboxId],
|
||||
);
|
||||
const archiveMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||
[mailboxes],
|
||||
() => findArchiveMailbox(scopedMailboxes)?.id ?? null,
|
||||
[scopedMailboxes],
|
||||
);
|
||||
const trashMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
|
||||
[mailboxes],
|
||||
() => findTrashMailbox(scopedMailboxes)?.id ?? null,
|
||||
[scopedMailboxes],
|
||||
);
|
||||
const junkMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
|
||||
[mailboxes],
|
||||
() => findJunkMailbox(scopedMailboxes)?.id ?? null,
|
||||
[scopedMailboxes],
|
||||
);
|
||||
|
||||
// Import .eml / .zip files into the current mailbox (falls back to Inbox).
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const handleImport = React.useCallback(async () => {
|
||||
const targetMailboxId =
|
||||
currentMailboxId ?? ownMailboxes(mailboxes).find((m) => m.role === 'inbox')?.id ?? null;
|
||||
if (!targetMailboxId || importing) return;
|
||||
let result: DocumentPicker.DocumentPickerResult;
|
||||
try {
|
||||
result = await DocumentPicker.getDocumentAsync({
|
||||
multiple: true,
|
||||
copyToCacheDirectory: true,
|
||||
type: [
|
||||
'message/rfc822',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/octet-stream',
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
return; // picker dismissed / unavailable
|
||||
}
|
||||
if (result.canceled) return;
|
||||
if (result.assets.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const files = result.assets.map((a) => ({
|
||||
uri: a.uri,
|
||||
name: a.name,
|
||||
mimeType: a.mimeType,
|
||||
}));
|
||||
const { imported, failed } = await importEmails(files, targetMailboxId);
|
||||
Alert.alert(
|
||||
'Import',
|
||||
failed > 0
|
||||
? `${imported} imported, ${failed} failed.`
|
||||
: imported === 0
|
||||
? 'No messages were imported.'
|
||||
: `${imported} message${imported === 1 ? '' : 's'} imported.`,
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [currentMailboxId, mailboxes, importEmails, importing]);
|
||||
|
||||
// Move-to-folder picker triggered by the swipe action.
|
||||
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
|
||||
// Batch (multi-select) sheets.
|
||||
@@ -298,16 +357,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
case 'archive':
|
||||
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
|
||||
void archiveEmailAction(id);
|
||||
} else if (!archiveMailboxId) {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_archive_folder', 'Could not find an Archive folder on the server.'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (trashMailboxId) {
|
||||
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'spam':
|
||||
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
|
||||
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
|
||||
} else if (!junkMailboxId) {
|
||||
Alert.alert(
|
||||
t('email_list.error', 'Error'),
|
||||
t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'read':
|
||||
@@ -327,7 +401,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}, [
|
||||
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
|
||||
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
|
||||
markRead, markUnread, toggleStar, togglePin,
|
||||
markRead, markUnread, toggleStar, togglePin, t,
|
||||
]);
|
||||
|
||||
const renderEmailRow = React.useCallback(
|
||||
@@ -395,7 +469,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const trash = mailboxes.find((m) => m.role === 'trash');
|
||||
const trash = findTrashMailbox(scopedMailboxes);
|
||||
if (!trash || !currentMailboxId) {
|
||||
clearSelection();
|
||||
return;
|
||||
@@ -483,7 +557,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mailboxes.length > 0 && !currentMailboxId) {
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox') || mailboxes[0];
|
||||
const own = ownMailboxes(mailboxes);
|
||||
const inbox = own.find((m) => m.role === 'inbox') || own[0];
|
||||
if (!inbox) return;
|
||||
void selectMailbox(inbox.id);
|
||||
}
|
||||
}, [mailboxes, currentMailboxId, selectMailbox]);
|
||||
@@ -555,8 +631,26 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<Pressable onPress={() => setDrawerOpen(true)} style={styles.headerButton}>
|
||||
<Menu size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{currentMailbox?.name ?? 'Inbox'}</Text>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{currentMailbox?.isShared && currentMailbox.accountName
|
||||
// Name the owning group account — a bare "Inbox" would look like
|
||||
// the user's own.
|
||||
? `${currentMailbox.accountName} · ${currentMailbox.name}`
|
||||
: currentMailbox?.name ?? 'Inbox'}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Pressable
|
||||
onPress={() => { void handleImport(); }}
|
||||
style={styles.headerButton}
|
||||
disabled={importing}
|
||||
hitSlop={6}
|
||||
>
|
||||
{importing ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
) : (
|
||||
<Import size={20} color={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Image
|
||||
source={require('../../assets/logos/Bulwark Logo White.png')}
|
||||
style={styles.headerLogo}
|
||||
@@ -936,7 +1030,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<MoveSheet
|
||||
visible={pendingMoveId !== null}
|
||||
onClose={() => setPendingMoveId(null)}
|
||||
mailboxes={mailboxes}
|
||||
mailboxes={scopedMailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={(toId) => {
|
||||
const id = pendingMoveId;
|
||||
@@ -950,7 +1044,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
<MoveSheet
|
||||
visible={batchMoveOpen}
|
||||
onClose={() => setBatchMoveOpen(false)}
|
||||
mailboxes={mailboxes}
|
||||
mailboxes={scopedMailboxes}
|
||||
currentMailboxId={currentMailboxId}
|
||||
onPick={handleBatchMovePick}
|
||||
/>
|
||||
@@ -1025,7 +1119,7 @@ function makeStyles(c: ThemePalette) {
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 }, // text-lg font-semibold
|
||||
headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, // text-lg font-semibold
|
||||
headerLogo: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -1299,6 +1393,20 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
emailRowPressed: { backgroundColor: c.surface },
|
||||
emailRowSelected: { backgroundColor: c.selection },
|
||||
// Mirrors the webmail's unread indicator: an 8px filled circle at the row's
|
||||
// start edge, vertically centered (email-list-item.tsx, fill-unread). The
|
||||
// weight/color change alone is too subtle on some device fonts (#27).
|
||||
// `top` is set per row: the dot is anchored on the row's *first* line (top
|
||||
// padding + half an avatar), not on the row's midpoint, which would drop it
|
||||
// a full line below the checkbox and avatar it reads as a column with.
|
||||
unreadDot: {
|
||||
position: 'absolute',
|
||||
left: 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: c.unread,
|
||||
},
|
||||
rowCheckboxWrap: {
|
||||
width: 16,
|
||||
height: componentSizes.avatarMd,
|
||||
|
||||
@@ -15,7 +15,9 @@ type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
|
||||
export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { blobId, subject } = route.params;
|
||||
// Blobs are account-scoped, so a message from a shared/group mailbox has
|
||||
// to be fetched against its owning account.
|
||||
const { blobId, subject, jmapAccountId } = route.params;
|
||||
const [raw, setRaw] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -23,18 +25,18 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await fetchRawEmail(blobId);
|
||||
const text = await fetchRawEmail(blobId, jmapAccountId);
|
||||
if (!cancelled) setRaw(text);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [blobId]);
|
||||
}, [blobId, jmapAccountId]);
|
||||
|
||||
const onShare = async () => {
|
||||
try {
|
||||
await shareEmailEml(blobId, subject);
|
||||
await shareEmailEml(blobId, undefined, subject, jmapAccountId);
|
||||
} catch (e) {
|
||||
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
+592
-227
File diff suppressed because it is too large
Load Diff
+187
-87
@@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
ChevronLeft, FileText, Folder, FolderPlus, HardDrive, FileImage, FileVideo,
|
||||
FileAudio, FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon,
|
||||
MoreVertical, Pencil, Share2, Trash2, Upload, X, Download,
|
||||
MoreVertical, Pencil, Share2, Trash2, Upload, Users, X, Download,
|
||||
} from 'lucide-react-native';
|
||||
|
||||
// expo-document-picker is loaded lazily on first upload. Its native module
|
||||
@@ -38,9 +38,8 @@ function loadDocumentPicker(): DocumentPickerModule | null {
|
||||
}
|
||||
|
||||
import {
|
||||
createFolder, deleteWithDescendants, getAllFileNodes, isDirectChildOfPrefix,
|
||||
isDirectory, nodeDisplayName, pathToPrefix, PATH_SEP, renameFileNode,
|
||||
uploadFileNode,
|
||||
createFolder, deleteFileNodes, getAllFileNodesAcrossAccounts, isCrossAccountId,
|
||||
isFolder, renameFileNode, supportsSharing, uploadFileNode,
|
||||
} from '../api/files';
|
||||
import { downloadAttachment, shareAttachment } from '../lib/email-export';
|
||||
import type { FileNode } from '../api/types';
|
||||
@@ -48,9 +47,9 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSettingsStore, type FilesViewMode } from '../stores/settings-store';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ShareSheet from '../components/files/ShareSheet';
|
||||
|
||||
// A file row carries the resolved display name alongside the original
|
||||
// server-side name (the path-encoded `node.name` field).
|
||||
// A file row carries the display name alongside the rest of the node.
|
||||
interface FileRow extends FileNode {
|
||||
displayName: string;
|
||||
}
|
||||
@@ -108,7 +107,10 @@ export default function FilesScreen() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
// Folder navigation stack. Hierarchy is real parentId nesting (#379) — each
|
||||
// entry is the folder's node id (namespaced "accountId:nodeId" inside a
|
||||
// shared-with-me subtree) plus its name for the breadcrumb.
|
||||
const [path, setPath] = useState<{ id: string; name: string }[]>([]);
|
||||
const [allNodes, setAllNodes] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -124,6 +126,7 @@ export default function FilesScreen() {
|
||||
const [renameTarget, setRenameTarget] = useState<FileRow | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [actionsTarget, setActionsTarget] = useState<FileRow | null>(null);
|
||||
const [shareTarget, setShareTarget] = useState<FileRow | null>(null);
|
||||
|
||||
const showIcons = useSettingsStore((s) => s.filesShowIcons);
|
||||
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
|
||||
@@ -137,8 +140,13 @@ export default function FilesScreen() {
|
||||
const viewMode: FilesViewMode = viewOverride ?? defaultViewMode;
|
||||
|
||||
const selectionMode = selection.size > 0;
|
||||
const prefix = useMemo(() => pathToPrefix(path), [path]);
|
||||
const headerName = path.length === 0 ? 'Files' : path[path.length - 1];
|
||||
const currentParentId = path.length === 0 ? null : path[path.length - 1].id;
|
||||
// True while browsing inside a folder another principal shared with us.
|
||||
// Creates/uploads would have to route to the owner's account, which this
|
||||
// screen doesn't do (matches the webmail's Files app); writes are hidden.
|
||||
const inSharedSubtree = isCrossAccountId(currentParentId);
|
||||
const headerName = path.length === 0 ? 'Files' : path[path.length - 1].name;
|
||||
const sharingEnabled = supportsSharing();
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (mode: 'initial' | 'refresh' = 'initial') => {
|
||||
@@ -146,7 +154,9 @@ export default function FilesScreen() {
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nodes = await getAllFileNodes();
|
||||
// One fetch across all accessible accounts: own files plus nodes other
|
||||
// principals shared with us (tagged isShared, ids namespaced).
|
||||
const nodes = await getAllFileNodesAcrossAccounts();
|
||||
setAllNodes(nodes);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load files');
|
||||
@@ -162,33 +172,49 @@ export default function FilesScreen() {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
// If we navigated into a folder that no longer exists (it was deleted, or
|
||||
// we never had it), walk back up until we land on one that does. The check
|
||||
// looks for a directory node whose serverName matches the encoded path.
|
||||
// If we navigated into a folder that no longer exists (deleted, or its
|
||||
// share was revoked), cut the stack back to the deepest ancestor that does.
|
||||
useEffect(() => {
|
||||
if (allNodes.length === 0) return;
|
||||
setPath((prev) => {
|
||||
let next = prev;
|
||||
while (next.length > 0) {
|
||||
const parentPrefix = pathToPrefix(next.slice(0, -1));
|
||||
const expectedName = parentPrefix + next[next.length - 1];
|
||||
const exists = allNodes.some((n) => n.name === expectedName && isDirectory(n));
|
||||
if (exists) break;
|
||||
next = next.slice(0, -1);
|
||||
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
||||
let cut = prev.length;
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
const node = byId.get(prev[i].id);
|
||||
if (!node || !isFolder(node)) {
|
||||
cut = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next.length === prev.length ? prev : next;
|
||||
return cut === prev.length ? prev : prev.slice(0, cut);
|
||||
});
|
||||
}, [allNodes]);
|
||||
|
||||
const visibleFiles = useMemo<FileRow[]>(() => {
|
||||
const rows = allNodes
|
||||
.filter((n) => isDirectChildOfPrefix(n, prefix))
|
||||
.map<FileRow>((n) => ({ ...n, displayName: nodeDisplayName(n, prefix) }))
|
||||
let nodes: FileNode[];
|
||||
if (currentParentId === null) {
|
||||
// Root: our own top-level nodes, plus the roots of every shared-with-me
|
||||
// subtree (a shared node whose parent isn't visible to us).
|
||||
const idSet = new Set(allNodes.map((n) => n.id));
|
||||
nodes = allNodes.filter((n) =>
|
||||
n.isShared
|
||||
? n.parentId == null || !idSet.has(n.parentId)
|
||||
: (n.parentId ?? null) === null,
|
||||
);
|
||||
} else {
|
||||
nodes = allNodes.filter((n) => (n.parentId ?? null) === currentParentId);
|
||||
}
|
||||
const rows = nodes
|
||||
.map<FileRow>((n) => ({ ...n, displayName: n.name }))
|
||||
.filter((r) => showHiddenFiles || !r.displayName.startsWith('.'));
|
||||
const dir = sortDir === 'desc' ? -1 : 1;
|
||||
return rows.sort((a, b) => {
|
||||
const aDir = isDirectory(a) ? 0 : 1;
|
||||
const bDir = isDirectory(b) ? 0 : 1;
|
||||
// Own content first, shared-with-me entries after (root only).
|
||||
const aShared = a.isShared ? 1 : 0;
|
||||
const bShared = b.isShared ? 1 : 0;
|
||||
if (aShared !== bShared) return aShared - bShared;
|
||||
const aDir = isFolder(a) ? 0 : 1;
|
||||
const bDir = isFolder(b) ? 0 : 1;
|
||||
if (aDir !== bDir) return aDir - bDir;
|
||||
switch (sortKey) {
|
||||
case 'size':
|
||||
@@ -203,15 +229,18 @@ export default function FilesScreen() {
|
||||
return a.displayName.localeCompare(b.displayName) * dir;
|
||||
}
|
||||
});
|
||||
}, [allNodes, prefix, showHiddenFiles, sortKey, sortDir]);
|
||||
}, [allNodes, currentParentId, showHiddenFiles, sortKey, sortDir]);
|
||||
|
||||
const clearSelection = useCallback(() => setSelection(new Set()), []);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
// Shared-with-me rows stay out of multi-select: batch delete routes to our
|
||||
// own account and would fail (or worse, mismatch) on namespaced ids.
|
||||
const toggleSelect = useCallback((row: FileRow) => {
|
||||
if (row.isShared) return;
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
if (next.has(row.id)) next.delete(row.id);
|
||||
else next.add(row.id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -220,7 +249,7 @@ export default function FilesScreen() {
|
||||
if (!row.blobId) return;
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
await shareAttachment(row.blobId, row.displayName, row.type);
|
||||
await shareAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
|
||||
} catch (e) {
|
||||
Alert.alert('Preview failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
@@ -232,7 +261,7 @@ export default function FilesScreen() {
|
||||
if (!row.blobId) return;
|
||||
setBusyId(row.id);
|
||||
try {
|
||||
await downloadAttachment(row.blobId, row.displayName, row.type);
|
||||
await downloadAttachment(row.blobId, row.displayName, row.type, undefined, row.accountId);
|
||||
} catch (e) {
|
||||
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
@@ -243,11 +272,11 @@ export default function FilesScreen() {
|
||||
const handleRowPress = useCallback(
|
||||
(row: FileRow) => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(row.id);
|
||||
toggleSelect(row);
|
||||
return;
|
||||
}
|
||||
if (isDirectory(row)) {
|
||||
setPath((p) => [...p, row.displayName]);
|
||||
if (isFolder(row)) {
|
||||
setPath((p) => [...p, { id: row.id, name: row.displayName }]);
|
||||
return;
|
||||
}
|
||||
void previewFile(row);
|
||||
@@ -256,7 +285,7 @@ export default function FilesScreen() {
|
||||
);
|
||||
|
||||
const handleRowLongPress = useCallback((row: FileRow) => {
|
||||
toggleSelect(row.id);
|
||||
toggleSelect(row);
|
||||
}, [toggleSelect]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
@@ -277,19 +306,19 @@ export default function FilesScreen() {
|
||||
const submitNewFolder = useCallback(async () => {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
if (name.includes(PATH_SEP) || name.includes('/')) {
|
||||
if (name.includes('/')) {
|
||||
Alert.alert('Invalid name', 'Folder names cannot contain "/".');
|
||||
return;
|
||||
}
|
||||
setNewFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
try {
|
||||
await createFolder(name, path);
|
||||
await createFolder(name, currentParentId);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Create folder failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [newFolderName, path, loadFiles]);
|
||||
}, [newFolderName, currentParentId, loadFiles]);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (!renameTarget) return;
|
||||
@@ -298,7 +327,7 @@ export default function FilesScreen() {
|
||||
setRenameTarget(null);
|
||||
return;
|
||||
}
|
||||
if (name.includes(PATH_SEP) || name.includes('/')) {
|
||||
if (name.includes('/')) {
|
||||
Alert.alert('Invalid name', 'Names cannot contain "/".');
|
||||
return;
|
||||
}
|
||||
@@ -306,12 +335,12 @@ export default function FilesScreen() {
|
||||
setRenameTarget(null);
|
||||
setRenameValue('');
|
||||
try {
|
||||
await renameFileNode(target, name, path, allNodes);
|
||||
await renameFileNode(target.id, name);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Rename failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [renameTarget, renameValue, path, allNodes, loadFiles]);
|
||||
}, [renameTarget, renameValue, loadFiles]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (uploading) return;
|
||||
@@ -341,7 +370,7 @@ export default function FilesScreen() {
|
||||
asset.uri,
|
||||
asset.name,
|
||||
asset.mimeType || 'application/octet-stream',
|
||||
path,
|
||||
currentParentId,
|
||||
);
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
@@ -349,7 +378,7 @@ export default function FilesScreen() {
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [uploading, path, loadFiles]);
|
||||
}, [uploading, currentParentId, loadFiles]);
|
||||
|
||||
const requestDelete = useCallback((rows: FileRow[]) => {
|
||||
if (rows.length === 0) return;
|
||||
@@ -362,12 +391,13 @@ export default function FilesScreen() {
|
||||
setConfirmDelete(null);
|
||||
clearSelection();
|
||||
try {
|
||||
await deleteWithDescendants(targets, allNodes);
|
||||
// The server removes folder descendants (onDestroyRemoveChildren).
|
||||
await deleteFileNodes(targets.map((t) => t.id));
|
||||
await loadFiles('refresh');
|
||||
} catch (e) {
|
||||
Alert.alert('Delete failed', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [confirmDelete, allNodes, clearSelection, loadFiles]);
|
||||
}, [confirmDelete, clearSelection, loadFiles]);
|
||||
|
||||
const renderHeader = () => {
|
||||
const canBack = path.length > 0;
|
||||
@@ -409,27 +439,31 @@ export default function FilesScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={() => setNewFolderOpen(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="New folder"
|
||||
>
|
||||
<FolderPlus size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void startUpload()}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="Upload file"
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<Upload size={20} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
{!inSharedSubtree ? (
|
||||
<Pressable
|
||||
onPress={() => setNewFolderOpen(true)}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="New folder"
|
||||
>
|
||||
<FolderPlus size={20} color={c.text} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!inSharedSubtree ? (
|
||||
<Pressable
|
||||
onPress={() => void startUpload()}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityLabel="Upload file"
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<Upload size={20} color={c.text} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={toggleView}
|
||||
hitSlop={8}
|
||||
@@ -442,7 +476,7 @@ export default function FilesScreen() {
|
||||
</View>
|
||||
{canBack ? (
|
||||
<Text style={styles.breadcrumb} numberOfLines={1}>
|
||||
{['Files', ...path].join(' / ')}
|
||||
{['Files', ...path.map((p) => p.name)].join(' / ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -450,13 +484,14 @@ export default function FilesScreen() {
|
||||
};
|
||||
|
||||
const renderRow = (item: FileRow) => {
|
||||
const isDir = isDirectory(item);
|
||||
const isDir = isFolder(item);
|
||||
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
|
||||
const tint = isDir
|
||||
? (coloredIcons ? c.calendar.blue : c.textMuted)
|
||||
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
|
||||
const selected = selection.has(item.id);
|
||||
const busy = busyId === item.id;
|
||||
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => handleRowPress(item)}
|
||||
@@ -470,10 +505,22 @@ export default function FilesScreen() {
|
||||
>
|
||||
{showIcons && <Icon size={20} color={tint} />}
|
||||
<View style={styles.fileInfo}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
<View style={styles.fileNameRow}>
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
{item.isShared ? (
|
||||
<Share2 size={13} color={c.primary} />
|
||||
) : sharedOut ? (
|
||||
<Users size={13} color={c.primary} />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.fileMetaRow}>
|
||||
{item.isShared && item.accountName ? (
|
||||
<Text style={styles.fileMeta} numberOfLines={1}>
|
||||
Shared by {item.accountName}
|
||||
</Text>
|
||||
) : null}
|
||||
{item.size != null && !isDir ? (
|
||||
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
|
||||
) : null}
|
||||
@@ -501,12 +548,13 @@ export default function FilesScreen() {
|
||||
};
|
||||
|
||||
const renderGridCell = (item: FileRow) => {
|
||||
const isDir = isDirectory(item);
|
||||
const isDir = isFolder(item);
|
||||
const Icon = isDir ? Folder : pickFileIcon(item.displayName);
|
||||
const tint = isDir
|
||||
? (coloredIcons ? c.calendar.blue : c.textMuted)
|
||||
: (coloredIcons ? fileIconColor(item.displayName, c) : c.textMuted);
|
||||
const selected = selection.has(item.id);
|
||||
const sharedOut = !!item.shareWith && Object.keys(item.shareWith).length > 0;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => handleRowPress(item)}
|
||||
@@ -523,10 +571,21 @@ export default function FilesScreen() {
|
||||
) : (
|
||||
<View style={{ width: 36, height: 36 }} />
|
||||
)}
|
||||
<Text style={styles.gridName} numberOfLines={2}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
{item.size != null && !isDir ? (
|
||||
<View style={styles.gridNameRow}>
|
||||
<Text style={styles.gridName} numberOfLines={2}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
{item.isShared ? (
|
||||
<Share2 size={12} color={c.primary} />
|
||||
) : sharedOut ? (
|
||||
<Users size={12} color={c.primary} />
|
||||
) : null}
|
||||
</View>
|
||||
{item.isShared && item.accountName ? (
|
||||
<Text style={styles.gridMeta} numberOfLines={1}>
|
||||
Shared by {item.accountName}
|
||||
</Text>
|
||||
) : item.size != null && !isDir ? (
|
||||
<Text style={styles.gridMeta} numberOfLines={1}>
|
||||
{formatFileSize(item.size)}
|
||||
</Text>
|
||||
@@ -627,6 +686,7 @@ export default function FilesScreen() {
|
||||
|
||||
<ActionsSheet
|
||||
target={actionsTarget}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onClose={() => setActionsTarget(null)}
|
||||
onPreview={(r) => {
|
||||
setActionsTarget(null);
|
||||
@@ -636,6 +696,10 @@ export default function FilesScreen() {
|
||||
setActionsTarget(null);
|
||||
void downloadFile(r);
|
||||
}}
|
||||
onShare={(r) => {
|
||||
setActionsTarget(null);
|
||||
setShareTarget(r);
|
||||
}}
|
||||
onRename={(r) => {
|
||||
setActionsTarget(null);
|
||||
setRenameTarget(r);
|
||||
@@ -647,6 +711,12 @@ export default function FilesScreen() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareSheet
|
||||
node={shareTarget}
|
||||
onClose={() => setShareTarget(null)}
|
||||
onChanged={() => void loadFiles('refresh')}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={confirmDelete != null}
|
||||
title={
|
||||
@@ -722,18 +792,24 @@ function PromptModal(props: PromptModalProps) {
|
||||
|
||||
interface ActionsSheetProps {
|
||||
target: FileRow | null;
|
||||
sharingEnabled: boolean;
|
||||
onClose: () => void;
|
||||
onPreview: (r: FileRow) => void;
|
||||
onDownload: (r: FileRow) => void;
|
||||
onShare: (r: FileRow) => void;
|
||||
onRename: (r: FileRow) => void;
|
||||
onDelete: (r: FileRow) => void;
|
||||
}
|
||||
|
||||
function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDelete }: ActionsSheetProps) {
|
||||
function ActionsSheet({ target, sharingEnabled, onClose, onPreview, onDownload, onShare, onRename, onDelete }: ActionsSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeSheetStyles(c), [c]);
|
||||
if (!target) return null;
|
||||
const isDir = isDirectory(target);
|
||||
const isDir = isFolder(target);
|
||||
// Owned nodes report full rights (or no myRights at all); shared-with-me
|
||||
// nodes are managed by their owner, and write ops would have to route to
|
||||
// the owner's account — so they only get preview/download here.
|
||||
const canShare = sharingEnabled && !target.isShared && (target.myRights?.mayShare ?? true);
|
||||
return (
|
||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
@@ -755,14 +831,24 @@ function ActionsSheet({ target, onClose, onPreview, onDownload, onRename, onDele
|
||||
<Text style={styles.actionLabel}>Save to device</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.action} onPress={() => onRename(target)}>
|
||||
<Pencil size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>Rename</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.action} onPress={() => onDelete(target)}>
|
||||
<Trash2 size={18} color={c.error} />
|
||||
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
|
||||
</Pressable>
|
||||
{canShare ? (
|
||||
<Pressable style={styles.action} onPress={() => onShare(target)}>
|
||||
<Users size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>Sharing & access</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!target.isShared ? (
|
||||
<Pressable style={styles.action} onPress={() => onRename(target)}>
|
||||
<Pencil size={18} color={c.text} />
|
||||
<Text style={styles.actionLabel}>Rename</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!target.isShared ? (
|
||||
<Pressable style={styles.action} onPress={() => onDelete(target)}>
|
||||
<Trash2 size={18} color={c.error} />
|
||||
<Text style={[styles.actionLabel, { color: c.error }]}>Delete</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
@@ -856,9 +942,15 @@ function makeStyles(c: ThemePalette) {
|
||||
fileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
fileNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
fileName: {
|
||||
fontSize: typography.body.fontSize,
|
||||
color: c.text,
|
||||
flexShrink: 1,
|
||||
},
|
||||
fileMetaRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -886,10 +978,18 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingHorizontal: spacing.xs,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
gridNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
gridName: {
|
||||
fontSize: typography.caption.fontSize,
|
||||
color: c.text,
|
||||
textAlign: 'center',
|
||||
flexShrink: 1,
|
||||
},
|
||||
gridMeta: {
|
||||
fontSize: typography.small.fontSize,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user