Compare commits

...
38 Commits
Author SHA1 Message Date
Linus Rath e574fd9e11 chore: update version number to 0.1.56 2026-08-01 14:25:31 +02:00
Linus Rath dfa278f7a8 fix: sign iOS archives manually with an App Store provisioning profile 2026-08-01 13:47:49 +02:00
Linus Rath 4eeda34d95 fix: sign iOS archives with the distribution identity 2026-08-01 13:37:34 +02:00
Linus Rath 22ad291202 Merge branch 'main' of https://github.com/bulwarkmail/native 2026-08-01 13:29:18 +02:00
Linus Rath f040a48e2f feat: iOS TestFlight release pipeline 2026-08-01 13:29:15 +02:00
Linus Rath c29cbcd549 feat: step-by-step sign-in with server discovery from the email address 2026-08-01 13:28:31 +02:00
Linus Rath bf67b97176 Merge pull request #36 from lucletoffe/fix/unread-dot-anchor
fix(list): anchor the unread dot on the first line, not the row midpoint
2026-07-31 21:15:30 +02:00
lucletoffeandClaude Opus 5 a2484f3508 fix(list): anchor the unread dot on the first line, not the row midpoint
The dot added in #27 is positioned with `top: '50%'`, so it centres on the
whole row. Rows are three lines tall (sender / subject / preview), so the dot
lands a full line below the checkbox and the avatar it reads as a column
with — the taller the row, the worse it looks.

Anchor it on the first line instead: row padding + half an avatar, following
the density (and the text line height when the avatar is hidden in
extra-compact). The webmail's list carries the same `top-1/2` and the same
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FTRyrFCR2N9qkpKK37GgWb
2026-07-31 12:39:01 +02:00
Linus Rath 82997e27e4 docs: fix doubled slash in logo URLs 2026-07-25 18:23:17 +02:00
Linus Rath 802e1e1c71 fix: send-time editor readback, base-view restore after search, Stalwart recurrence #9, #10, #13 2026-07-22 22:17:40 +02:00
Linus Rath 2427bdbd79 fix: keep VTODO-only task lists out of the Calendars drawer #28
Stalwart exposes task lists (supported-calendar-component-set = VTODO)
as sibling CalDAV collections, so Calendar/get returns them mixed in
with event calendars and the drawer showed confusing duplicate entries
(Todoist Inbox, per-project task lists colliding with same-named
event calendars).

JMAP Calendar/get doesn't surface the CalDAV component set, so the
store now infers the collection kind from its contents: a calendar
whose objects are all Tasks (>=1 task, no events) is flagged as a task
list. Flagged calendars are excluded from the Calendars drawer, the
event-creation calendar picker, and the ICS import target picker, and
sorted first in the Tasks sheet so new tasks default into a dedicated
task list. Empty calendars and mixed event+task calendars are
untouched.
2026-07-22 21:37:27 +02:00
Linus Rath 05e2af23dd fix: add unread indicator dot to email list and unified inbox rows #27
The only unread cues were a font-weight bump and a grey-to-white shift
on the sender name, which barely render on some device fonts and are
nearly invisible in the dark theme. Mirror the webmail's dedicated
indicator (email-list-item.tsx): an 8px circle in the theme's existing
unread color, vertically centered in the row's start gutter, shown in
both the mailbox list and the unified inbox.

Verified on a Pixel 7 API 34 emulator in dark theme: unread rows are
unambiguous at a glance, read rows unchanged.
2026-07-21 20:17:41 +02:00
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b fix(auth): coalesce concurrent token refresh requests
Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation.

Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete.

Fixes #14
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
Linus Rath b92f85a236 chore: update version number to 0.1.51 2026-06-14 14:46:22 +02:00
Linus Rath 643d704b74 fix: namespace shared calendar ids to avoid collisions with own calendars 2026-06-14 14:46:15 +02:00
Linus Rath 9e383c3616 chore: update version number to 0.1.50 2026-06-14 13:43:36 +02:00
Linus Rath 1543db49d1 fix: match webmail event display logic 2026-06-14 13:43:27 +02:00
59 changed files with 5305 additions and 741 deletions
+274
View File
@@ -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."
+6 -5
View File
@@ -1,9 +1,9 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
</picture>
</div>
@@ -26,10 +26,10 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
## What's missing or rough
- iOS build is untested
- iOS builds, but push notifications and client certificates are Android-only so far
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
- Calendar editing is partial; contacts editing is basic
- No Play Store distribution yet (sideload APK from Releases)
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
## Run locally
@@ -41,6 +41,7 @@ npx expo start
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
## License
+1 -1
View File
@@ -1 +1 @@
0.1.49
0.1.56
+27
View File
@@ -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,
},
],
],
+159
View File
@@ -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.
+1
View File
@@ -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?",
+953
View File
@@ -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&nbsp;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>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.49",
"version": "0.1.56",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.49",
"version": "0.1.56",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.49",
"version": "0.1.56",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+93 -3
View File
@@ -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
View File
@@ -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(
+1
View File
@@ -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,
+9
View File
@@ -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;
+4 -1
View File
@@ -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
+205
View File
@@ -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,
},
});
}
+10 -6
View File
@@ -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,
+51 -273
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
ref,
@@ -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':
+2 -2
View File
@@ -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();
+1
View File
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
flexDirection: 'row',
+32 -26
View File
@@ -1,15 +1,16 @@
import React from 'react';
import { SectionList, StyleSheet, Text, View } from 'react-native';
import { format, isSameDay, isToday, isTomorrow } from 'date-fns';
import { addDays, format, isSameDay, isToday, isTomorrow, parseISO, startOfDay } from 'date-fns';
import { CalendarDays } from 'lucide-react-native';
import type { Calendar, CalendarEvent } from '../../api/types';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
dayKey,
eventsOnDayFromIndex,
getEventStartDate,
type EventDayIndex,
type TimeFormat,
} from '../../lib/calendar-utils';
import { EventCard } from './EventCard';
@@ -19,6 +20,7 @@ interface AgendaViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
}
@@ -40,6 +42,7 @@ export function AgendaView({
events,
calendars,
eventsByDay,
timeFormat,
onSelectEvent,
}: AgendaViewProps) {
const c = useColors();
@@ -50,32 +53,35 @@ export function AgendaView({
);
const sections = React.useMemo<DaySection[]>(() => {
const result: DaySection[] = [];
const start = new Date(fromDate);
start.setHours(0, 0, 0, 0);
// Mirror webmail's agenda (calendar-agenda-view.tsx): build a group for
// every day that actually has events, always include a "Today" anchor, and
// render them sorted ascending. Driving the list off the events (rather than
// a fixed day-counter) guarantees every upcoming loaded event appears - not
// just the ones inside a brittle N-day window from the anchor.
const anchor = startOfDay(fromDate);
const horizonEnd = addDays(anchor, daysAhead); // safety bound (exclusive)
for (let i = 0; i < daysAhead; i++) {
const day = new Date(start);
day.setDate(start.getDate() + i);
const dayEvents = eventsOnDayFromIndex(index, day).slice().sort((a, b) => {
// All-day events first within a day, then by start time, then title
// (stable tie-break) - mirrors the comparator the webmail uses for
// packing week segments and keeps multi-event days predictable.
if (a.showWithoutTime !== b.showWithoutTime) {
return a.showWithoutTime ? -1 : 1;
}
const diff = getEventStartDate(a).getTime() - getEventStartDate(b).getTime();
if (diff !== 0) return diff;
return (a.title || '').localeCompare(b.title || '');
});
if (dayEvents.length === 0 && !isToday(day)) continue;
result.push({
const days = new Map<string, Date>();
for (const key of index.keys()) {
const day = parseISO(key); // index keys are local yyyy-MM-dd
if (isNaN(day.getTime())) continue;
if (day < anchor || day >= horizonEnd) continue;
days.set(key, day);
}
// Always anchor "Today" so the view has a stable starting point even when
// the current day has no events (webmail injects the same anchor).
const today = startOfDay(new Date());
if (today >= anchor && today < horizonEnd) days.set(dayKey(today), today);
return [...days.values()]
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => ({
title: formatDayHeader(day),
date: day,
data: dayEvents,
});
}
return result;
// Buckets are pre-sorted by buildEventDayIndex (all-day first, then by
// start, then title), so no extra sort is needed here.
data: eventsOnDayFromIndex(index, day),
}));
}, [fromDate, daysAhead, index]);
if (sections.length === 0) {
@@ -107,7 +113,7 @@ export function AgendaView({
}}
renderItem={({ item }) => (
<View style={styles.itemWrap}>
<EventCard event={item} calendars={calendars} onPress={onSelectEvent} />
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
</View>
)}
renderSectionFooter={({ section }) =>
+12 -5
View File
@@ -5,11 +5,17 @@ import { format } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { eventTimeRange, getEventColor } from '../../lib/calendar-utils';
import {
eventTimeRange,
getEventColor,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
interface EventCardProps {
event: CalendarEvent;
calendars: Calendar[];
timeFormat?: TimeFormat;
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
}
@@ -18,17 +24,18 @@ function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0;
}
function formatTimeRange(event: CalendarEvent): string {
function formatTimeRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) return 'All day';
return `${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
const fmt = timePattern(timeFormat);
return `${format(start, fmt)} – ${format(end, fmt)}`;
}
export function EventCard({ event, calendars, onPress, onLongPress }: EventCardProps) {
export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const color = getEventColor(event, calendars);
const time = formatTimeRange(event);
const time = formatTimeRange(event, timeFormat);
const count = participantCount(event);
return (
+9 -4
View File
@@ -36,6 +36,8 @@ import {
eventTimeRange,
getEventColor,
getPrimaryCalendarId,
timePattern,
type TimeFormat,
} from '../../lib/calendar-utils';
import { alertsToReminders, formatReminder } from '../../lib/calendar-alerts';
import {
@@ -50,6 +52,7 @@ type RsvpStatus = 'accepted' | 'declined' | 'tentative';
interface EventDetailSheetProps {
event: CalendarEvent | null;
calendars: Calendar[];
timeFormat?: TimeFormat;
onClose: () => void;
onEdit?: (event: CalendarEvent) => void;
onDelete?: (event: CalendarEvent) => void;
@@ -57,7 +60,7 @@ interface EventDetailSheetProps {
onRsvp?: (event: CalendarEvent, participantId: string, status: RsvpStatus) => void | Promise<void>;
}
function formatRange(event: CalendarEvent): string {
function formatRange(event: CalendarEvent, timeFormat?: TimeFormat): string {
const { start, end, allDay } = eventTimeRange(event);
if (allDay) {
if (
@@ -69,14 +72,15 @@ function formatRange(event: CalendarEvent): string {
}
return `${format(start, 'MMM d')} – ${format(end, 'MMM d, yyyy')}`;
}
const t = timePattern(timeFormat);
const sameDay =
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate();
if (sameDay) {
return `${format(start, 'EEE, MMM d')} · ${format(start, 'HH:mm')} – ${format(end, 'HH:mm')}`;
return `${format(start, 'EEE, MMM d')} · ${format(start, t)} – ${format(end, t)}`;
}
return `${format(start, 'MMM d, HH:mm')} – ${format(end, 'MMM d, HH:mm')}`;
return `${format(start, `MMM d, ${t}`)} – ${format(end, `MMM d, ${t}`)}`;
}
function recurrenceLabel(event: CalendarEvent): string | null {
@@ -100,6 +104,7 @@ function recurrenceLabel(event: CalendarEvent): string | null {
export function EventDetailSheet({
event,
calendars,
timeFormat,
onClose,
onEdit,
onDelete,
@@ -156,7 +161,7 @@ export function EventDetailSheet({
const color = getEventColor(event, calendars);
const calendarId = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === calendarId);
const range = formatRange(event);
const range = formatRange(event, timeFormat);
const recurrence = recurrenceLabel(event);
const reminders = alertsToReminders(event.alerts);
const participants = event.participants ? Object.values(event.participants) : [];
+2
View File
@@ -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';
+29
View File
@@ -7,6 +7,9 @@ import {
normalizeAllDayDuration,
buildAllDayDuration,
getCalendarColor,
getEventStartDate,
getEventEndDate,
timePattern,
layoutOverlappingEvents,
CALENDAR_COLOR_PALETTE,
} from '../calendar-utils';
@@ -51,6 +54,32 @@ describe('parseLocalDateTime', () => {
});
});
describe('getEventStartDate / getEventEndDate malformed utc fallback', () => {
// Mirrors webmail (#316): a malformed utcStart/utcEnd must fall back to the
// floating `start` + duration instead of producing an Invalid Date, which
// would silently drop the event from the day index and every view.
it('falls back to start when utcStart is unparseable', () => {
const d = getEventStartDate(ev({ utcStart: 'garbage' }));
expect(isNaN(d.getTime())).toBe(false);
expect(d.getHours()).toBe(9);
});
it('falls back to start + duration when utcEnd is unparseable', () => {
const end = getEventEndDate(ev({ utcEnd: 'garbage', duration: 'PT1H' }));
expect(isNaN(end.getTime())).toBe(false);
const start = getEventStartDate(ev({}));
expect(end.getTime() - start.getTime()).toBe(60 * 60 * 1000);
});
});
describe('timePattern', () => {
it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => {
expect(timePattern('12h')).toBe('h:mm a');
expect(timePattern('24h')).toBe('HH:mm');
expect(timePattern(undefined)).toBe('HH:mm');
});
});
describe('eventTimeRange', () => {
it('returns start, end, allDay for a timed event with utcStart/utcEnd', () => {
const range = eventTimeRange(ev({
+58
View File
@@ -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 })");
});
});
+59
View File
@@ -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');
});
});
+89
View File
@@ -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 };
+184
View File
@@ -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();
});
});
+21 -3
View File
@@ -35,13 +35,20 @@ export function parseLocalDateTime(iso: string): Date {
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start;
return parseISO(source);
// Prefer utcStart for timed events but fall back to start if utcStart is
// missing or unparseable — a malformed utcStart used to surface as an
// Invalid Date that silently dropped the event from every view (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
}
return parseISO(event.start);
}
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
return parseISO(event.utcEnd);
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
}
const start = getEventStartDate(event);
if (!event.duration) return start;
@@ -57,6 +64,17 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date {
return subMilliseconds(end, 1);
}
// ─── Time-of-day formatting ──────────────────────────────
// Maps the user's 12h/24h preference to a date-fns pattern, matching webmail
// (event-card.tsx: `timeFormat === "12h" ? "h:mm a" : "HH:mm"`). Components
// thread the `calendarTimeFormat` setting through so the calendar renders
// times in the same format the webmail does.
export type TimeFormat = '12h' | '24h';
export function timePattern(timeFormat: TimeFormat | undefined): string {
return timeFormat === '12h' ? 'h:mm a' : 'HH:mm';
}
export interface EventTimeRange {
start: Date;
end: Date;
+292
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
}
reportChange();
reportHeight();
reportSelection();
},
unsetLink: function () {
editor.focus();
try { document.execCommand('unlink', false, null); } catch (e) {}
reportChange();
reportSelection();
},
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
// 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>`;
}
+102
View File
@@ -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.',
};
}
+33
View File
@@ -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
View File
@@ -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();
+16
View File
@@ -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';
+76 -15
View File
@@ -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({
+170
View File
@@ -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;
}
+51 -9
View File
@@ -58,6 +58,7 @@ import {
pickUnusedCalendarColor,
sharedCalendarColorKey,
type EventDayIndex,
type TimeFormat,
} from '../lib/calendar-utils';
import { buildReplyTo } from '../lib/calendar-invitation';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
@@ -162,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);
@@ -217,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],
@@ -291,25 +315,34 @@ export default function CalendarScreen() {
[],
);
// A recurring series member is a master (has recurrenceRules) or an expanded
// occurrence (has recurrenceId) — those get the this/all scope dialog. Note:
// we must NOT key off originalId here, since shared/group events now carry it
// for id-namespacing even when they aren't recurring.
const isRecurringSeriesMember = React.useCallback(
(event: CalendarEvent) => !!event.recurrenceRules?.length || !!event.recurrenceId,
[],
);
const handleEditFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'edit', event });
} else {
openEditDirect(event);
}
}, [openEditDirect, isReadOnlyEvent]);
}, [openEditDirect, isReadOnlyEvent, isRecurringSeriesMember]);
const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => {
if (isReadOnlyEvent(event)) { setDetailEvent(null); return; }
setDetailEvent(null);
if (event.recurrenceRules?.length || event.originalId) {
if (isRecurringSeriesMember(event)) {
setPendingAction({ kind: 'delete', event });
} else {
void deleteEvent(event.id);
}
}, [deleteEvent, isReadOnlyEvent]);
}, [deleteEvent, isReadOnlyEvent, isRecurringSeriesMember]);
const handleScopeSelect = React.useCallback(
async (scope: RecurrenceEditScope) => {
@@ -459,6 +492,7 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
onSelectEvent={setDetailEvent}
/>
)}
@@ -475,6 +509,7 @@ export default function CalendarScreen() {
date={selectedDate}
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
onSelectEvent={setDetailEvent}
refreshing={refreshing}
onRefresh={onRefresh}
@@ -486,6 +521,7 @@ export default function CalendarScreen() {
<EventDetailSheet
event={detailEvent}
calendars={calendars}
timeFormat={calendarTimeFormat}
onClose={() => setDetailEvent(null)}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -508,7 +544,7 @@ export default function CalendarScreen() {
<EventModal
visible={modalVisible}
event={modalEvent}
calendars={allCalendars}
calendars={eventCalendars}
defaultDate={modalDate}
onSave={handleSave}
onDelete={handleDeleteFromModal}
@@ -524,7 +560,7 @@ export default function CalendarScreen() {
<CalendarSidebarDrawer
visible={sidebarVisible}
calendars={allCalendars}
calendars={eventCalendars}
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
@@ -542,7 +578,7 @@ export default function CalendarScreen() {
<TasksSheet
visible={tasksVisible}
tasks={tasks}
calendars={allCalendars}
calendars={taskSheetCalendars}
onClose={() => setTasksVisible(false)}
onCreate={createTask}
onToggle={toggleTaskComplete}
@@ -552,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}
/>
@@ -570,6 +609,7 @@ function DayEventList({
date,
eventsByDay,
calendars,
timeFormat,
onSelectEvent,
refreshing,
onRefresh,
@@ -577,6 +617,7 @@ function DayEventList({
date: Date;
eventsByDay: EventDayIndex;
calendars: Calendar[];
timeFormat?: TimeFormat;
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
@@ -613,6 +654,7 @@ function DayEventList({
key={event.id}
event={event}
calendars={calendars}
timeFormat={timeFormat}
onPress={onSelectEvent}
/>
))}
+129 -35
View File
@@ -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: {
+47 -6
View File
@@ -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,
+11 -2
View File
@@ -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
View File
@@ -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 },
});
}
+9 -1
View File
@@ -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);
}
+14
View File
@@ -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',
+184
View File
@@ -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&apos;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 },
});
}
+119
View File
@@ -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&apos;ll sign in on your provider&apos;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 },
});
}
+87
View File
@@ -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&apos;s usually all we need — we&apos;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 },
});
}
+52
View File
@@ -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 },
});
}
+107
View File
@@ -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 },
});
}
+91
View File
@@ -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 },
});
}
+98
View File
@@ -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 },
});
}
+88
View File
@@ -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&apos;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 },
});
}
+102
View File
@@ -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 },
});
}
+101
View File
@@ -45,6 +45,8 @@ beforeEach(() => {
useCalendarStore.setState({
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -101,6 +103,105 @@ describe('calendar-store', () => {
expect(useCalendarStore.getState().events).toEqual([]);
expect(mockGetEvents).not.toHaveBeenCalled();
});
it('namespaces shared/group event ids by owning account and keeps the real id in originalId', async () => {
// A shared calendar lives in another account; its event id ("ev1") can
// collide with one of the user's own events, so the store must namespace
// it. Mirrors webmail's mapServerEventToStoreEvent.
useCalendarStore.setState({
calendars: [
{ id: 'cal-1', name: 'Personal' } as any,
{ id: 'shared-cal', name: 'Team', accountId: 'acc-2', isShared: true } as any,
],
});
mockQueryEvents.mockImplementation(async (_ids: string[], _a: string, _b: string, accountId?: string) =>
accountId === 'acc-2' ? ['ev1'] : [],
);
mockGetEvents.mockImplementation(async (_ids: string[], accountId?: string) =>
accountId === 'acc-2'
? [{ id: 'ev1', title: 'Standup', start: '2026-03-15T10:00:00', calendarIds: { 'shared-cal': true } }]
: [],
);
await useCalendarStore.getState().fetchEvents(['cal-1', 'shared-cal'], '2026-03-01', '2026-03-31');
const stored = useCalendarStore.getState().events;
expect(stored).toHaveLength(1);
expect(stored[0].id).toBe('acc-2:ev1');
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', () => {
+144
View File
@@ -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>' } } };
+95 -16
View File
@@ -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,7 +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)) ?? [];
raw.push(...(accountId ? fetched.map((e) => ({ ...e, accountId })) : 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;
@@ -166,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' });
}
@@ -225,15 +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,
);
set({ events: [...get().events, accountId ? { ...created, accountId } : created] });
// 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;
},
@@ -285,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;
},
@@ -311,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 };
@@ -326,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();
},
@@ -379,6 +456,7 @@ export const useCalendarStore = create<CalendarState>()(
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
loadedRange: null,
loading: false,
error: null,
@@ -394,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
View File
@@ -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,
+7
View File
@@ -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';