mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e574fd9e11 | ||
|
|
dfa278f7a8 | ||
|
|
4eeda34d95 | ||
|
|
22ad291202 | ||
|
|
f040a48e2f | ||
|
|
c29cbcd549 | ||
|
|
bf67b97176 | ||
|
|
a2484f3508 | ||
|
|
82997e27e4 | ||
|
|
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 |
@@ -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."
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
@@ -55,6 +69,19 @@ module.exports = {
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.56",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.56",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.56",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -87,7 +87,10 @@ describe('calendar operations', () => {
|
||||
});
|
||||
|
||||
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']],
|
||||
@@ -99,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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,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', () => {
|
||||
@@ -132,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', () => {
|
||||
|
||||
+107
-12
@@ -37,18 +37,94 @@ function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -89,7 +165,18 @@ export async function getCalendars(): Promise<Calendar[]> {
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? [];
|
||||
return list.map((cal) => ({ ...cal, accountId: sharedAccountId, isShared: true }));
|
||||
// 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 [];
|
||||
}
|
||||
@@ -138,7 +225,7 @@ export async function getEvents(ids: string[], accountId?: string): Promise<Cale
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: CalendarEvent[] }>(res).list ?? [];
|
||||
all.push(...list);
|
||||
all.push(...list.map(normalizeRecurrenceProperties));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -165,11 +252,11 @@ export async function createEvent(
|
||||
targetAccountId?: string,
|
||||
): Promise<CalendarEvent> {
|
||||
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,
|
||||
);
|
||||
@@ -182,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']],
|
||||
@@ -211,8 +303,10 @@ export async function updateEvent(
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
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);
|
||||
@@ -273,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(
|
||||
|
||||
@@ -151,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,
|
||||
|
||||
@@ -455,6 +455,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>;
|
||||
@@ -474,6 +477,8 @@ export interface CalendarEvent {
|
||||
// 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 {
|
||||
@@ -502,6 +507,10 @@ export interface Calendar {
|
||||
// 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;
|
||||
|
||||
@@ -101,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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -53,16 +53,19 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
<Pressable onPress={onClose} hitSlop={10} style={styles.closeButton}>
|
||||
<X size={24} color="#ffffff" />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Scan QR code</Text>
|
||||
<Text style={styles.title}>Sign-in code</Text>
|
||||
<View style={styles.closeButton} />
|
||||
</View>
|
||||
|
||||
{permission?.granted ? (
|
||||
<View style={styles.frameWrap}>
|
||||
<View style={styles.frame} />
|
||||
<Text style={styles.hint}>
|
||||
Point your camera at the QR code shown on the webmail settings screen.
|
||||
</Text>
|
||||
<View 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 style={styles.permissionWrap}>
|
||||
@@ -105,11 +108,12 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl },
|
||||
hintTitle: { ...typography.bodySemibold, color: '#ffffff', textAlign: 'center' },
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: '#ffffff',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
permissionWrap: {
|
||||
flex: 1,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -172,9 +172,9 @@ export function SwipeableRow({
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
|
||||
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
|
||||
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
} else {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
|
||||
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 50,
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,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,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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
@@ -96,3 +96,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));
|
||||
});
|
||||
}
|
||||
|
||||
+47
-30
@@ -207,6 +207,8 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -214,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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -163,6 +163,7 @@ export default function CalendarScreen() {
|
||||
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);
|
||||
@@ -218,6 +219,28 @@ export default function CalendarScreen() {
|
||||
[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],
|
||||
@@ -521,7 +544,7 @@ export default function CalendarScreen() {
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={modalEvent}
|
||||
calendars={allCalendars}
|
||||
calendars={eventCalendars}
|
||||
defaultDate={modalDate}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDeleteFromModal}
|
||||
@@ -537,7 +560,7 @@ export default function CalendarScreen() {
|
||||
|
||||
<CalendarSidebarDrawer
|
||||
visible={sidebarVisible}
|
||||
calendars={allCalendars}
|
||||
calendars={eventCalendars}
|
||||
hiddenCalendarIds={hiddenCalendarIds}
|
||||
onToggle={toggleCalendarVisibility}
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
@@ -555,7 +578,7 @@ export default function CalendarScreen() {
|
||||
<TasksSheet
|
||||
visible={tasksVisible}
|
||||
tasks={tasks}
|
||||
calendars={allCalendars}
|
||||
calendars={taskSheetCalendars}
|
||||
onClose={() => setTasksVisible(false)}
|
||||
onCreate={createTask}
|
||||
onToggle={toggleTaskComplete}
|
||||
@@ -565,8 +588,11 @@ export default function CalendarScreen() {
|
||||
<ICalImportSheet
|
||||
visible={importVisible}
|
||||
// Import batch-creates against the primary account, so only offer the
|
||||
// user's own calendars as targets.
|
||||
calendars={displayCalendars.filter((cal) => !cal.isShared)}
|
||||
// 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}
|
||||
/>
|
||||
|
||||
+129
-35
@@ -17,7 +17,7 @@ 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,
|
||||
@@ -26,6 +26,7 @@ import { useEmailStore } from '../stores/email-store';
|
||||
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 +70,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 +123,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 +138,7 @@ function SuggestionList({
|
||||
return (
|
||||
<Pressable
|
||||
key={`${s.contact.id}-${s.email}-${i}`}
|
||||
onPressIn={onPressIn}
|
||||
onPress={() => onPick(s)}
|
||||
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
|
||||
>
|
||||
@@ -260,6 +272,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.
|
||||
@@ -343,6 +356,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());
|
||||
@@ -375,6 +389,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]);
|
||||
@@ -405,34 +420,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 = () => {
|
||||
@@ -448,11 +464,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('');
|
||||
}
|
||||
};
|
||||
@@ -720,13 +758,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;
|
||||
@@ -840,12 +878,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);
|
||||
@@ -976,7 +1035,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}>
|
||||
@@ -993,7 +1052,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) => (
|
||||
@@ -1011,8 +1070,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"
|
||||
@@ -1021,7 +1085,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}>
|
||||
@@ -1031,7 +1101,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) => (
|
||||
@@ -1049,8 +1119,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"
|
||||
@@ -1059,7 +1134,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>
|
||||
)}
|
||||
@@ -1293,6 +1374,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>
|
||||
);
|
||||
}
|
||||
@@ -1332,6 +1421,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
gap: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
fieldLabel: {
|
||||
...typography.body,
|
||||
@@ -1374,12 +1464,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: {
|
||||
|
||||
@@ -24,8 +24,8 @@ 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 } 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';
|
||||
}
|
||||
@@ -46,6 +46,9 @@ function isPinned(email: Email): boolean {
|
||||
return !!email.keywords?.$important;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -92,6 +95,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 ? (
|
||||
@@ -164,6 +175,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);
|
||||
@@ -232,15 +244,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
}, [emails, disableThreading]);
|
||||
|
||||
const archiveMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
|
||||
() => findArchiveMailbox(mailboxes)?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const trashMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
|
||||
() => findTrashMailbox(mailboxes)?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
const junkMailboxId = React.useMemo(
|
||||
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
|
||||
() => findJunkMailbox(mailboxes)?.id ?? null,
|
||||
[mailboxes],
|
||||
);
|
||||
|
||||
@@ -336,16 +348,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':
|
||||
@@ -365,7 +392,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(
|
||||
@@ -433,7 +460,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const trash = mailboxes.find((m) => m.role === 'trash');
|
||||
const trash = findTrashMailbox(mailboxes);
|
||||
if (!trash || !currentMailboxId) {
|
||||
clearSelection();
|
||||
return;
|
||||
@@ -1349,6 +1376,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,
|
||||
|
||||
@@ -29,6 +29,8 @@ import { setEmailKeywords } from '../api/email';
|
||||
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
|
||||
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { findTrashMailbox } from '../lib/mailbox-tree';
|
||||
import type { Email, Mailbox } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
@@ -65,6 +67,7 @@ function plainTextBody(email: Email): string {
|
||||
export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { t } = useLocaleStore();
|
||||
const { jmapAccountId } = route.params;
|
||||
// The displayed email is tracked in local state (not a route param) so that
|
||||
// swiping / Prev-Next can switch messages in place without remounting the
|
||||
@@ -299,8 +302,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
|
||||
|
||||
const onDelete = () => {
|
||||
if (!email || !currentMailboxId) return;
|
||||
const trash = mailboxes.find((m) => m.role === 'trash');
|
||||
if (!trash) return;
|
||||
const trash = findTrashMailbox(mailboxes);
|
||||
if (!trash) {
|
||||
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.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void deleteEmail(email.id, trash.id, currentMailboxId);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
+338
-270
@@ -1,16 +1,23 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Constants from 'expo-constants';
|
||||
import { Eye, EyeOff, X, QrCode } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { Button, Input } from '../components';
|
||||
import { BackHandler } from 'react-native';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { QrScanModal } from '../components/QrScanModal';
|
||||
import { parseQrLoginPayload } from '../lib/oauth';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
import {
|
||||
discoverServerForEmail,
|
||||
emailDomain,
|
||||
isEmailAddress,
|
||||
normalizeServerUrl,
|
||||
} from '../lib/server-discovery';
|
||||
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
|
||||
import LoginShell from './login/LoginShell';
|
||||
import ChooseStep from './login/ChooseStep';
|
||||
import EmailStep from './login/EmailStep';
|
||||
import ServerStep from './login/ServerStep';
|
||||
import ConfirmStep from './login/ConfirmStep';
|
||||
import PasswordStep from './login/PasswordStep';
|
||||
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin?: () => void;
|
||||
@@ -18,288 +25,349 @@ interface LoginScreenProps {
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password';
|
||||
|
||||
/**
|
||||
* Sign-in, one question per screen, cheapest question first.
|
||||
*
|
||||
* The routes that need no server address — a paired QR code, or an email we
|
||||
* can discover a server from — come first; the manual server + password form
|
||||
* is the fallback rather than the greeting. Every step drives one of the
|
||||
* existing auth-store actions, so the credential handling below is unchanged
|
||||
* from the single-form version this replaced.
|
||||
*/
|
||||
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const clearError = useAuthStore((state) => state.clearError);
|
||||
const storeError = useAuthStore((state) => state.error);
|
||||
|
||||
const [serverUrl, setServerUrl] = React.useState('');
|
||||
const accounts = useAccountStore((state) => state.accounts);
|
||||
const activeAccountId = useAccountStore((state) => state.activeAccountId);
|
||||
|
||||
const [step, setStep] = React.useState<StepName>('choose');
|
||||
const [history, setHistory] = React.useState<StepName[]>([]);
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [serverInput, setServerInput] = React.useState('');
|
||||
const [serverUrl, setServerUrl] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState<SigningInPhase | null>(null);
|
||||
const [scannerVisible, setScannerVisible] = React.useState(false);
|
||||
|
||||
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
|
||||
const canHandoff = Boolean(serverUrl.trim());
|
||||
// Servers we already hold credentials for. Discovery trusts these without a
|
||||
// probe, which makes "second account on the same host" instant.
|
||||
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]);
|
||||
const knownServerUrl = React.useMemo(() => {
|
||||
const active = accounts.find((a) => a.id === activeAccountId);
|
||||
return active?.serverUrl ?? accounts[0]?.serverUrl ?? null;
|
||||
}, [accounts, activeAccountId]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(serverUrl.trim(), email.trim(), password, { addAccount: isAddMode });
|
||||
onLogin?.();
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebmailHandoff = async () => {
|
||||
if (!canHandoff) return;
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
|
||||
// loginViaWebmail swallows the "user cancelled the browser" case
|
||||
// silently — only fire the navigation hook when authentication
|
||||
// actually completed.
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if (after && !before) {
|
||||
onLogin?.();
|
||||
} else if (isAddMode && after) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanned = async (data: string) => {
|
||||
setScannerVisible(false);
|
||||
const payload = parseQrLoginPayload(data);
|
||||
if (!payload) {
|
||||
Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const before = useAuthStore.getState().isAuthenticated;
|
||||
if (payload.kind === 'connect') {
|
||||
// Server-bootstrap QR: fill the field and run the normal browser
|
||||
// handoff so the user still authenticates in the webmail.
|
||||
setServerUrl(payload.webmailUrl);
|
||||
await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode });
|
||||
} else {
|
||||
// Cross-device pairing QR: redeem the one-time code for tokens, no
|
||||
// browser round-trip needed.
|
||||
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
|
||||
}
|
||||
const after = useAuthStore.getState().isAuthenticated;
|
||||
if ((after && !before) || (isAddMode && after)) {
|
||||
onLogin?.();
|
||||
}
|
||||
} catch {
|
||||
// Store state already contains the user-facing error.
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = React.useCallback(
|
||||
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
|
||||
if (useAuthStore.getState().error) {
|
||||
clearError();
|
||||
}
|
||||
setter(value);
|
||||
const goTo = React.useCallback(
|
||||
(next: StepName) => {
|
||||
clearError();
|
||||
setNotice(null);
|
||||
setHistory((prev) => [...prev, step]);
|
||||
setStep(next);
|
||||
},
|
||||
[clearError],
|
||||
[clearError, step],
|
||||
);
|
||||
|
||||
const onChangeServerUrl = React.useMemo(() => updateField(setServerUrl), [updateField]);
|
||||
const onChangeEmail = React.useMemo(() => updateField(setEmail), [updateField]);
|
||||
const onChangePassword = React.useMemo(() => updateField(setPassword), [updateField]);
|
||||
const goBack = React.useCallback(() => {
|
||||
if (history.length === 0) return;
|
||||
clearError();
|
||||
setNotice(null);
|
||||
setStep(history[history.length - 1]);
|
||||
setHistory((prev) => prev.slice(0, -1));
|
||||
}, [clearError, history]);
|
||||
|
||||
// Android hardware back mirrors the on-screen back: one step at a time, and
|
||||
// out of the flow entirely from the first step.
|
||||
React.useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (busy || searching) return true;
|
||||
if (scannerVisible) {
|
||||
setScannerVisible(false);
|
||||
return true;
|
||||
}
|
||||
if (history.length > 0) {
|
||||
goBack();
|
||||
return true;
|
||||
}
|
||||
if (isAddMode && onCancel) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [busy, searching, scannerVisible, history.length, goBack, isAddMode, onCancel]);
|
||||
|
||||
// ── flows ──────────────────────────────────────────────
|
||||
|
||||
const finishIfSignedIn = React.useCallback(
|
||||
(wasAuthenticated: boolean) => {
|
||||
const isAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
// In add mode the store was already authenticated before we started, so
|
||||
// the before/after comparison can't be the only signal.
|
||||
if ((isAuthenticated && !wasAuthenticated) || (isAddMode && isAuthenticated)) {
|
||||
onLogin?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isAddMode, onLogin],
|
||||
);
|
||||
|
||||
const runHandoff = React.useCallback(
|
||||
async (target: string) => {
|
||||
setNotice(null);
|
||||
setBusy('browser');
|
||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
try {
|
||||
await loginViaWebmail(target, { addAccount: isAddMode });
|
||||
// loginViaWebmail resolves quietly when the user closes the browser —
|
||||
// that's a cancellation, not a failure, so leave them where they were.
|
||||
finishIfSignedIn(wasAuthenticated);
|
||||
} catch (err) {
|
||||
setNotice(describeLoginError(err, { serverUrl: target }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[finishIfSignedIn, isAddMode, loginViaWebmail],
|
||||
);
|
||||
|
||||
const handleEmailContinue = React.useCallback(async () => {
|
||||
const value = email.trim();
|
||||
if (!isEmailAddress(value)) {
|
||||
setNotice({
|
||||
title: 'Enter a full email address',
|
||||
detail: 'Like ada@example.com. If you sign in with a username instead, use “I know my server address”.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setNotice(null);
|
||||
setSearching(true);
|
||||
try {
|
||||
const found = await discoverServerForEmail(value, { knownServerUrls });
|
||||
if (found) {
|
||||
setServerUrl(found);
|
||||
setServerInput(found);
|
||||
goTo('confirm');
|
||||
} else {
|
||||
setFailedDomain(emailDomain(value));
|
||||
setServerInput('');
|
||||
goTo('server');
|
||||
}
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [email, goTo, knownServerUrls]);
|
||||
|
||||
const handleServerContinue = React.useCallback(() => {
|
||||
const normalized = normalizeServerUrl(serverInput);
|
||||
if (!normalized) {
|
||||
setNotice({
|
||||
title: "That doesn't look like a server address",
|
||||
detail: 'Try the address you use for webmail, like mail.example.com.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setServerUrl(normalized);
|
||||
goTo('confirm');
|
||||
}, [goTo, serverInput]);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback(async () => {
|
||||
const target = serverUrl || normalizeServerUrl(serverInput);
|
||||
if (!target) {
|
||||
setNotice({ title: 'We need a server address first' });
|
||||
return;
|
||||
}
|
||||
if (!email.trim() || !password) {
|
||||
setNotice({ title: 'Enter your email and password' });
|
||||
return;
|
||||
}
|
||||
|
||||
setNotice(null);
|
||||
setBusy('connecting');
|
||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
try {
|
||||
await login(target, email.trim(), password, { addAccount: isAddMode });
|
||||
finishIfSignedIn(wasAuthenticated);
|
||||
} catch (err) {
|
||||
setNotice(describeLoginError(err, { serverUrl: target }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]);
|
||||
|
||||
const handleScanned = React.useCallback(
|
||||
async (data: string) => {
|
||||
setScannerVisible(false);
|
||||
const payload = parseQrLoginPayload(data);
|
||||
if (!payload) {
|
||||
setNotice({
|
||||
title: "That code isn't a Bulwark sign-in code",
|
||||
detail: 'Open Bulwark on the web, then Settings → Devices to show one.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.kind === 'connect') {
|
||||
// Server-bootstrap code: it carries the address, the user still
|
||||
// authenticates in the webmail.
|
||||
setServerUrl(payload.webmailUrl);
|
||||
setServerInput(payload.webmailUrl);
|
||||
await runHandoff(payload.webmailUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-device pairing code: redeem it for tokens, no browser needed.
|
||||
setNotice(null);
|
||||
setBusy('pairing');
|
||||
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||
try {
|
||||
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
|
||||
finishIfSignedIn(wasAuthenticated);
|
||||
} catch (err) {
|
||||
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl }));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[finishIfSignedIn, isAddMode, loginViaPairing, runHandoff],
|
||||
);
|
||||
|
||||
// ── render ─────────────────────────────────────────────
|
||||
|
||||
if (busy) {
|
||||
return (
|
||||
<SigningInStep
|
||||
phase={busy}
|
||||
serverUrl={serverUrl || knownServerUrl}
|
||||
email={email.trim() || null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canGoBack = history.length > 0;
|
||||
const shellProps = {
|
||||
onBack: canGoBack ? goBack : undefined,
|
||||
onClose: isAddMode && onCancel ? onCancel : undefined,
|
||||
};
|
||||
|
||||
if (step === 'choose') {
|
||||
// A leftover store error ("Session expired") is the reason some people
|
||||
// land back here, so it belongs on this screen.
|
||||
const chooseNotice = notice ?? (storeError ? { title: storeError } : null);
|
||||
return (
|
||||
<>
|
||||
<LoginShell {...shellProps} centered showFooter>
|
||||
<ChooseStep
|
||||
isAddMode={isAddMode}
|
||||
accounts={accounts}
|
||||
knownServerUrl={isAddMode ? knownServerUrl : null}
|
||||
notice={chooseNotice}
|
||||
onScan={() => setScannerVisible(true)}
|
||||
onUseEmail={() => goTo('email')}
|
||||
onUseKnownServer={() => {
|
||||
if (!knownServerUrl) return;
|
||||
setServerUrl(knownServerUrl);
|
||||
setServerInput(knownServerUrl);
|
||||
goTo('confirm');
|
||||
}}
|
||||
onManualSetup={() => {
|
||||
setFailedDomain(null);
|
||||
goTo('server');
|
||||
}}
|
||||
/>
|
||||
</LoginShell>
|
||||
<QrScanModal
|
||||
visible={scannerVisible}
|
||||
onClose={() => setScannerVisible(false)}
|
||||
onScanned={(data) => void handleScanned(data)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{isAddMode && onCancel ? (
|
||||
<Pressable onPress={onCancel} style={styles.cancelButton} hitSlop={10}>
|
||||
<X size={24} color={c.text} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{/* Logo & Branding */}
|
||||
<View style={styles.branding}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={require('../../assets/logos/Bulwark Logo White.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.appName}>{isAddMode ? 'Add account' : 'Bulwark Mail'}</Text>
|
||||
<Text style={styles.tagline}>
|
||||
{isAddMode ? 'Sign in to a second account' : 'Secure. Private. Yours.'}
|
||||
</Text>
|
||||
</View>
|
||||
<>
|
||||
<LoginShell {...shellProps}>
|
||||
{step === 'email' ? (
|
||||
<EmailStep
|
||||
isAddMode={isAddMode}
|
||||
value={email}
|
||||
onChange={(value) => {
|
||||
setNotice(null);
|
||||
setEmail(value);
|
||||
}}
|
||||
onSubmit={() => void handleEmailContinue()}
|
||||
onKnowServer={() => {
|
||||
setFailedDomain(null);
|
||||
goTo('server');
|
||||
}}
|
||||
isSearching={searching}
|
||||
notice={notice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Login Form */}
|
||||
<View style={styles.form}>
|
||||
<Input
|
||||
label="Server URL"
|
||||
placeholder="https://mail.example.com"
|
||||
value={serverUrl}
|
||||
onChangeText={onChangeServerUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{step === 'server' ? (
|
||||
<ServerStep
|
||||
value={serverInput}
|
||||
onChange={(value) => {
|
||||
setNotice(null);
|
||||
setServerInput(value);
|
||||
}}
|
||||
onSubmit={handleServerContinue}
|
||||
onScan={() => setScannerVisible(true)}
|
||||
failedDomain={failedDomain}
|
||||
notice={notice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Email or Username"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChangeText={onChangeEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{step === 'confirm' ? (
|
||||
<ConfirmStep
|
||||
serverUrl={serverUrl}
|
||||
onContinue={() => void runHandoff(serverUrl)}
|
||||
onChangeServer={() => {
|
||||
setFailedDomain(null);
|
||||
goTo('server');
|
||||
}}
|
||||
onUsePassword={() => goTo('password')}
|
||||
notice={notice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChangeText={onChangePassword}
|
||||
secureTextEntry={!showPassword}
|
||||
onSubmitEditing={() => {
|
||||
void handleLogin();
|
||||
}}
|
||||
rightIcon={
|
||||
<Pressable onPress={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? (
|
||||
<EyeOff size={20} color={c.textMuted} />
|
||||
) : (
|
||||
<Eye size={20} color={c.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
void handleLogin();
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
loading={isLoading}
|
||||
style={styles.loginButton}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
|
||||
{/* OAuth divider */}
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>or continue with</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* Webmail-mediated login */}
|
||||
<View style={styles.oauthRow}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
void handleWebmailHandoff();
|
||||
}}
|
||||
disabled={!canHandoff || isLoading}
|
||||
>
|
||||
Sign in via webmail
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onPress={() => setScannerVisible(true)}
|
||||
disabled={isLoading}
|
||||
icon={<QrCode size={18} color={c.text} />}
|
||||
>
|
||||
Scan QR code
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
|
||||
<Text style={styles.footerLink}>Privacy Policy</Text>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
{step === 'password' ? (
|
||||
<PasswordStep
|
||||
serverUrl={serverUrl}
|
||||
email={email}
|
||||
password={password}
|
||||
onChangeEmail={(value) => {
|
||||
setNotice(null);
|
||||
setEmail(value);
|
||||
}}
|
||||
onChangePassword={(value) => {
|
||||
setNotice(null);
|
||||
setPassword(value);
|
||||
}}
|
||||
onSubmit={() => void handlePasswordSubmit()}
|
||||
notice={notice}
|
||||
/>
|
||||
) : null}
|
||||
</LoginShell>
|
||||
|
||||
<QrScanModal
|
||||
visible={scannerVisible}
|
||||
onClose={() => setScannerVisible(false)}
|
||||
onScanned={(data) => {
|
||||
void handleScanned(data);
|
||||
}}
|
||||
onScanned={(data) => void handleScanned(data)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
cancelButton: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: 16,
|
||||
zIndex: 10,
|
||||
padding: 8,
|
||||
},
|
||||
keyboardView: { flex: 1 },
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
branding: { alignItems: 'center', marginBottom: 40 },
|
||||
logoContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
logoImage: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
},
|
||||
appName: { ...typography.h1, color: c.text },
|
||||
tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs },
|
||||
|
||||
form: { gap: spacing.lg },
|
||||
|
||||
loginButton: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
errorText: {
|
||||
...typography.caption,
|
||||
color: c.error,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
dividerLine: { flex: 1, height: 1, backgroundColor: c.border },
|
||||
dividerText: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
oauthRow: { gap: spacing.md },
|
||||
|
||||
footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs },
|
||||
footerText: { ...typography.caption, color: c.textMuted },
|
||||
footerLink: { ...typography.caption, color: c.textLink },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
|
||||
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -138,11 +139,18 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
updates: UpdatesSettings,
|
||||
};
|
||||
|
||||
// Tabs whose feature does not exist on this platform never appear in the list.
|
||||
// The "Updates" pane drives the sideload installer, which is Android-only - the
|
||||
// current version and build are still shown under "About & Data".
|
||||
const AVAILABLE_TABS: TabDef[] = TABS.filter(
|
||||
(t) => t.id !== 'updates' || supportsSideloadUpdates,
|
||||
);
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
items: AVAILABLE_TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
|
||||
onPress={() => onOpen(item)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
{unread && <View style={styles.unreadDot} />}
|
||||
<View style={styles.avatarWrap}>
|
||||
<SenderAvatar
|
||||
name={senderName(item)}
|
||||
@@ -219,6 +220,19 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
// Same unread indicator as the mailbox list (and the webmail): an 8px
|
||||
// filled circle in the start gutter (#27). Anchored on the row's *first*
|
||||
// line — top padding + half an avatar — because centring it on the row
|
||||
// drops it a full line below the avatar it reads as a column with.
|
||||
unreadDot: {
|
||||
position: 'absolute',
|
||||
left: 4,
|
||||
top: spacing.md + componentSizes.avatarMd / 2 - 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: c.unread,
|
||||
},
|
||||
avatarWrap: { position: 'relative' },
|
||||
accountDot: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Image } from 'react-native';
|
||||
import { QrCode, Mail, Plus, Server } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import type { AccountEntry } from '../../stores/account-store';
|
||||
import OptionTile from './OptionTile';
|
||||
import LoginNotice from './LoginNotice';
|
||||
|
||||
// The white mark disappears on the light palette, so pick per theme. `require`
|
||||
// can't take an expression, hence the pair.
|
||||
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
|
||||
const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
|
||||
|
||||
interface ChooseStepProps {
|
||||
isAddMode: boolean;
|
||||
accounts: AccountEntry[];
|
||||
/** Host of the signed-in account, offered as a shortcut in add mode. */
|
||||
knownServerUrl: string | null;
|
||||
onScan: () => void;
|
||||
onUseEmail: () => void;
|
||||
onUseKnownServer: () => void;
|
||||
onManualSetup: () => void;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
|
||||
}
|
||||
|
||||
function initialsOf(account: AccountEntry): string {
|
||||
const source = account.displayName || account.email || account.username;
|
||||
const parts = source.replace(/@.*$/, '').split(/[.\s_-]+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((part) => part[0]);
|
||||
return (letters.join('') || source[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry point. Leads with the two routes that don't require knowing a
|
||||
* server address; manual setup stays one tap away for people who do.
|
||||
*/
|
||||
export default function ChooseStep({
|
||||
isAddMode,
|
||||
accounts,
|
||||
knownServerUrl,
|
||||
onScan,
|
||||
onUseEmail,
|
||||
onUseKnownServer,
|
||||
onManualSetup,
|
||||
notice,
|
||||
disabled = false,
|
||||
}: ChooseStepProps) {
|
||||
const c = useColors();
|
||||
const theme = useResolvedTheme();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{isAddMode ? (
|
||||
<View style={styles.addHeading}>
|
||||
<Text style={styles.title}>Add another account</Text>
|
||||
{accounts.length > 0 ? (
|
||||
<Text style={styles.subtitle}>You're signed in to these already.</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.branding}>
|
||||
<Image
|
||||
source={theme === 'light' ? LOGO_LIGHT : LOGO_DARK}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.appName}>Bulwark Mail</Text>
|
||||
<Text style={styles.tagline}>Secure. Private. Yours.</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isAddMode && accounts.length > 0 ? (
|
||||
<View style={styles.accountList}>
|
||||
{accounts.map((account) => (
|
||||
<View key={account.id} style={styles.accountRow}>
|
||||
<View style={[styles.avatar, { backgroundColor: account.avatarColor }]}>
|
||||
<Text style={styles.avatarText}>{initialsOf(account)}</Text>
|
||||
</View>
|
||||
<View style={styles.accountText}>
|
||||
<Text style={styles.accountName} numberOfLines={1}>
|
||||
{account.displayName || account.username}
|
||||
</Text>
|
||||
<Text style={styles.accountEmail} numberOfLines={1}>
|
||||
{account.email || account.username}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<View style={styles.notice}>
|
||||
<LoginNotice title={notice.title} detail={notice.detail} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.options}>
|
||||
{isAddMode && knownServerUrl ? (
|
||||
<OptionTile
|
||||
emphasis="primary"
|
||||
title={`Another account on ${hostOf(knownServerUrl)}`}
|
||||
description="Server already known — no setup needed"
|
||||
renderIcon={(color, size) => <Plus size={size} color={color} />}
|
||||
onPress={onUseKnownServer}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : (
|
||||
<OptionTile
|
||||
emphasis="primary"
|
||||
title="Scan a sign-in code"
|
||||
description="Already signed in on the web? Fastest way in."
|
||||
renderIcon={(color, size) => <QrCode size={size} color={color} />}
|
||||
onPress={onScan}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OptionTile
|
||||
title={isAddMode ? 'An account somewhere else' : 'Use my email address'}
|
||||
description="We'll find your mail server"
|
||||
renderIcon={(color, size) => <Mail size={size} color={color} />}
|
||||
onPress={onUseEmail}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isAddMode && knownServerUrl ? (
|
||||
<OptionTile
|
||||
title="Scan a sign-in code"
|
||||
renderIcon={(color, size) => <QrCode size={size} color={color} />}
|
||||
onPress={onScan}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Pressable onPress={onManualSetup} disabled={disabled} hitSlop={8} style={styles.manual}>
|
||||
<Server size={14} color={c.textMuted} />
|
||||
<Text style={styles.manualText}>Enter server details manually</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
root: { gap: spacing.xl },
|
||||
branding: { alignItems: 'center', gap: spacing.xs, marginBottom: spacing.md },
|
||||
logo: { width: 64, height: 64, marginBottom: spacing.sm },
|
||||
appName: { ...typography.h1, color: c.text },
|
||||
tagline: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
addHeading: { gap: spacing.xs },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
|
||||
accountList: { gap: spacing.md },
|
||||
accountRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
avatar: { width: 36, height: 36, borderRadius: radius.full, alignItems: 'center', justifyContent: 'center' },
|
||||
avatarText: { ...typography.captionMedium, color: '#ffffff' },
|
||||
accountText: { flex: 1 },
|
||||
accountName: { ...typography.bodyMedium, color: c.text },
|
||||
accountEmail: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
notice: { marginTop: -spacing.xs },
|
||||
options: { gap: spacing.md },
|
||||
|
||||
manual: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
manualText: { ...typography.body, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { Globe } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button } from '../../components';
|
||||
import LoginNotice from './LoginNotice';
|
||||
|
||||
interface ConfirmStepProps {
|
||||
serverUrl: string;
|
||||
onContinue: () => void;
|
||||
onChangeServer: () => void;
|
||||
onUsePassword: () => void;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the server before the browser opens. A sign-in that jumps to a browser
|
||||
* with no warning reads as a bug; saying which server, and why the password
|
||||
* isn't asked for here, turns the detour into the point.
|
||||
*/
|
||||
export default function ConfirmStep({
|
||||
serverUrl,
|
||||
onContinue,
|
||||
onChangeServer,
|
||||
onUsePassword,
|
||||
notice,
|
||||
}: ConfirmStepProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>Found your mail server</Text>
|
||||
<Text style={styles.subtitle}>Next you'll sign in on your provider's own page.</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardIcon}>
|
||||
<Globe size={19} color={c.primary} />
|
||||
</View>
|
||||
<View style={styles.cardText}>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{hostOf(serverUrl)}
|
||||
</Text>
|
||||
<View style={styles.cardMetaRow}>
|
||||
<View style={styles.dot} />
|
||||
<Text style={styles.cardMeta}>
|
||||
{serverUrl.startsWith('https://') ? 'JMAP · secure connection' : 'JMAP · unencrypted connection'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<LoginNotice
|
||||
tone="info"
|
||||
title="Your password stays with your provider"
|
||||
detail="You'll type it on their page, not into this app. Bulwark keeps a token it can revoke."
|
||||
/>
|
||||
|
||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||
|
||||
<Button variant="default" size="lg" onPress={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Pressable onPress={onUsePassword} hitSlop={8}>
|
||||
<Text style={styles.link}>Sign in with a password instead</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={onChangeServer} hitSlop={8}>
|
||||
<Text style={styles.linkMuted}>Use a different server</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
root: { gap: spacing.lg },
|
||||
heading: { gap: spacing.sm },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
cardIcon: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.primaryBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardText: { flex: 1, gap: 2 },
|
||||
cardTitle: { ...typography.baseMedium, color: c.text },
|
||||
cardMetaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
dot: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: c.success },
|
||||
cardMeta: { ...typography.caption, color: c.textSecondary },
|
||||
|
||||
links: { alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs },
|
||||
link: { ...typography.body, color: c.textLink },
|
||||
linkMuted: { ...typography.body, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { Mail } from 'lucide-react-native';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button, Input } from '../../components';
|
||||
import LoginNotice from './LoginNotice';
|
||||
|
||||
interface EmailStepProps {
|
||||
isAddMode: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onKnowServer: () => void;
|
||||
isSearching: boolean;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One field, keyboard already up. The button stays enabled and validates on
|
||||
* press, so nobody is left guessing which field disabled it.
|
||||
*/
|
||||
export default function EmailStep({
|
||||
isAddMode,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onKnowServer,
|
||||
isSearching,
|
||||
notice,
|
||||
}: EmailStepProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>
|
||||
{isAddMode ? "What's the other address?" : "What's your email address?"}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
That's usually all we need — we'll look up the mail server for you.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
placeholder="you@example.com"
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
keyboardType="email-address"
|
||||
returnKeyType="go"
|
||||
editable={!isSearching}
|
||||
onSubmitEditing={onSubmit}
|
||||
leftIcon={<Mail size={17} color={c.textMuted} />}
|
||||
/>
|
||||
|
||||
<Text style={styles.hint}>Works with Bulwark, Stalwart, and any JMAP mail server.</Text>
|
||||
|
||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||
|
||||
<Button variant="default" size="lg" onPress={onSubmit} loading={isSearching}>
|
||||
{isSearching ? 'Looking up your server…' : 'Continue'}
|
||||
</Button>
|
||||
|
||||
<Pressable onPress={onKnowServer} disabled={isSearching} hitSlop={8} style={styles.link}>
|
||||
<Text style={styles.linkText}>I know my server address</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
root: { gap: spacing.lg },
|
||||
heading: { gap: spacing.sm },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
|
||||
link: { alignItems: 'center', paddingVertical: spacing.sm },
|
||||
linkText: { ...typography.body, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { AlertTriangle, Lock } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface LoginNoticeProps {
|
||||
title: string;
|
||||
detail?: string;
|
||||
tone?: 'error' | 'info';
|
||||
}
|
||||
|
||||
/** Inline banner for a sign-in failure, or a reassurance about where a
|
||||
* password ends up. Errors lead with what went wrong, then what to do. */
|
||||
export default function LoginNotice({ title, detail, tone = 'error' }: LoginNoticeProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isError = tone === 'error';
|
||||
const Icon = isError ? AlertTriangle : Lock;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.banner, isError ? styles.bannerError : styles.bannerInfo]}
|
||||
accessibilityLiveRegion={isError ? 'polite' : 'none'}
|
||||
>
|
||||
<Icon size={16} color={isError ? c.error : c.textMuted} style={styles.icon} />
|
||||
<View style={styles.text}>
|
||||
<Text style={[styles.title, isError ? styles.titleError : styles.titleInfo]}>{title}</Text>
|
||||
{detail ? <Text style={styles.detail}>{detail}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
bannerError: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.errorBorder },
|
||||
bannerInfo: { backgroundColor: 'transparent', paddingHorizontal: 0 },
|
||||
icon: { marginTop: 1 },
|
||||
text: { flex: 1, gap: 2 },
|
||||
title: { ...typography.captionMedium },
|
||||
titleError: { color: c.error },
|
||||
titleInfo: { color: c.textSecondary },
|
||||
detail: { ...typography.caption, color: c.textSecondary, lineHeight: 17 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Constants from 'expo-constants';
|
||||
import { ArrowLeft, X } from 'lucide-react-native';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
|
||||
interface LoginShellProps {
|
||||
children: React.ReactNode;
|
||||
/** Renders a back chevron. Omit on the first step of the flow. */
|
||||
onBack?: () => void;
|
||||
/** Renders a close X on the right — add-account mode only. */
|
||||
onClose?: () => void;
|
||||
/** Vertically centres the content instead of stacking from the top. */
|
||||
centered?: boolean;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for every sign-in step: safe area, keyboard avoidance, the
|
||||
* back/close affordances, and the version footer. Steps supply only their own
|
||||
* content so they all sit on the same grid.
|
||||
*/
|
||||
export default function LoginShell({
|
||||
children,
|
||||
onBack,
|
||||
onClose,
|
||||
centered = false,
|
||||
showFooter = false,
|
||||
}: LoginShellProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.flex}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
{onBack ? (
|
||||
<Pressable onPress={onBack} hitSlop={12} style={styles.headerButton}>
|
||||
<ArrowLeft size={22} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.headerButton} />
|
||||
)}
|
||||
{onClose ? (
|
||||
<Pressable onPress={onClose} hitSlop={12} style={styles.headerButton}>
|
||||
<X size={22} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.headerButton} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
contentContainerStyle={[styles.content, centered && styles.contentCentered]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
|
||||
{showFooter ? (
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
flex: { flex: 1 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
height: 44,
|
||||
},
|
||||
headerButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
content: {
|
||||
paddingHorizontal: spacing.xxl,
|
||||
paddingBottom: spacing.xxxl,
|
||||
},
|
||||
contentCentered: { flexGrow: 1, justifyContent: 'center' },
|
||||
footer: { alignItems: 'center', paddingBottom: spacing.lg, gap: spacing.xs },
|
||||
footerText: { ...typography.caption, color: c.textMuted },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { ChevronRight } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface OptionTileProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Receives the colour the tile's text renders in. */
|
||||
renderIcon: (color: string, size: number) => React.ReactNode;
|
||||
onPress: () => void;
|
||||
emphasis?: 'primary' | 'default';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A full-width choice on the sign-in screen: icon, what it does, and what it
|
||||
* costs you. Used instead of a stack of buttons so each route can say enough
|
||||
* to be picked without trial and error.
|
||||
*/
|
||||
export default function OptionTile({
|
||||
title,
|
||||
description,
|
||||
renderIcon,
|
||||
onPress,
|
||||
emphasis = 'default',
|
||||
disabled = false,
|
||||
}: OptionTileProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isPrimary = emphasis === 'primary';
|
||||
const contentColor = isPrimary ? c.primaryForeground : c.text;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={description ? `${title}. ${description}` : title}
|
||||
style={({ pressed }) => [
|
||||
styles.tile,
|
||||
isPrimary ? styles.tilePrimary : styles.tileDefault,
|
||||
pressed && !disabled && (isPrimary ? styles.pressedPrimary : styles.pressedDefault),
|
||||
disabled && styles.disabled,
|
||||
]}
|
||||
>
|
||||
<View style={styles.icon}>{renderIcon(contentColor, 22)}</View>
|
||||
<View style={styles.text}>
|
||||
<Text style={[styles.title, { color: contentColor }]}>{title}</Text>
|
||||
{description ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{ color: isPrimary ? c.primaryForeground : c.textSecondary },
|
||||
isPrimary && styles.descriptionPrimary,
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<ChevronRight size={18} color={contentColor} style={styles.chevron} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
tile: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
borderRadius: radius.lg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
borderWidth: 1,
|
||||
},
|
||||
tileDefault: { backgroundColor: c.surface, borderColor: c.border },
|
||||
tilePrimary: { backgroundColor: c.primary, borderColor: c.primary },
|
||||
pressedDefault: { backgroundColor: c.surfaceHover },
|
||||
pressedPrimary: { backgroundColor: c.primaryDark },
|
||||
disabled: { opacity: 0.5 },
|
||||
icon: { width: 22, alignItems: 'center' },
|
||||
text: { flex: 1, gap: 2 },
|
||||
title: { ...typography.baseMedium },
|
||||
description: { ...typography.caption },
|
||||
descriptionPrimary: { opacity: 0.8 },
|
||||
chevron: { opacity: 0.5 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { Eye, EyeOff } from 'lucide-react-native';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button, Input } from '../../components';
|
||||
import LoginNotice from './LoginNotice';
|
||||
|
||||
interface PasswordStepProps {
|
||||
serverUrl: string;
|
||||
email: string;
|
||||
password: string;
|
||||
onChangeEmail: (value: string) => void;
|
||||
onChangePassword: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
|
||||
}
|
||||
|
||||
/** The fallback for servers without the browser hand-off — and the only step
|
||||
* where a password is typed into the app. */
|
||||
export default function PasswordStep({
|
||||
serverUrl,
|
||||
email,
|
||||
password,
|
||||
onChangeEmail,
|
||||
onChangePassword,
|
||||
onSubmit,
|
||||
notice,
|
||||
}: PasswordStepProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>Sign in to {hostOf(serverUrl)}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Your password is sent to this server only, and stored in the device keychain.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Email or username"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChangeText={onChangeEmail}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChangeText={onChangePassword}
|
||||
autoFocus={Boolean(email)}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="current-password"
|
||||
textContentType="password"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={onSubmit}
|
||||
rightIcon={
|
||||
<Pressable
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||
|
||||
<Button variant="default" size="lg" onPress={onSubmit}>
|
||||
Sign in
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
root: { gap: spacing.lg },
|
||||
heading: { gap: spacing.sm },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { Globe, QrCode } from 'lucide-react-native';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button, Input } from '../../components';
|
||||
import LoginNotice from './LoginNotice';
|
||||
|
||||
interface ServerStepProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onScan: () => void;
|
||||
/** Set when we got here because discovery came back empty for this domain. */
|
||||
failedDomain?: string | null;
|
||||
notice?: { title: string; detail?: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the address people actually know — the one they type in the browser
|
||||
* to reach their webmail — not the JMAP session endpoint. The scheme and the
|
||||
* `/.well-known/jmap` suffix are our problem, not theirs.
|
||||
*/
|
||||
export default function ServerStep({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onScan,
|
||||
failedDomain,
|
||||
notice,
|
||||
}: ServerStepProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.heading}>
|
||||
<Text style={styles.title}>Where does your mail live?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{failedDomain
|
||||
? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for webmail.`
|
||||
: 'Enter the address you use to reach your webmail.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
label="Server address"
|
||||
placeholder="mail.example.com"
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={onSubmit}
|
||||
leftIcon={<Globe size={17} color={c.textMuted} />}
|
||||
/>
|
||||
|
||||
<Text style={styles.hint}>Same address you type in the browser. We'll add https:// for you.</Text>
|
||||
|
||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||
|
||||
<Button variant="default" size="lg" onPress={onSubmit}>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<Pressable onPress={onScan} hitSlop={8} style={styles.scanRow}>
|
||||
<QrCode size={15} color={c.textMuted} />
|
||||
<Text style={styles.scanText}>
|
||||
Not sure? Scan a sign-in code instead — it carries the address with it.
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
root: { gap: spacing.lg },
|
||||
heading: { gap: spacing.sm },
|
||||
title: { ...typography.h1, color: c.text },
|
||||
subtitle: { ...typography.body, color: c.textSecondary },
|
||||
hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm },
|
||||
scanRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingVertical: spacing.sm },
|
||||
scanText: { ...typography.caption, color: c.textMuted, flex: 1, lineHeight: 17 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
|
||||
// The white mark disappears on the light palette, so pick per theme.
|
||||
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
|
||||
const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
|
||||
|
||||
export type SigningInPhase = 'browser' | 'connecting' | 'pairing';
|
||||
|
||||
interface SigningInStepProps {
|
||||
phase: SigningInPhase;
|
||||
serverUrl: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
function hostOf(url: string | null): string {
|
||||
if (!url) return 'your server';
|
||||
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
|
||||
}
|
||||
|
||||
function initialsOf(email: string): string {
|
||||
const local = email.replace(/@.*$/, '');
|
||||
const parts = local.split(/[.\s_-]+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((part) => part[0]);
|
||||
return (letters.join('') || local[0] || '?').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers the wait while a sign-in is in flight. Naming the server and the
|
||||
* current stage makes a slow first connection legible instead of a bare
|
||||
* spinner that could mean anything.
|
||||
*/
|
||||
export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) {
|
||||
const c = useColors();
|
||||
const theme = useResolvedTheme();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const host = hostOf(serverUrl);
|
||||
|
||||
const copy: Record<SigningInPhase, { title: string; detail: string }> = {
|
||||
browser: {
|
||||
title: `Opening ${host}`,
|
||||
detail: 'Finish signing in on the page that just opened. We’ll take it from there.',
|
||||
},
|
||||
connecting: { title: `Connecting to ${host}`, detail: 'Setting up your mailbox…' },
|
||||
pairing: { title: 'Redeeming your sign-in code', detail: `Pairing this device with ${host}…` },
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{email ? (
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{initialsOf(email)}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Image
|
||||
source={theme === 'light' ? LOGO_LIGHT : LOGO_DARK}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
{email ? <Text style={styles.email}>{email}</Text> : null}
|
||||
<Text style={styles.title}>{copy[phase].title}</Text>
|
||||
<Text style={styles.detail}>{copy[phase].detail}</Text>
|
||||
|
||||
<ActivityIndicator size="small" color={c.primary} style={styles.spinner} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
avatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
avatarText: { ...typography.h2, color: c.primaryForeground },
|
||||
logo: { width: 60, height: 60, marginBottom: spacing.md },
|
||||
email: { ...typography.caption, color: c.textMuted },
|
||||
title: { ...typography.h2, color: c.text, textAlign: 'center' },
|
||||
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
|
||||
spinner: { marginTop: spacing.xl },
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,8 @@ beforeEach(() => {
|
||||
useCalendarStore.setState({
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
taskOnlyCalendarIds: [],
|
||||
hiddenCalendarIds: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
@@ -129,6 +131,77 @@ describe('calendar-store', () => {
|
||||
expect(stored[0].originalId).toBe('ev1');
|
||||
expect(stored[0].accountId).toBe('acc-2');
|
||||
});
|
||||
|
||||
it('classifies calendars whose contents are all Tasks as task lists (#28)', async () => {
|
||||
// VTODO-only CalDAV collections (Todoist imports, Thunderbird task
|
||||
// lists) must be flagged so the drawer can keep them out of the
|
||||
// calendar list. Mixed calendars, calendars with start-less Events,
|
||||
// and empty calendars all stay ordinary calendars.
|
||||
mockQueryEvents.mockResolvedValue(['t1', 'e1', 't2', 'startless']);
|
||||
mockGetEvents.mockResolvedValue([
|
||||
{ id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-tasks': true } },
|
||||
{ id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-mixed': true } },
|
||||
{ id: 't2', '@type': 'Task', title: 'Ship it', calendarIds: { 'cal-mixed': true } },
|
||||
{ id: 'startless', '@type': 'Event', calendarIds: { 'cal-startless': true } },
|
||||
]);
|
||||
|
||||
await useCalendarStore.getState().fetchEvents(
|
||||
['cal-tasks', 'cal-mixed', 'cal-startless', 'cal-empty'],
|
||||
'2026-03-01',
|
||||
'2026-03-31',
|
||||
);
|
||||
|
||||
expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual(['cal-tasks']);
|
||||
expect(useCalendarStore.getState().tasks.map((t) => t.id)).toEqual(['t1', 't2']);
|
||||
});
|
||||
|
||||
it('reclassifies task lists on refetch instead of accumulating stale ids', async () => {
|
||||
useCalendarStore.setState({ taskOnlyCalendarIds: ['cal-old'] });
|
||||
mockQueryEvents.mockResolvedValue(['e1']);
|
||||
mockGetEvents.mockResolvedValue([
|
||||
{ id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-old': true } },
|
||||
]);
|
||||
|
||||
await useCalendarStore.getState().fetchEvents(['cal-old'], '2026-03-01', '2026-03-31');
|
||||
|
||||
expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('routes queries by account when own and shared calendars share a raw id', async () => {
|
||||
// The user's own "default" calendar and a family group's "default"
|
||||
// calendar collide on the raw JMAP id. The shared one is namespaced
|
||||
// (`acc-2:default`, originalId "default"); the query must use the raw id
|
||||
// against acc-2, and the returned event's calendarIds must be remapped to
|
||||
// the namespaced store id so it isn't hidden with the personal calendar.
|
||||
useCalendarStore.setState({
|
||||
calendars: [
|
||||
{ id: 'default', name: 'Personal' } as any,
|
||||
{ id: 'acc-2:default', originalId: 'default', name: 'Family', accountId: 'acc-2', isShared: true } as any,
|
||||
],
|
||||
});
|
||||
const queried: Record<string, string[]> = {};
|
||||
mockQueryEvents.mockImplementation(async (ids: string[], _a: string, _b: string, accountId?: string) => {
|
||||
queried[accountId ?? 'primary'] = ids;
|
||||
return accountId === 'acc-2' ? ['fam1'] : [];
|
||||
});
|
||||
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
|
||||
accountId === 'acc-2'
|
||||
? [{ id: 'fam1', title: 'Soccer', start: '2026-03-20T15:00:00', calendarIds: { default: true } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
await useCalendarStore.getState().fetchEvents(['default', 'acc-2:default'], '2026-03-01', '2026-03-31');
|
||||
|
||||
// The shared account is queried with the raw calendar id, not the prefix.
|
||||
expect(queried['acc-2']).toEqual(['default']);
|
||||
const ev = useCalendarStore.getState().events[0];
|
||||
expect(ev.calendarIds).toEqual({ 'acc-2:default': true });
|
||||
|
||||
// Hiding the personal "default" must not hide the family event.
|
||||
useCalendarStore.setState({ hiddenCalendarIds: ['default'] });
|
||||
const visible = selectVisibleEvents(useCalendarStore.getState());
|
||||
expect(visible.map((e) => e.id)).toContain('acc-2:fam1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRange', () => {
|
||||
|
||||
@@ -111,6 +111,7 @@ import { useEmailStore } from '../email-store';
|
||||
|
||||
const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType<typeof vi.fn>;
|
||||
const mockQueryEmails = emailApi.queryEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmails = emailApi.getEmails as ReturnType<typeof vi.fn>;
|
||||
const mockGetEmailsWithState = emailApi.getEmailsWithState as ReturnType<typeof vi.fn>;
|
||||
const mockGetFullEmail = emailApi.getFullEmail as ReturnType<typeof vi.fn>;
|
||||
@@ -313,6 +314,149 @@ describe('email-store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #6: the "Unread" tri-state filter must reach Email/query as a
|
||||
// notKeyword condition and force the full re-query path (the incremental
|
||||
// path only serves the unfiltered base view).
|
||||
describe('filters (issue #6)', () => {
|
||||
it('applies the unread filter to the mailbox query', async () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [],
|
||||
totalEmails: 0,
|
||||
searchQuery: '',
|
||||
filters: { isUnread: true },
|
||||
mailboxSnapshots: {
|
||||
'mb-1': { emails: [{ id: 'e9' } as any], total: 1, queryState: 'q-base' },
|
||||
},
|
||||
});
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-unread' });
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: [{ id: 'e1' } as any], state: 'em-1' });
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
|
||||
const [mailboxId, opts] = mockQueryEmails.mock.calls[0];
|
||||
expect(mailboxId).toBe('mb-1');
|
||||
expect(opts.filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
|
||||
});
|
||||
// Filter results must not leak into the base-view snapshot.
|
||||
expect(useEmailStore.getState().mailboxSnapshots['mb-1'].emails).toEqual([{ id: 'e9' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #10: after searching, the inbox stayed stuck on the search results.
|
||||
// Root cause: the incremental Email/queryChanges refresh diffed against the
|
||||
// on-screen list (search results) instead of the cached base view, then
|
||||
// wrote the result back into the persisted mailbox snapshot.
|
||||
describe('search / return to inbox (issue #10)', () => {
|
||||
const base = [
|
||||
{ id: 'e1', subject: 'One' } as any,
|
||||
{ id: 'e2', subject: 'Two' } as any,
|
||||
];
|
||||
|
||||
it('incremental refresh diffs against the snapshot, not on-screen search results', async () => {
|
||||
// Screen still shows a (stale) search-result list, but searchQuery is
|
||||
// already '' — the cold-start / just-cleared-search shape.
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [base[1]],
|
||||
totalEmails: 1,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
|
||||
});
|
||||
mockGetEmailQueryChanges.mockResolvedValue({
|
||||
oldQueryState: 'q-base',
|
||||
newQueryState: 'q-2',
|
||||
total: 2,
|
||||
removed: [],
|
||||
added: [],
|
||||
});
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails).toEqual(base);
|
||||
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
|
||||
expect(mockQueryEmails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write search results into the mailbox snapshot', async () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: base,
|
||||
totalEmails: 2,
|
||||
searchQuery: 'two',
|
||||
filters: {},
|
||||
queryState: 'q-base',
|
||||
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
|
||||
});
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e2'], total: 1, queryState: 'q-search' });
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: [base[1]], state: 'em-1' });
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails).toEqual([base[1]]);
|
||||
// Base-view cache must survive the search untouched.
|
||||
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
|
||||
expect(state.queryState).toBe('q-base');
|
||||
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clearSearchAndFilters restores the cached base view immediately', () => {
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [base[1]],
|
||||
totalEmails: 1,
|
||||
searchQuery: 'two',
|
||||
filters: {},
|
||||
mailboxSnapshots: { 'mb-1': { emails: base, total: 2, queryState: 'q-base' } },
|
||||
});
|
||||
mockGetEmailQueryChanges.mockResolvedValue({
|
||||
oldQueryState: 'q-base',
|
||||
newQueryState: 'q-2',
|
||||
total: 2,
|
||||
removed: [],
|
||||
added: [],
|
||||
});
|
||||
|
||||
useEmailStore.getState().clearSearchAndFilters();
|
||||
|
||||
// Synchronously back on the base view — no waiting for the network.
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.searchQuery).toBe('');
|
||||
expect(state.emails).toEqual(base);
|
||||
expect(state.totalEmails).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to a full re-query when the snapshot window is incomplete', async () => {
|
||||
// A pre-fix install could have a poisoned snapshot: a handful of search
|
||||
// results stored against the base view's total. It can't be patched
|
||||
// incrementally — the refresh must rebuild it from the server.
|
||||
useEmailStore.setState({
|
||||
currentMailboxId: 'mb-1',
|
||||
emails: [base[1]],
|
||||
totalEmails: 1,
|
||||
searchQuery: '',
|
||||
filters: {},
|
||||
mailboxSnapshots: { 'mb-1': { emails: [base[1]], total: 50, queryState: 'q-base' } },
|
||||
});
|
||||
mockQueryEmails.mockResolvedValue({ ids: ['e1', 'e2'], total: 50, queryState: 'q-new' });
|
||||
mockGetEmailsWithState.mockResolvedValue({ list: base, state: 'em-2' });
|
||||
|
||||
await useEmailStore.getState().refreshEmails();
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(mockGetEmailQueryChanges).not.toHaveBeenCalled();
|
||||
expect(state.emails).toEqual(base);
|
||||
expect(state.mailboxSnapshots['mb-1'].emails).toEqual(base);
|
||||
expect(state.mailboxSnapshots['mb-1'].queryState).toBe('q-new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailDetail', () => {
|
||||
it('should fetch full email', async () => {
|
||||
const fullEmail = { id: 'e1', subject: 'Test', bodyValues: { html: { value: '<p>Hi</p>' } } };
|
||||
|
||||
@@ -25,6 +25,37 @@ function hasSchedulingParticipants(event?: Partial<CalendarEvent> | null): boole
|
||||
|
||||
const HIDDEN_CALENDARS_STORAGE_KEY = 'webmail:calendar:hidden:v1';
|
||||
|
||||
// Map a server CalendarEvent onto its store representation. For events from a
|
||||
// shared/group account the JMAP id is only unique within that account, so we
|
||||
// namespace `id` (stashing the real id in originalId) and remap the event's
|
||||
// `calendarIds` from raw server ids onto the namespaced store calendar ids so
|
||||
// visibility filtering and per-calendar colour lookup line up. Events from the
|
||||
// primary account are returned unchanged. Mirrors webmail's
|
||||
// mapServerEventToStoreEvent.
|
||||
function mapServerEventToStoreEvent(
|
||||
event: CalendarEvent,
|
||||
calendars: Calendar[],
|
||||
accountId?: string,
|
||||
): CalendarEvent {
|
||||
if (!accountId) return event;
|
||||
const mapped: Record<string, boolean> = {};
|
||||
for (const [calId, included] of Object.entries(event.calendarIds || {})) {
|
||||
const cal = calendars.find(
|
||||
(c) => (c.originalId || c.id) === calId && c.accountId === accountId,
|
||||
);
|
||||
mapped[cal?.id || calId] = included;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
id: `${accountId}:${event.id}`,
|
||||
originalId: event.id,
|
||||
originalCalendarIds: event.calendarIds,
|
||||
calendarIds: Object.keys(mapped).length > 0 ? mapped : event.calendarIds,
|
||||
accountId,
|
||||
isShared: true,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoadedRange {
|
||||
after: string;
|
||||
before: string;
|
||||
@@ -34,6 +65,12 @@ export interface CalendarState {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
tasks: CalendarEvent[];
|
||||
// Store ids of calendars that hold only Task objects (VTODO-only CalDAV
|
||||
// collections, e.g. Thunderbird/Todoist task lists). JMAP Calendar/get
|
||||
// doesn't expose the CalDAV supported-calendar-component-set, so the
|
||||
// collection kind is inferred from contents: ≥1 task and no events.
|
||||
// Empty calendars are treated as ordinary event calendars. (#28)
|
||||
taskOnlyCalendarIds: string[];
|
||||
hiddenCalendarIds: string[];
|
||||
loadedRange: LoadedRange | null;
|
||||
loading: boolean;
|
||||
@@ -93,6 +130,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
taskOnlyCalendarIds: [],
|
||||
hiddenCalendarIds: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
@@ -135,14 +173,20 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
try {
|
||||
// Group the requested calendars by owning account: the primary account
|
||||
// (calendars without an accountId tag) plus one group per shared
|
||||
// account, since CalendarEvent/query is scoped to a single account.
|
||||
// account, since CalendarEvent/query is scoped to a single account. The
|
||||
// incoming ids are store ids (shared calendars are namespaced
|
||||
// `${accountId}:${id}`); map them back to the raw server ids the query
|
||||
// filter expects.
|
||||
const calendars = get().calendars;
|
||||
const byId = new Map(calendars.map((c) => [c.id, c]));
|
||||
const groups = new Map<string | undefined, string[]>();
|
||||
for (const id of calendarIds) {
|
||||
const accountId = calendars.find((c) => c.id === id)?.accountId;
|
||||
const cal = byId.get(id);
|
||||
const accountId = cal?.accountId;
|
||||
const serverId = cal?.originalId || id;
|
||||
const group = groups.get(accountId);
|
||||
if (group) group.push(id);
|
||||
else groups.set(accountId, [id]);
|
||||
if (group) group.push(serverId);
|
||||
else groups.set(accountId, [serverId]);
|
||||
}
|
||||
if (groups.size === 0) groups.set(undefined, []);
|
||||
|
||||
@@ -152,17 +196,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
|
||||
if (eventIds.length === 0) continue;
|
||||
const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
|
||||
// Shared/group accounts: JMAP object ids are only unique *within* an
|
||||
// account, so two accounts can return events with the same id.
|
||||
// Namespace shared ids with the owning accountId (stashing the real id
|
||||
// in originalId for mutations) so they don't collide with the user's
|
||||
// own events in the store / React keys. Mirrors webmail's
|
||||
// mapServerEventToStoreEvent.
|
||||
raw.push(
|
||||
...(accountId
|
||||
? fetched.map((e) => ({ ...e, accountId, id: `${accountId}:${e.id}`, originalId: e.id }))
|
||||
: fetched),
|
||||
);
|
||||
raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
|
||||
} catch (err) {
|
||||
// A failing shared account must not hide the user's own events.
|
||||
if (!accountId) throw err;
|
||||
@@ -176,8 +210,23 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
return (t === 'Event' || t === undefined) && !!e.start;
|
||||
});
|
||||
const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task');
|
||||
// Classify VTODO-only task lists from the full (undated) object set:
|
||||
// a calendar counts as a task list when every object in it is a Task.
|
||||
// Anything that isn't a Task — including start-less Events — keeps the
|
||||
// calendar in the ordinary event-calendar bucket.
|
||||
const eventCalendarIds = new Set<string>();
|
||||
const taskCalendarIds = new Set<string>();
|
||||
for (const obj of raw) {
|
||||
const isTask = (obj as { '@type'?: string })['@type'] === 'Task';
|
||||
for (const calId of Object.keys(obj.calendarIds || {})) {
|
||||
(isTask ? taskCalendarIds : eventCalendarIds).add(calId);
|
||||
}
|
||||
}
|
||||
const taskOnlyCalendarIds = [...taskCalendarIds].filter(
|
||||
(calId) => !eventCalendarIds.has(calId),
|
||||
);
|
||||
const events = expandRecurringEvents(onlyEvents, after, before);
|
||||
set({ events, tasks, loadedRange: { after, before }, loading: false });
|
||||
set({ events, tasks, taskOnlyCalendarIds, loadedRange: { after, before }, loading: false });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
|
||||
}
|
||||
@@ -235,20 +284,22 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
createEvent: async (event, calendarId) => {
|
||||
// Shared calendars live in the owner's account — route the create there.
|
||||
const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId;
|
||||
// Shared calendars live in the owner's account — route the create there,
|
||||
// and against the calendar's raw server id (calendarId is the namespaced
|
||||
// store id for shared calendars).
|
||||
const calendars = get().calendars;
|
||||
const cal = calendars.find((c) => c.id === calendarId);
|
||||
const accountId = cal?.accountId;
|
||||
const created = await apiCreateEvent(
|
||||
event,
|
||||
calendarId,
|
||||
cal?.originalId || calendarId,
|
||||
hasSchedulingParticipants(event) ? true : undefined,
|
||||
accountId,
|
||||
);
|
||||
// Namespace shared/group event ids the same way fetchEvents does so the
|
||||
// optimistic insert doesn't collide with the user's own events.
|
||||
const storeCreated = accountId
|
||||
? { ...created, accountId, id: `${accountId}:${created.id}`, originalId: created.id }
|
||||
: created;
|
||||
set({ events: [...get().events, storeCreated] });
|
||||
// Map the same way fetchEvents does so the optimistic insert doesn't
|
||||
// collide with the user's own events and stays visible under the right
|
||||
// calendar filter.
|
||||
set({ events: [...get().events, mapServerEventToStoreEvent(created, calendars, accountId)] });
|
||||
return created;
|
||||
},
|
||||
|
||||
@@ -300,20 +351,26 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
|
||||
importEvents: async (events, calendarId) => {
|
||||
if (events.length === 0) return 0;
|
||||
// Shared calendars live in the owner's account and carry a namespaced
|
||||
// store id — resolve the raw server id + owning account so dedup and
|
||||
// create target the right place.
|
||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
||||
const accountId = cal?.accountId;
|
||||
const serverCalendarId = cal?.originalId || calendarId;
|
||||
// Stalwart enforces UID uniqueness across calendars. Skip events whose UID
|
||||
// already exists; create the rest. (We don't attempt cross-calendar linking
|
||||
// on mobile — a duplicate is simply skipped.)
|
||||
let toCreate = events;
|
||||
try {
|
||||
const existingIds = await queryEvents([], '', '');
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds) : [];
|
||||
const existingIds = await queryEvents([], '', '', accountId);
|
||||
const existing = existingIds.length > 0 ? await fetchEvents(existingIds, accountId) : [];
|
||||
const seenUids = new Set(existing.map((e) => e.uid).filter(Boolean) as string[]);
|
||||
toCreate = events.filter((e) => !e.uid || !seenUids.has(e.uid));
|
||||
} catch {
|
||||
// Couldn't dedupe — proceed and let the server reject genuine dupes.
|
||||
}
|
||||
if (toCreate.length === 0) return 0;
|
||||
const count = await apiBatchCreateEvents(toCreate, calendarId);
|
||||
const count = await apiBatchCreateEvents(toCreate, serverCalendarId, accountId);
|
||||
await get().refresh();
|
||||
return count;
|
||||
},
|
||||
@@ -326,7 +383,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
|
||||
setDefaultCalendar: async (id) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiSetDefaultCalendar(id, cal?.accountId);
|
||||
await apiSetDefaultCalendar(cal?.originalId || id, cal?.accountId);
|
||||
set({
|
||||
calendars: get().calendars.map((c) => {
|
||||
if (c.id === id) return { ...c, isDefault: true };
|
||||
@@ -341,8 +398,13 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
createTask: async (task, calendarId) => {
|
||||
const accountId = get().calendars.find((c) => c.id === calendarId)?.accountId;
|
||||
await apiCreateEvent({ ...task, '@type': 'Task' }, calendarId, undefined, accountId);
|
||||
const cal = get().calendars.find((c) => c.id === calendarId);
|
||||
await apiCreateEvent(
|
||||
{ ...task, '@type': 'Task' },
|
||||
cal?.originalId || calendarId,
|
||||
undefined,
|
||||
cal?.accountId,
|
||||
);
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
@@ -394,6 +456,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
calendars: [],
|
||||
events: [],
|
||||
tasks: [],
|
||||
taskOnlyCalendarIds: [],
|
||||
loadedRange: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -409,6 +472,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
calendars: state.calendars,
|
||||
events: state.events,
|
||||
tasks: state.tasks,
|
||||
taskOnlyCalendarIds: state.taskOnlyCalendarIds,
|
||||
loadedRange: state.loadedRange,
|
||||
}),
|
||||
},
|
||||
|
||||
+96
-13
@@ -223,6 +223,17 @@ function isBaseView(searchQuery: string, filters: EmailFilters): boolean {
|
||||
return !searchQuery.trim() && Object.keys(filters).length === 0;
|
||||
}
|
||||
|
||||
// View fields to apply when returning from a search/filter to the base view:
|
||||
// the cached base-view snapshot, shown immediately so the list doesn't keep
|
||||
// displaying search results while the refresh is in flight (issue #10).
|
||||
function restoredBaseView(state: EmailState): Partial<EmailState> {
|
||||
const snap = state.currentMailboxId
|
||||
? state.mailboxSnapshots[state.currentMailboxId]
|
||||
: undefined;
|
||||
if (!snap) return {};
|
||||
return { emails: snap.emails, totalEmails: snap.total, queryState: snap.queryState };
|
||||
}
|
||||
|
||||
function snapshotFromActive(state: EmailState): AccountSnapshot {
|
||||
// Persist the currently-visible mailbox into its snapshot before tucking
|
||||
// the whole account away.
|
||||
@@ -642,7 +653,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
|
||||
refreshEmails: async () => {
|
||||
const state = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing, emailState, activeAccountId } = state;
|
||||
if (!currentMailboxId) return;
|
||||
if (!jmapClientServesActiveAccount(activeAccountId)) return;
|
||||
set({ loading: true, error: null });
|
||||
@@ -651,13 +662,37 @@ export const useEmailStore = create<EmailState>()(
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const baseView = isBaseView(searchQuery, filters);
|
||||
|
||||
// A response that lands after the user switched account/mailbox or
|
||||
// changed search/filters must not overwrite the newer view.
|
||||
const viewChanged = () =>
|
||||
get().activeAccountId !== activeAccountId ||
|
||||
get().currentMailboxId !== currentMailboxId ||
|
||||
get().searchQuery !== searchQuery ||
|
||||
get().filters !== filters;
|
||||
|
||||
// The incremental path diffs against the *base-view* list, which lives in
|
||||
// the per-mailbox snapshot — NOT `emails`, which may still hold search or
|
||||
// filter results (right after clearing a search, or after a cold start
|
||||
// that rehydrated a persisted search-result list). Diffing against a
|
||||
// non-base list lets Email/queryChanges "confirm" the search results as
|
||||
// the whole mailbox and bakes them into the snapshot (issue #10). The
|
||||
// snapshot is only trusted when its window is plausibly complete —
|
||||
// anything shorter can't be patched incrementally and needs the full
|
||||
// re-query below to rebuild it.
|
||||
const snap = state.mailboxSnapshots[currentMailboxId];
|
||||
|
||||
try {
|
||||
// Incremental sync path: requires the base unfiltered view AND a known
|
||||
// queryState (so Email/queryChanges has something to diff against).
|
||||
// Anything else — search, filter active, first-ever load — falls
|
||||
// through to a full re-query.
|
||||
if (baseView && queryState) {
|
||||
const queryChanges = await getEmailQueryChanges(currentMailboxId, queryState, {
|
||||
if (
|
||||
baseView &&
|
||||
snap?.queryState &&
|
||||
snap.emails.length >= Math.min(limit, snap.total)
|
||||
) {
|
||||
const baseEmails = snap.emails;
|
||||
const queryChanges = await getEmailQueryChanges(currentMailboxId, snap.queryState, {
|
||||
filter: undefined,
|
||||
});
|
||||
if (queryChanges) {
|
||||
@@ -687,9 +722,9 @@ export const useEmailStore = create<EmailState>()(
|
||||
}
|
||||
|
||||
// Fetch only what we don't already have. `addedIds` are new to the
|
||||
// window; `updatedIds` may already be in `existing` but their
|
||||
// window; `updatedIds` may already be in the base list but their
|
||||
// keywords/mailboxIds need refreshing.
|
||||
const existingById = new Map(existing.map((e) => [e.id, e]));
|
||||
const existingById = new Map(baseEmails.map((e) => [e.id, e]));
|
||||
const idsToFetch = [
|
||||
...addedIds.filter((id) => !existingById.has(id)),
|
||||
...updatedIds.filter((id) => existingById.has(id)),
|
||||
@@ -705,7 +740,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
// Rebuild the visible window order: start with existing emails,
|
||||
// drop removed/destroyed, then splice added at their indices.
|
||||
const allDestroyed = new Set([...destroyedExtra, ...removed]);
|
||||
const kept = existing.filter((e) => !allDestroyed.has(e.id));
|
||||
const kept = baseEmails.filter((e) => !allDestroyed.has(e.id));
|
||||
// Map updated entries onto kept array
|
||||
const fetchedById = new Map(fetched.map((e) => [e.id, e]));
|
||||
const updatedKept = kept.map((e) => fetchedById.get(e.id) ?? e);
|
||||
@@ -727,7 +762,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
const nextQueryState = queryChanges.newQueryState;
|
||||
const nextTotal = queryChanges.total;
|
||||
|
||||
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
|
||||
if (viewChanged()) return;
|
||||
|
||||
set({
|
||||
emails: trimmed,
|
||||
@@ -758,7 +793,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
? await getEmailsWithState(queryRes.ids)
|
||||
: { list: [], state: undefined as string | undefined };
|
||||
|
||||
if (get().currentMailboxId !== currentMailboxId) return;
|
||||
if (viewChanged()) return;
|
||||
|
||||
const updates: Partial<EmailState> = {
|
||||
emails: fetched.list,
|
||||
@@ -836,19 +871,31 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
setSearchQuery: (query) => {
|
||||
set({ searchQuery: query });
|
||||
const state = get();
|
||||
const backToBase =
|
||||
!isBaseView(state.searchQuery, state.filters) && isBaseView(query, state.filters);
|
||||
set({
|
||||
searchQuery: query,
|
||||
...(backToBase ? restoredBaseView(state) : {}),
|
||||
});
|
||||
void get().refreshEmails();
|
||||
},
|
||||
|
||||
setFilters: (filters) => {
|
||||
set({ filters });
|
||||
const state = get();
|
||||
const backToBase =
|
||||
!isBaseView(state.searchQuery, state.filters) && isBaseView(state.searchQuery, filters);
|
||||
set({
|
||||
filters,
|
||||
...(backToBase ? restoredBaseView(state) : {}),
|
||||
});
|
||||
void get().refreshEmails();
|
||||
},
|
||||
|
||||
clearSearchAndFilters: () => {
|
||||
const { searchQuery, filters } = get();
|
||||
if (!searchQuery && Object.keys(filters).length === 0) return;
|
||||
set({ searchQuery: '', filters: {} });
|
||||
const state = get();
|
||||
if (!state.searchQuery && Object.keys(state.filters).length === 0) return;
|
||||
set({ searchQuery: '', filters: {}, ...restoredBaseView(state) });
|
||||
void get().refreshEmails();
|
||||
},
|
||||
|
||||
@@ -1330,6 +1377,42 @@ export const useEmailStore = create<EmailState>()(
|
||||
// refresh once the session is ready.
|
||||
name: 'email-cache',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
version: 1,
|
||||
// v0 → v1: drop every cached queryState. Pre-v1 builds could persist a
|
||||
// search-result list next to the base view's queryState, and the
|
||||
// incremental sync path would then "confirm" those search results as
|
||||
// the whole mailbox and bake them into the snapshot (issue #10).
|
||||
// Without a queryState the next refresh does a full re-query, which
|
||||
// rebuilds any poisoned window from the server.
|
||||
migrate: (persisted, version) => {
|
||||
if (version >= 1) return persisted as EmailState;
|
||||
const s = persisted as Pick<
|
||||
EmailState,
|
||||
'accountSnapshots' | 'mailboxSnapshots' | 'queryState'
|
||||
> & Record<string, unknown>;
|
||||
const stripQueryStates = (
|
||||
snaps: Record<string, MailboxSnapshot> | undefined,
|
||||
): Record<string, MailboxSnapshot> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(snaps ?? {}).map(([id, snap]) => [
|
||||
id,
|
||||
{ ...snap, queryState: undefined },
|
||||
]),
|
||||
);
|
||||
const accountSnapshots: Record<string, AccountSnapshot> = {};
|
||||
for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) {
|
||||
accountSnapshots[id] = {
|
||||
...acc,
|
||||
mailboxSnapshots: stripQueryStates(acc.mailboxSnapshots),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
accountSnapshots,
|
||||
mailboxSnapshots: stripQueryStates(s.mailboxSnapshots),
|
||||
queryState: undefined,
|
||||
} as unknown as EmailState;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
accountSnapshots: state.accountSnapshots,
|
||||
activeAccountId: state.activeAccountId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import Constants from 'expo-constants';
|
||||
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
||||
import { isNewer } from '../lib/version-compare';
|
||||
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
|
||||
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
||||
|
||||
const STORAGE_KEY = 'webmail:updates:v1';
|
||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
@@ -80,6 +81,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
},
|
||||
|
||||
checkNow: async (opts) => {
|
||||
// On platforms without sideloading there is nothing actionable to report,
|
||||
// so skip the GitHub round-trip entirely rather than caching a release the
|
||||
// user can never install.
|
||||
if (!supportsSideloadUpdates) return;
|
||||
const s = get();
|
||||
if (s.checking) return;
|
||||
const now = Date.now();
|
||||
@@ -104,6 +109,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
},
|
||||
|
||||
installLatest: async () => {
|
||||
if (!supportsSideloadUpdates) return;
|
||||
const s = get();
|
||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||
@@ -142,6 +148,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
|
||||
|
||||
hasUpdate: () => {
|
||||
if (!supportsSideloadUpdates) return false;
|
||||
const s = get();
|
||||
if (!s.cachedLatest) return false;
|
||||
const current = Constants.expoConfig?.version ?? '0.0.0';
|
||||
|
||||
Reference in New Issue
Block a user