mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9857fe1711 | ||
|
|
b4331a9bf9 | ||
|
|
c08e9d6fa8 | ||
|
|
470e7fc3ba | ||
|
|
b3bd19cc8c | ||
|
|
95cfa809ef | ||
|
|
3d3f4aec5a | ||
|
|
8303ff769f | ||
|
|
7078dfe806 | ||
|
|
f62849d1fe | ||
|
|
4bb846b726 | ||
|
|
2618680b21 | ||
|
|
533f176e88 | ||
|
|
da72415ef3 | ||
|
|
0109273037 | ||
|
|
33eb8f99e5 | ||
|
|
721c2e34a1 | ||
|
|
e06d92d88f | ||
|
|
42e89122bd | ||
|
|
33fc33910a | ||
|
|
dde504f825 | ||
|
|
f5f3637d01 | ||
|
|
bdade8503d | ||
|
|
57ca1c8083 | ||
|
|
32a41f42d3 | ||
|
|
ad9db5cbff | ||
|
|
99e4bd9614 | ||
|
|
239bf618b5 | ||
|
|
58e52421e9 | ||
|
|
96ffeaa0e2 | ||
|
|
81a0723abd | ||
|
|
6103df86a5 | ||
|
|
b97a877fce | ||
|
|
663f6f0007 | ||
|
|
5477cc039a | ||
|
|
ab8e8d7e1e | ||
|
|
49e57482cc | ||
|
|
5f06c3918c | ||
|
|
96471cf13b | ||
|
|
3575ed9450 | ||
|
|
f181c73a56 | ||
|
|
c85e32c9f4 | ||
|
|
f2b98c6dba | ||
|
|
653d5965ed | ||
|
|
01d15fd24f | ||
|
|
35e44318b9 | ||
|
|
3bdafefb65 | ||
|
|
982a23cafd |
@@ -44,7 +44,8 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: e2e-ios-${{ github.ref }}
|
# Per branch and set of legs: a perf run doesn't cancel a feature run.
|
||||||
|
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -121,6 +122,11 @@ jobs:
|
|||||||
- name: Build for the simulator
|
- name: Build for the simulator
|
||||||
env:
|
env:
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
# The bundle times each module's evaluation for the perf leg, and
|
||||||
|
# Metro writes which id is which module (metro.config.js; a branch
|
||||||
|
# without that support ignores both).
|
||||||
|
BULWARK_MODULE_TIMINGS: '1'
|
||||||
|
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
|
||||||
run: |
|
run: |
|
||||||
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||||
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||||
@@ -155,7 +161,13 @@ jobs:
|
|||||||
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
|
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
|
||||||
# Packed as a tar: artifacts drop the executable bit, which would
|
# Packed as a tar: artifacts drop the executable bit, which would
|
||||||
# leave a reused app unable to launch.
|
# leave a reused app unable to launch.
|
||||||
tar -C build/Build/Products/Release-iphonesimulator -czf "$RUNNER_TEMP/simulator-app.tar.gz" BulwarkMobile.app
|
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
|
||||||
|
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
|
||||||
|
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
|
||||||
|
else
|
||||||
|
echo "no module map (branch without module timings)"
|
||||||
|
fi
|
||||||
|
gzip -f "$RUNNER_TEMP/simulator-app.tar"
|
||||||
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
|
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||||
|
|
||||||
- name: Upload the simulator app
|
- name: Upload the simulator app
|
||||||
@@ -325,7 +337,7 @@ jobs:
|
|||||||
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
|
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
|
||||||
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
|
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
|
||||||
fi
|
fi
|
||||||
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 3 \
|
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
|
||||||
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
|
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
|
||||||
|
|
||||||
- name: Upload results
|
- name: Upload results
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ jobs:
|
|||||||
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||||
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -450,11 +451,11 @@ jobs:
|
|||||||
${{ steps.artifact.outputs.mapping }}
|
${{ steps.artifact.outputs.mapping }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set. Play only accepts API
|
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||||
# uploads after the first bundle was uploaded by hand in the Console, and
|
# uploads after the first bundle was uploaded by hand in the Console).
|
||||||
# releases of an app that was never published must be drafts; switch
|
# Every release goes out to the internal and closed (beta) testers right
|
||||||
# `status` to `completed` after the first production release.
|
# away; closed testing may wait for Google's review first.
|
||||||
- name: Upload to Google Play (internal testing, draft)
|
- name: Upload to Google Play (internal and closed testing)
|
||||||
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: r0adkll/upload-google-play@v1
|
||||||
with:
|
with:
|
||||||
@@ -462,5 +463,6 @@ jobs:
|
|||||||
packageName: org.bulwarkmail.mobile
|
packageName: org.bulwarkmail.mobile
|
||||||
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||||
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||||
track: internal
|
releaseName: ${{ steps.artifact.outputs.version }}
|
||||||
status: draft
|
tracks: internal,beta
|
||||||
|
status: completed
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ tags:
|
|||||||
visible: ".*q3-report.pdf.*"
|
visible: ".*q3-report.pdf.*"
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
- takeScreenshot: read-04-attachment
|
- takeScreenshot: read-04-attachment
|
||||||
- tapOn: ".*q3-report.pdf.*"
|
# The message's attachment chip, labelled with the file name alone (on the
|
||||||
|
# iPad the list row next to it mentions the name in its longer label).
|
||||||
|
- tapOn: "q3-report.pdf"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Share"
|
visible: "Share"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
@@ -53,9 +55,11 @@ tags:
|
|||||||
- takeScreenshot: read-05-attachment-preview
|
- takeScreenshot: read-05-attachment-preview
|
||||||
- assertVisible: "q3-report.pdf"
|
- assertVisible: "q3-report.pdf"
|
||||||
- tapOn: "Close"
|
- tapOn: "Close"
|
||||||
|
# Back on the message (the iPad pane has a quick reply instead of Reply All).
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Reply All"
|
notVisible: "Share"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
|
- assertVisible: ".*q3-report.pdf.*"
|
||||||
- runFlow: ../subflows/close-message.yaml
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
# A thread (Projects/Alpha)
|
# A thread (Projects/Alpha)
|
||||||
|
|||||||
@@ -21,13 +21,12 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/compose-body.yaml
|
file: ../subflows/compose-body.yaml
|
||||||
env:
|
env:
|
||||||
|
FROM_SUBJECT: "yes"
|
||||||
BODY: "Hello from the iOS simulator test."
|
BODY: "Hello from the iOS simulator test."
|
||||||
- takeScreenshot: compose-01-filled
|
- takeScreenshot: compose-01-filled
|
||||||
|
|
||||||
# The attach sheet offers its options one by one (it used to be a single
|
# The attach sheet offers its options one by one (it used to be a single
|
||||||
# accessibility element). The system photo and file pickers run in another
|
# accessibility element, and picking one opened no picker on iOS).
|
||||||
# process that XCTest can't drive, so a picked attachment is covered by the
|
|
||||||
# forward in 04-incoming-reply-forward instead.
|
|
||||||
- tapOn: "Attach"
|
- tapOn: "Attach"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Photos & Videos"
|
visible: "Photos & Videos"
|
||||||
@@ -35,28 +34,54 @@ tags:
|
|||||||
- assertVisible: "Camera"
|
- assertVisible: "Camera"
|
||||||
- assertVisible: "Files"
|
- assertVisible: "Files"
|
||||||
- takeScreenshot: compose-02-attach-sheet
|
- takeScreenshot: compose-02-attach-sheet
|
||||||
- tapOn: "Photos & Videos"
|
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
|
||||||
- waitForAnimationToEnd:
|
# The system photo picker runs in another process: XCTest sees none of its
|
||||||
timeout: 5000
|
# elements, but taps by position reach it. Pick the first photo (the app
|
||||||
- takeScreenshot: compose-03-photo-picker
|
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
|
||||||
# Dismiss the picker sheet.
|
# landscape lays the picker out differently, so it only closes the sheet.
|
||||||
- swipe:
|
|
||||||
start: 50%,12%
|
|
||||||
end: 50%,95%
|
|
||||||
duration: 400
|
|
||||||
- waitForAnimationToEnd:
|
|
||||||
timeout: 3000
|
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
notVisible: "New Message"
|
true: ${!output.pickPhoto}
|
||||||
commands:
|
commands:
|
||||||
- swipe:
|
- tapOn: "Cancel"
|
||||||
start: 50%,12%
|
- runFlow:
|
||||||
end: 50%,95%
|
when:
|
||||||
duration: 400
|
true: ${output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Photos & Videos"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Draft saved|New Message"
|
||||||
|
timeout: 10000
|
||||||
|
optional: true
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-03-photo-picker
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "44%,33%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "90%,26%"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "16%,25%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "90%,13%"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "New Message"
|
visible: "New Message"
|
||||||
timeout: 10000
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-04-with-photo
|
||||||
|
|
||||||
- tapOn: "Send"
|
- tapOn: "Send"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
@@ -67,3 +92,10 @@ tags:
|
|||||||
file: ../subflows/assert-delivered.yaml
|
file: ../subflows/assert-delivered.yaml
|
||||||
env:
|
env:
|
||||||
SUBJECT: ${output.subject}
|
SUBJECT: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.attachments.length >= 1}
|
||||||
|
label: "${output.subject} carries the picked photo"
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ tags:
|
|||||||
- features
|
- features
|
||||||
---
|
---
|
||||||
- runFlow: ../subflows/signed-in.yaml
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*Grüße aus München.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
timeout: 20000
|
||||||
- swipe:
|
- swipe:
|
||||||
from: ".*Grüße aus München.*"
|
from: ".*Grüße aus München.*"
|
||||||
direction: LEFT
|
direction: LEFT
|
||||||
duration: 400
|
duration: 400
|
||||||
- extendedWaitUntil:
|
# The undo bar shows for 5 s; when it goes, the Compose button slides into
|
||||||
visible: "Email archived"
|
# its place, and a late tap on UNDO opens the composer. So tap at once and
|
||||||
timeout: 10000
|
# check the rest afterwards.
|
||||||
- takeScreenshot: swipe-01-archived
|
- tapOn:
|
||||||
- assertNotVisible: ".*Grüße aus München.*"
|
text: "UNDO"
|
||||||
- tapOn: "UNDO"
|
waitToSettleTimeoutMs: 500
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: ".*Grüße aus München.*"
|
visible: ".*Grüße aus München.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ tags:
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Move to..."
|
visible: "Move to..."
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "Projects"
|
- tapOn:
|
||||||
- extendedWaitUntil:
|
text: "Projects"
|
||||||
visible: "Moved to Projects"
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
timeout: 15000
|
waitToSettleTimeoutMs: 500
|
||||||
- takeScreenshot: actions-02-moved
|
- takeScreenshot: actions-02-moved
|
||||||
- tapOn: "UNDO"
|
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
- runFlow: ../subflows/close-message.yaml
|
- runFlow: ../subflows/close-message.yaml
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: ".*Weekly digest.*"
|
visible: ".*Weekly digest.*"
|
||||||
@@ -38,27 +42,34 @@ tags:
|
|||||||
env:
|
env:
|
||||||
SUBJECT: Weekly digest
|
SUBJECT: Weekly digest
|
||||||
- takeScreenshot: actions-03a-before-delete
|
- takeScreenshot: actions-03a-before-delete
|
||||||
- tapOn: "Delete"
|
- tapOn:
|
||||||
- waitForAnimationToEnd:
|
text: "Delete"
|
||||||
timeout: 3000
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
- takeScreenshot: actions-03b-after-delete
|
waitToSettleTimeoutMs: 500
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "Email moved to Trash"
|
|
||||||
timeout: 15000
|
|
||||||
- takeScreenshot: actions-03-deleted
|
- takeScreenshot: actions-03-deleted
|
||||||
- tapOn: "UNDO"
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
- runFlow: ../subflows/close-message.yaml
|
- runFlow: ../subflows/close-message.yaml
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: ".*Weekly digest.*"
|
visible: ".*Weekly digest.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|
||||||
# Select from the list and star, then unstar
|
# Select from the list and star, then unstar. On a phone the row can be
|
||||||
|
# below the fold (earlier runs left mail on top).
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*Grüße aus München.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
timeout: 20000
|
||||||
- longPressOn: ".*Grüße aus München.*"
|
- longPressOn: ".*Grüße aus München.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Clear selection"
|
visible: "Clear selection"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- takeScreenshot: actions-04-selected
|
- takeScreenshot: actions-04-selected
|
||||||
- tapOn: "Star"
|
# Star (or Unstar, when an earlier run left it starred), then toggle back.
|
||||||
|
- tapOn: "Star|Unstar"
|
||||||
# A bulk action ends selection mode by itself.
|
# A bulk action ends selection mode by itself.
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ tags:
|
|||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/compose-body.yaml
|
file: ../subflows/compose-body.yaml
|
||||||
env:
|
env:
|
||||||
|
FROM_SUBJECT: "yes"
|
||||||
BODY: "Scheduled from the iOS simulator test."
|
BODY: "Scheduled from the iOS simulator test."
|
||||||
- tapOn: "Schedule send"
|
- tapOn: "Schedule send"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
|
|||||||
@@ -12,13 +12,7 @@ tags:
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
- runFlow:
|
- runFlow: ../subflows/allow-notifications.yaml
|
||||||
when:
|
|
||||||
visible: ".*Would Like to Send You Notifications.*"
|
|
||||||
commands:
|
|
||||||
- tapOn:
|
|
||||||
text: "Allow"
|
|
||||||
optional: true
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Go to today"
|
visible: "Go to today"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
@@ -47,16 +41,33 @@ tags:
|
|||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "Title"
|
- tapOn: "Title"
|
||||||
- inputText: ${output.event}
|
- inputText: ${output.event}
|
||||||
|
# Close the keyboard first: a tap while it slides away lands on a form that
|
||||||
|
# is still moving.
|
||||||
|
- pressKey: Enter
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
- scrollUntilVisible:
|
- scrollUntilVisible:
|
||||||
element: "Add reminder"
|
element: "Add reminder"
|
||||||
direction: DOWN
|
direction: DOWN
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- tapOn: "Add reminder"
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "15 minutes before"
|
||||||
|
times: 2
|
||||||
|
commands:
|
||||||
|
- tapOn: "Add reminder"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "15 minutes before"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
- tapOn: "15 minutes before"
|
- tapOn: "15 minutes before"
|
||||||
- takeScreenshot: calendar-05-editor
|
- takeScreenshot: calendar-05-editor
|
||||||
- tapOn: "Save"
|
- tapOn: "Save"
|
||||||
|
# The editor closes (its Save goes; the FAB is labelled "New event" too).
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "New event"
|
notVisible: "Save"
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
|
|
||||||
# Find it in the schedule and open it
|
# Find it in the schedule and open it
|
||||||
|
|||||||
@@ -44,7 +44,14 @@ tags:
|
|||||||
timeout: 20000
|
timeout: 20000
|
||||||
- takeScreenshot: files-04-folder-created
|
- takeScreenshot: files-04-folder-created
|
||||||
- longPressOn: ".*${output.folder}.*"
|
- longPressOn: ".*${output.folder}.*"
|
||||||
- tapOn: "Delete"
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Delete this item.*"
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Delete this item.*"
|
visible: "Delete this item.*"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
|
|||||||
@@ -12,27 +12,27 @@ tags:
|
|||||||
timeout: 20000
|
timeout: 20000
|
||||||
- takeScreenshot: settings-00-list
|
- takeScreenshot: settings-00-list
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", PROOF: "Date Format"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", PROOF: "Email Notifications"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", PROOF: "Choose your preferred color scheme"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", PROOF: "Tune the email list interactions for mobile."}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", PROOF: "Mark as Read"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", PROOF: "Composer"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", PROOF: "Sending Identities"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", PROOF: "Reply automatically"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", PROOF: "Email Filters"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", PROOF: "Email Templates"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", PROOF: ".*Inbox.*"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "Folders", PROOF: ".*Inbox.*"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", PROOF: "Email Tags"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", PROOF: "Email .*eml.* filename"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", PROOF: "Account Security|Detecting server capabilities.*"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", PROOF: "External Content"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content", PROOF: "External Content"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", PROOF: "Default view"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Calendar", PROOF: "Default view"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", PROOF: "Group by first letter"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Contacts", PROOF: "Group by first letter"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", PROOF: "Default View"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", PROOF: "Keep Apps Loaded"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", PROOF: "The default light and dark palettes."}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "The default light and dark palettes."}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", PROOF: "Offline mail.*"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", PROOF: "This device"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: tab-mail
|
id: tab-mail
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
# Push on iOS: the relay's payload carries only ids; the notification service
|
# Push on iOS: the relay's payload carries only ids and a placeholder text.
|
||||||
# extension (plugins/ios-push) fetches sender and subject before the banner
|
# A tap opens the message, with the app running and from a cold start. The
|
||||||
# shows. A tap opens the message, with the app running and from a cold start.
|
# helper sends the message as userb and pushes the relay's payload with
|
||||||
# The helper sends the message as userb and pushes with simctl, so this needs
|
# simctl, so this needs no APNs key.
|
||||||
# no APNs key.
|
#
|
||||||
|
# simctl push hands the payload to the simulator as a local notification
|
||||||
|
# request: the notification service extension (plugins/ios-push), which
|
||||||
|
# puts sender and subject in, never runs that way (the log shows no
|
||||||
|
# NotificationService process). So the banner shows the placeholder here;
|
||||||
|
# the extension needs a real APNs push to be tested.
|
||||||
appId: org.bulwarkmail.mobile
|
appId: org.bulwarkmail.mobile
|
||||||
name: Push notification on iOS
|
name: Push notification on iOS
|
||||||
tags:
|
tags:
|
||||||
@@ -30,38 +35,43 @@ tags:
|
|||||||
timeout: 3000
|
timeout: 3000
|
||||||
- pressKey: Home
|
- pressKey: Home
|
||||||
|
|
||||||
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
|
# 1. The banner shows (and what it says).
|
||||||
|
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
|
||||||
- runScript:
|
- runScript:
|
||||||
file: ../scripts/helper.js
|
file: ../scripts/helper.js
|
||||||
env:
|
env:
|
||||||
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject)}
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
|
||||||
# The banner shows for a few seconds: catch it with either text, then check.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: ".*${output.subject}.*|.*New message.*"
|
visible: ".*${output.subject0}.*|.*New message.*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
- takeScreenshot: push-01-banner
|
- takeScreenshot: push-01-banner
|
||||||
# Sender and subject come from the extension, not the relay.
|
# Let it go, so the next tap can't hit this one.
|
||||||
- assertVisible: ".*User B.*"
|
- extendedWaitUntil:
|
||||||
- assertNotVisible: ".*New message.*"
|
notVisible: ".*${output.subject0}.*|.*New message.*"
|
||||||
- tapOn: ".*${output.subject}.*"
|
timeout: 20000
|
||||||
|
|
||||||
|
# 2. A tap opens the message, app in the background. The banner shows for a
|
||||||
|
# few seconds and finding it takes Maestro about as long; taps on the lock
|
||||||
|
# screen list do nothing on these simulators. So tap where the banner
|
||||||
|
# appears, right after the push (push-and-tap.yaml).
|
||||||
|
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/push-and-tap.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject}
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Reply All"
|
visible: "Reply All"
|
||||||
timeout: 60000
|
timeout: 60000
|
||||||
- assertVisible: ".*${output.subject}.*"
|
- assertVisible: ".*${output.subject}.*"
|
||||||
- takeScreenshot: push-02-opened-warm
|
- takeScreenshot: push-02-opened-warm
|
||||||
|
|
||||||
# Cold start: the app isn't running when the notification is tapped.
|
# 3. The same from a cold start: the app isn't running.
|
||||||
- stopApp
|
- stopApp
|
||||||
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
||||||
- runScript:
|
- runFlow:
|
||||||
file: ../scripts/helper.js
|
file: ../subflows/push-and-tap.yaml
|
||||||
env:
|
env:
|
||||||
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject2)}
|
SUBJECT: ${output.subject2}
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: ".*${output.subject2}.*"
|
|
||||||
timeout: 30000
|
|
||||||
- takeScreenshot: push-03-banner-cold
|
|
||||||
- tapOn: ".*${output.subject2}.*"
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Reply All"
|
visible: "Reply All"
|
||||||
timeout: 90000
|
timeout: 90000
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# The app asks for notification permission now and then (calendar
|
||||||
|
# reminders, push). The system alert covers the whole app, so XCTest sees
|
||||||
|
# nothing else until it is answered: allow it when it is up. Taps on system
|
||||||
|
# alerts miss in landscape, so answer it in portrait.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- setOrientation: PORTRAIT
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
# In the composer: type BODY into the editor. The rich editor is a WebView
|
# In the composer: type BODY into the editor. The rich editor is a WebView
|
||||||
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
|
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
|
||||||
# the screen, which is the body area below Subject (above the keyboard).
|
# the screen, which is the body area below Subject (above the keyboard).
|
||||||
|
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
|
||||||
|
# to the body (an iPad in landscape shows hardly any body with the keyboard
|
||||||
|
# up, so there is nothing to tap).
|
||||||
appId: org.bulwarkmail.mobile
|
appId: org.bulwarkmail.mobile
|
||||||
---
|
---
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
visible: "Write your message.*"
|
true: ${FROM_SUBJECT == 'yes'}
|
||||||
commands:
|
commands:
|
||||||
- tapOn: "Write your message.*"
|
- pressKey: Enter
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- inputText: ${BODY}
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
notVisible: "Write your message.*"
|
true: ${FROM_SUBJECT != 'yes'}
|
||||||
commands:
|
commands:
|
||||||
- tapOn:
|
- runFlow:
|
||||||
point: "50%,50%"
|
when:
|
||||||
- waitForAnimationToEnd:
|
visible: "Write your message.*"
|
||||||
timeout: 3000
|
commands:
|
||||||
- inputText: ${BODY}
|
- tapOn: "Write your message.*"
|
||||||
|
# A tap on the WebView doesn't always take the focus from the Subject field
|
||||||
|
# (on the iPad the body once went into Subject); a second tap does.
|
||||||
|
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${BODY}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ appId: org.bulwarkmail.mobile
|
|||||||
visible: "Open in .*Bulwark Mobile.*"
|
visible: "Open in .*Bulwark Mobile.*"
|
||||||
commands:
|
commands:
|
||||||
- tapOn: "Open"
|
- tapOn: "Open"
|
||||||
|
# Right after a sign-out the app can take a while to get to the link.
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Sign in with a link.|Open the sign-in page of.*"
|
visible: "Sign in with a link.|Open the sign-in page of.*"
|
||||||
timeout: 20000
|
timeout: 60000
|
||||||
- takeScreenshot: link-confirm-prompt
|
- takeScreenshot: link-confirm-prompt
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Continue"
|
text: "Continue"
|
||||||
|
|||||||
@@ -18,5 +18,9 @@ appId: org.bulwarkmail.mobile
|
|||||||
visible:
|
visible:
|
||||||
id: email-row
|
id: email-row
|
||||||
timeout: 60000
|
timeout: 60000
|
||||||
# Rows are one element: sender, subject and preview together.
|
# Rows are one element: sender, subject and preview together. Mail the
|
||||||
- assertVisible: ${INBOX_TEXT || '.*Your verification code.*'}
|
# tests sent earlier can push the seeded row below the fold.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ${INBOX_TEXT || '.*Your verification code.*'}
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ appId: org.bulwarkmail.mobile
|
|||||||
TAP: "no"
|
TAP: "no"
|
||||||
TEXT: ${HOST}
|
TEXT: ${HOST}
|
||||||
- tapOn: "Continue"
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
# Once in a while the tap doesn't reach the button (it moves as the keyboard
|
||||||
|
# goes away): tap again if the form is still there.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Sign in with a password instead"
|
visible: "Sign in with a password instead"
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
# From a message list: open the message whose row mentions SUBJECT.
|
# From a message list: open the message whose row mentions SUBJECT.
|
||||||
|
# A row near the bottom can count as visible while the phone's tab bar
|
||||||
|
# covers it (the tap then opens another tab): on the phone, scroll it to the
|
||||||
|
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
|
||||||
|
# screen) doesn't reach; its rows fit on screen.
|
||||||
appId: org.bulwarkmail.mobile
|
appId: org.bulwarkmail.mobile
|
||||||
---
|
---
|
||||||
- scrollUntilVisible:
|
- runFlow:
|
||||||
element: ".*${SUBJECT}.*"
|
when:
|
||||||
direction: DOWN
|
true: ${DEVICE != 'tablet'}
|
||||||
timeout: 30000
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
visibilityPercentage: 100
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
# A tap while the list still moves lands on another row.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
- tapOn: ".*${SUBJECT}.*"
|
- tapOn: ".*${SUBJECT}.*"
|
||||||
|
# The message toolbar's "more" button: "Reply All" shows on the phone, but
|
||||||
|
# the iPad pane has a quick reply box instead for mail to one recipient.
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Reply All"
|
visible: "more"
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
- waitForAnimationToEnd:
|
- waitForAnimationToEnd:
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
|
||||||
|
# where it appears, at once (it shows for a few seconds, about as long as
|
||||||
|
# Maestro takes to find it). If the tap came too early or too late, push
|
||||||
|
# again: up to three tries until the message is open.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Reply All"
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,9%"
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
retryTapIfNoChange: false
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 30000
|
||||||
|
optional: true
|
||||||
@@ -1,18 +1,81 @@
|
|||||||
# Settings list: scroll to the row ROW (regex on "Title, summary"), open it,
|
# Settings list: find the row ROW (regex on "Title, summary"), open it,
|
||||||
# check PROOF shows, screenshot, and go back on a phone. On the iPad the list
|
# check PROOF shows, screenshot, and go back on a phone.
|
||||||
# is a left column, so scroll there instead of the middle of the screen.
|
#
|
||||||
|
# Rows count as visible while the phone's tab bar or header covers them or
|
||||||
|
# while they hang half off the iPad's screen, and a tap then misses; the
|
||||||
|
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
|
||||||
|
# doesn't reach. So search for the page (Q, a word of its title): the row
|
||||||
|
# then sits right under the search box. The account card (no Q) is always
|
||||||
|
# at the top.
|
||||||
appId: org.bulwarkmail.mobile
|
appId: org.bulwarkmail.mobile
|
||||||
---
|
---
|
||||||
- repeat:
|
- runFlow:
|
||||||
while:
|
when:
|
||||||
notVisible: ${ROW}
|
true: ${!!Q}
|
||||||
times: 10
|
|
||||||
commands:
|
commands:
|
||||||
- swipe:
|
- tapOn: "Search settings"
|
||||||
start: "${DEVICE == 'tablet' ? '18%,80%' : '50%,80%'}"
|
- eraseText: 40
|
||||||
end: "${DEVICE == 'tablet' ? '18%,40%' : '50%,40%'}"
|
- inputText: ${Q}
|
||||||
duration: 600
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# Now and then a typed character goes missing: type it again.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: ${ROW}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Search settings"
|
||||||
|
- eraseText: 40
|
||||||
|
- inputText: ${Q}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# A tap while the list still coasts from the swipe only stops it.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
- tapOn: ${ROW}
|
- tapOn: ${ROW}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
# On the phone the page replaces the list: make sure it opened before
|
||||||
|
# scrolling (else the swipes below scroll the list away).
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: ${ROW}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Back"
|
||||||
|
timeout: 10000
|
||||||
|
# PROOF can be further down the page: scroll the page (the right-hand pane
|
||||||
|
# on the iPad) until it shows.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${PROOF}
|
||||||
|
times: 6
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 75%,80%
|
||||||
|
end: 75%,40%
|
||||||
|
duration: 600
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${PROOF}
|
||||||
|
times: 6
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,80%
|
||||||
|
end: 50%,40%
|
||||||
|
duration: 600
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: ${PROOF}
|
visible: ${PROOF}
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
# Signed in: sign out of the active account from the end of Settings.
|
# Signed in: sign out of the active account from the end of Settings.
|
||||||
|
# On the iPad the Settings list is the left column; scroll that one.
|
||||||
appId: org.bulwarkmail.mobile
|
appId: org.bulwarkmail.mobile
|
||||||
---
|
---
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: tab-settings
|
id: tab-settings
|
||||||
- scrollUntilVisible:
|
- runFlow:
|
||||||
element: "Sign out"
|
when:
|
||||||
direction: DOWN
|
true: ${DEVICE == 'tablet'}
|
||||||
timeout: 30000
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Sign out"
|
||||||
|
times: 12
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 30%,85%
|
||||||
|
end: 30%,30%
|
||||||
|
duration: 500
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Sign out"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
- tapOn: "Sign out"
|
- tapOn: "Sign out"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "The account is removed from this device.*"
|
visible: "The account is removed from this device.*"
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ appId: org.bulwarkmail.mobile
|
|||||||
notifications: allow
|
notifications: allow
|
||||||
photos: allow
|
photos: allow
|
||||||
camera: allow
|
camera: allow
|
||||||
|
# Answer the notification prompt (if the app asks) before turning to
|
||||||
|
# landscape: rotated, taps on system alerts miss.
|
||||||
|
- setOrientation: PORTRAIT
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
|
||||||
|
timeout: 120000
|
||||||
|
- runFlow: allow-notifications.yaml
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
true: ${ORIENTATION == 'landscape'}
|
true: ${ORIENTATION == 'landscape'}
|
||||||
@@ -14,7 +21,7 @@ appId: org.bulwarkmail.mobile
|
|||||||
- setOrientation: LANDSCAPE_LEFT
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Enter server details manually|Inbox"
|
visible: "Enter server details manually|Inbox"
|
||||||
timeout: 120000
|
timeout: 30000
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
visible: "Enter server details manually"
|
visible: "Enter server details manually"
|
||||||
@@ -23,6 +30,7 @@ appId: org.bulwarkmail.mobile
|
|||||||
file: password-sign-in.yaml
|
file: password-sign-in.yaml
|
||||||
env:
|
env:
|
||||||
LOGIN: ${ACCOUNT}@example.org
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: allow-notifications.yaml
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: tab-mail
|
id: tab-mail
|
||||||
- runFlow: go-to-inbox.yaml
|
- runFlow: go-to-inbox.yaml
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
|
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
|
||||||
# pages in the auth session), and check it arrived as typed. Now and then a
|
# pages in the auth session), and check it arrived as typed. Now and then a
|
||||||
# stray character lands in front of it on the CI simulator; then select the
|
# stray character lands in front of it on the CI simulator; then erase the
|
||||||
# field's text and type over it. SECRET=yes skips the check (a password
|
# field and type again. The caret is still at the end of what was just
|
||||||
# field shows dots).
|
# typed, so eraseText (which deletes before the caret) empties it; selecting
|
||||||
|
# the text with a long press didn't work and appended instead. SECRET=yes
|
||||||
|
# skips the check (a password field shows dots).
|
||||||
appId: org.bulwarkmail.mobile
|
appId: org.bulwarkmail.mobile
|
||||||
---
|
---
|
||||||
- tapOn: ${HINT}
|
- tapOn: ${HINT}
|
||||||
@@ -13,13 +15,13 @@ appId: org.bulwarkmail.mobile
|
|||||||
when:
|
when:
|
||||||
true: ${SECRET != 'yes'}
|
true: ${SECRET != 'yes'}
|
||||||
commands:
|
commands:
|
||||||
- runFlow:
|
- repeat:
|
||||||
when:
|
while:
|
||||||
notVisible: ${TEXT}
|
notVisible: ${TEXT}
|
||||||
|
times: 2
|
||||||
commands:
|
commands:
|
||||||
- longPressOn: ".*${TEXT}.*"
|
- eraseText: 150
|
||||||
- tapOn:
|
- waitForAnimationToEnd:
|
||||||
text: "Select All"
|
timeout: 2000
|
||||||
optional: true
|
|
||||||
- inputText: ${TEXT}
|
- inputText: ${TEXT}
|
||||||
- assertVisible: ${TEXT}
|
- assertVisible: ${TEXT}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
|||||||
import { useOutboxStore } from './src/stores/outbox-store';
|
import { useOutboxStore } from './src/stores/outbox-store';
|
||||||
import { runOfflineSync } from './src/lib/offline-sync';
|
import { runOfflineSync } from './src/lib/offline-sync';
|
||||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||||
|
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
|
||||||
import { useColors } from './src/theme/colors';
|
import { useColors } from './src/theme/colors';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
@@ -261,6 +262,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
<PushOnboardingPrompt />
|
<PushOnboardingPrompt />
|
||||||
<AppIconBadge />
|
<AppIconBadge />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
|
screenListeners={perfMarksEnabled() ? ({ route }) => ({
|
||||||
|
tabPress: () => perfStart('tab-switch'),
|
||||||
|
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
|
||||||
|
}) : undefined}
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Tabs out of view stop re-rendering until they are shown again.
|
// Tabs out of view stop re-rendering until they are shown again.
|
||||||
|
|||||||
@@ -228,9 +228,11 @@ The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and
|
|||||||
x86_64 (Play splits the bundle per ABI), checks the signer, the package name and
|
x86_64 (Play splits the bundle per ABI), checks the signer, the package name and
|
||||||
the permissions, and keeps the AAB and its R8 mapping as a workflow artifact.
|
the permissions, and keeps the AAB and its R8 mapping as a workflow artifact.
|
||||||
With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service
|
With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service
|
||||||
account bulwark-google-play-api") it also uploads the bundle to the internal
|
account bulwark-google-play-api") it also uploads the bundle and rolls it out
|
||||||
testing track as a draft. Play only accepts API uploads once a first bundle has
|
on the internal and closed (beta) testing tracks, so testers get every release
|
||||||
been uploaded by hand in the Play Console.
|
without a step in the Play Console; closed testing may wait for Google's
|
||||||
|
review. Play only accepts API uploads once a first bundle has been uploaded by
|
||||||
|
hand in the Play Console.
|
||||||
|
|
||||||
The `github` flavor stays on the old Firebase project until the app registers
|
The `github` flavor stays on the old Firebase project until the app registers
|
||||||
its new FCM token with the relay when the token changes; moving it before that
|
its new FCM token with the relay when the token changes; moving it before that
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
|
|||||||
import { pushBackgroundTask } from './src/lib/push-background-task';
|
import { pushBackgroundTask } from './src/lib/push-background-task';
|
||||||
import { widgetTaskHandler } from './src/widgets/task-handler';
|
import { widgetTaskHandler } from './src/widgets/task-handler';
|
||||||
import { refreshWidgetsInBackground } from './src/widgets/sync';
|
import { refreshWidgetsInBackground } from './src/widgets/sync';
|
||||||
|
import { perfMark } from './src/lib/perf-marks';
|
||||||
|
|
||||||
|
// Every import above has run: the bundle is loaded (performance harness).
|
||||||
|
perfMark('bundle-loaded');
|
||||||
import { markUiStarted } from './src/widgets/ui-presence';
|
import { markUiStarted } from './src/widgets/ui-presence';
|
||||||
|
|
||||||
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
// Performance harness (the iOS simulator tests, .github/workflows/e2e-ios.yml):
|
||||||
|
// with BULWARK_MODULE_TIMINGS set, the bundle times each module's
|
||||||
|
// evaluation (scripts/perf/module-timings.js), and BULWARK_MODULE_MAP names
|
||||||
|
// a file that receives the module ids with their source paths, so the
|
||||||
|
// harness can tell which module is which. Release builds set neither.
|
||||||
|
if (process.env.BULWARK_MODULE_TIMINGS) {
|
||||||
|
const basePolyfills = config.serializer.getPolyfills;
|
||||||
|
config.serializer.getPolyfills = (ctx) => [
|
||||||
|
...basePolyfills(ctx),
|
||||||
|
path.join(__dirname, 'scripts/perf/module-timings.js'),
|
||||||
|
];
|
||||||
|
const mapFile = process.env.BULWARK_MODULE_MAP;
|
||||||
|
if (mapFile) {
|
||||||
|
const baseFactory = config.serializer.createModuleIdFactory;
|
||||||
|
config.serializer.createModuleIdFactory = () => {
|
||||||
|
const idOf = baseFactory();
|
||||||
|
const names = {};
|
||||||
|
process.on('exit', () => {
|
||||||
|
try { fs.writeFileSync(mapFile, JSON.stringify(names)); } catch { /* best effort */ }
|
||||||
|
});
|
||||||
|
return (modulePath) => {
|
||||||
|
const id = idOf(modulePath);
|
||||||
|
names[id] = path.relative(__dirname, modulePath).split(path.sep).join('/');
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.83",
|
"version": "0.1.86",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.83",
|
"version": "0.1.86",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.83",
|
"version": "0.1.86",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+17
-2
@@ -105,9 +105,24 @@ function simctl(args, input) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// simctl pbcopy times out now and then (seen on the iPad simulator); a
|
||||||
|
// second or third try goes through.
|
||||||
|
async function pbcopy(text) {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await simctl(['pbcopy', UDID], text);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt >= 3) throw e;
|
||||||
|
log(`pbcopy attempt ${attempt} failed, trying again`);
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deliver(link, how) {
|
async function deliver(link, how) {
|
||||||
if (how === 'openurl') await simctl(['openurl', UDID, link]);
|
if (how === 'openurl') await simctl(['openurl', UDID, link]);
|
||||||
else if (how === 'pbcopy') await simctl(['pbcopy', UDID], link);
|
else if (how === 'pbcopy') await pbcopy(link);
|
||||||
else if (how !== 'none') throw new Error(`unknown deliver=${how}`);
|
else if (how !== 'none') throw new Error(`unknown deliver=${how}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +413,7 @@ const routes = {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
async '/clipboard'(q) {
|
async '/clipboard'(q) {
|
||||||
await simctl(['pbcopy', UDID], q.get('text') || '');
|
await pbcopy(q.get('text') || '');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+57
-6
@@ -11,7 +11,10 @@
|
|||||||
// stays signed in),
|
// stays signed in),
|
||||||
// 2. cold-starts it `--cold` times: terminate, launch, wait for
|
// 2. cold-starts it `--cold` times: terminate, launch, wait for
|
||||||
// `inbox-rendered`; cold start = that mark's time minus the launch time
|
// `inbox-rendered`; cold start = that mark's time minus the launch time
|
||||||
// taken on this machine (the simulator shares the host clock),
|
// taken on this machine (the simulator shares the host clock). The
|
||||||
|
// first launch after the install is much slower and swings a lot (iOS
|
||||||
|
// does its first-launch work for the new binary), so it is reported on
|
||||||
|
// its own as `first-start-*`; `cold-start-*` are the launches after it,
|
||||||
// 3. runs the Maestro interaction flows once each (open messages, switch
|
// 3. runs the Maestro interaction flows once each (open messages, switch
|
||||||
// tabs, open compose, scroll), which produce the other marks. Each flow
|
// tabs, open compose, scroll), which produce the other marks. Each flow
|
||||||
// is its own run, so one that fails costs only its own metric.
|
// is its own run, so one that fails costs only its own metric.
|
||||||
@@ -101,12 +104,14 @@ function waitForMark(name, timeoutMs) {
|
|||||||
|
|
||||||
// ── steps ───────────────────────────────────────────────────────────────────
|
// ── steps ───────────────────────────────────────────────────────────────────
|
||||||
const samples = []; // { label, metric, ms, round }
|
const samples = []; // { label, metric, ms, round }
|
||||||
|
const moduleRuns = {}; // label -> [{ total, top: { id: ms } }] (module-times marks)
|
||||||
|
|
||||||
async function coldStart(label, round) {
|
async function coldStart(label, round, first) {
|
||||||
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
|
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
const pending = waitForMark('inbox-rendered', 120_000);
|
const pending = waitForMark('inbox-rendered', 120_000);
|
||||||
const bundlePending = waitForMark('bundle-loaded', 120_000);
|
const bundlePending = waitForMark('bundle-loaded', 120_000);
|
||||||
|
const modulesPending = waitForMark('module-times', 125_000);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
simctl('launch', UDID, BUNDLE, '-perfMarks', 'YES');
|
simctl('launch', UDID, BUNDLE, '-perfMarks', 'YES');
|
||||||
const [mark, bundle] = await Promise.all([pending, bundlePending]);
|
const [mark, bundle] = await Promise.all([pending, bundlePending]);
|
||||||
@@ -114,8 +119,33 @@ async function coldStart(label, round) {
|
|||||||
console.log(` ${label} cold start: no inbox-rendered mark within 120 s`);
|
console.log(` ${label} cold start: no inbox-rendered mark within 120 s`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
samples.push({ label, round, metric: 'cold-start-to-inbox', ms: mark.t - t0 });
|
const kind = first ? 'first-start' : 'cold-start';
|
||||||
if (bundle) samples.push({ label, round, metric: 'cold-start-to-bundle', ms: bundle.t - t0 });
|
// Builds made with BULWARK_MODULE_TIMINGS log how long each module took
|
||||||
|
// to evaluate, right after the first inbox (src/lib/perf-marks.ts).
|
||||||
|
const modules = await Promise.race([modulesPending, sleep(3000).then(() => null)]);
|
||||||
|
if (modules && !first) {
|
||||||
|
const top = {};
|
||||||
|
for (const pair of String(modules.top || '').split(',')) {
|
||||||
|
const [id, ms] = pair.split(':');
|
||||||
|
if (id) top[id] = Number(ms);
|
||||||
|
}
|
||||||
|
(moduleRuns[label] ||= []).push({ total: modules.total, top });
|
||||||
|
}
|
||||||
|
samples.push({ label, round, metric: `${kind}-to-inbox`, ms: mark.t - t0 });
|
||||||
|
if (bundle) samples.push({ label, round, metric: `${kind}-to-bundle`, ms: bundle.t - t0 });
|
||||||
|
// The start-up timeline: every mark between launch and the inbox, as time
|
||||||
|
// since launch, plus how long each persisted cache took to read and parse.
|
||||||
|
if (!first) {
|
||||||
|
for (const m of marks) {
|
||||||
|
if (m.t < t0 || m.t > mark.t || m.n === 'inbox-rendered' || m.n === 'bundle-loaded') continue;
|
||||||
|
const key = m.store ? `${m.n}:${m.store}` : m.n;
|
||||||
|
samples.push({ label, round, metric: `start:${key}`, ms: m.t - t0 });
|
||||||
|
if (m.n === 'hydrate') {
|
||||||
|
samples.push({ label, round, metric: `hydrate-read:${m.store}`, ms: m.readMs });
|
||||||
|
samples.push({ label, round, metric: `hydrate-parse:${m.store}`, ms: m.parseMs, kb: m.kb });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log(` ${label} cold start ${mark.t - t0} ms (bundle ${bundle ? bundle.t - t0 : '?'} ms, rows ${mark.rows})`);
|
console.log(` ${label} cold start ${mark.t - t0} ms (bundle ${bundle ? bundle.t - t0 : '?'} ms, rows ${mark.rows})`);
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
}
|
}
|
||||||
@@ -168,7 +198,28 @@ const scroll = labels.map((l) => {
|
|||||||
if (!xs.length) return null;
|
if (!xs.length) return null;
|
||||||
return `${l}: median ${median(xs.map((s) => s.long))} long frames of ${median(xs.map((s) => s.frames))}, worst ${median(xs.map((s) => s.worst))} ms`;
|
return `${l}: median ${median(xs.map((s) => s.long))} long frames of ${median(xs.map((s) => s.frames))}, worst ${median(xs.map((s) => s.worst))} ms`;
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), ''].join('\n');
|
// The slowest modules at start-up per app, by median over the launches.
|
||||||
|
const moduleSections = [];
|
||||||
|
for (const app of apps) {
|
||||||
|
const runs = moduleRuns[app.label];
|
||||||
|
if (!runs?.length) continue;
|
||||||
|
let names = {};
|
||||||
|
try { names = JSON.parse(fs.readFileSync(path.join(path.dirname(app.path), 'module-map.json'), 'utf8')); } catch { /* no map */ }
|
||||||
|
const ids = new Set(runs.flatMap((r) => Object.keys(r.top)));
|
||||||
|
const rows = [...ids]
|
||||||
|
.map((id) => ({ id, ms: median(runs.map((r) => r.top[id] ?? 0)) }))
|
||||||
|
.sort((a, b) => b.ms - a.ms)
|
||||||
|
.slice(0, 30);
|
||||||
|
moduleSections.push(
|
||||||
|
`### Slowest modules at start-up: ${app.label} (own evaluation time, median of ${runs.length} launches; all modules ${median(runs.map((r) => r.total))} ms)`,
|
||||||
|
'',
|
||||||
|
'| module | ms |',
|
||||||
|
'|---|---|',
|
||||||
|
...rows.map((r) => `| ${names[r.id] ?? `#${r.id}`} | ${r.ms} |`),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), '', ...moduleSections].join('\n');
|
||||||
fs.writeFileSync(path.join(out, 'perf.json'), JSON.stringify({ apps, rounds, coldRuns, samples }, null, 1));
|
fs.writeFileSync(path.join(out, 'perf.json'), JSON.stringify({ apps, rounds, coldRuns, samples }, null, 1));
|
||||||
fs.writeFileSync(path.join(out, 'perf.md'), md);
|
fs.writeFileSync(path.join(out, 'perf.md'), md);
|
||||||
console.log(md);
|
console.log(md);
|
||||||
@@ -185,7 +236,7 @@ if (final && process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITH
|
|||||||
currentLabel = app.label;
|
currentLabel = app.label;
|
||||||
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
|
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
|
||||||
simctl('install', UDID, app.path);
|
simctl('install', UDID, app.path);
|
||||||
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round);
|
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round, i === 0);
|
||||||
const got = await runFlows(app.label);
|
const got = await runFlows(app.label);
|
||||||
for (const m of got) {
|
for (const m of got) {
|
||||||
const metric = m.n === 'tab-switch' && m.to ? `tab-switch:${m.to}` : m.n;
|
const metric = m.n === 'tab-switch' && m.to ? `tab-switch:${m.to}` : m.n;
|
||||||
|
|||||||
+13
-10
@@ -34,17 +34,20 @@ maestro --version
|
|||||||
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/run.mp4" &
|
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/run.mp4" &
|
||||||
RECORDER=$!
|
RECORDER=$!
|
||||||
|
|
||||||
# Warm-up: the XCTest driver takes minutes to start on a cold runner.
|
# Warm-up: the XCTest driver takes minutes to start on a cold runner. When
|
||||||
WARMUP_START=$(date +%s)
|
# it dies on the way (it does, now and then), a second try starts a new one
|
||||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
# instead of losing the whole leg.
|
||||||
--debug-output "$RUNNER_TEMP/maestro-warmup" "$ROOT/.maestro/launch.yaml")
|
for attempt in 1 2; do
|
||||||
STATUS=$?
|
WARMUP_START=$(date +%s)
|
||||||
echo "warm-up took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
|
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||||
if [ $STATUS -ne 0 ]; then
|
--debug-output "$RUNNER_TEMP/maestro-warmup-$attempt" "$ROOT/.maestro/launch.yaml")
|
||||||
echo "::group::Maestro warm-up log"
|
STATUS=$?
|
||||||
find "$RUNNER_TEMP/maestro-warmup" -name 'maestro.log' -exec tail -n 300 {} \;
|
echo "warm-up $attempt took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
|
||||||
|
[ $STATUS -eq 0 ] && break
|
||||||
|
echo "::group::Maestro warm-up log $attempt"
|
||||||
|
find "$RUNNER_TEMP/maestro-warmup-$attempt" -name 'maestro.log' -exec tail -n 300 {} \;
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
done
|
||||||
|
|
||||||
if [ $STATUS -eq 0 ]; then
|
if [ $STATUS -eq 0 ]; then
|
||||||
ARGS=(
|
ARGS=(
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Metro polyfill for the performance harness (metro.config.js adds it only
|
||||||
|
// when BULWARK_MODULE_TIMINGS is set at bundle time; release builds don't
|
||||||
|
// have it). It wraps every module factory and adds up how long each module's
|
||||||
|
// own code ran when it was first evaluated, without the modules it required
|
||||||
|
// meanwhile. src/lib/perf-marks.ts logs the slowest ones at first inbox.
|
||||||
|
(function (g) {
|
||||||
|
var define = g.__d;
|
||||||
|
if (typeof define !== 'function') return;
|
||||||
|
var now = typeof g.nativePerformanceNow === 'function'
|
||||||
|
? g.nativePerformanceNow
|
||||||
|
: function () { return Date.now(); };
|
||||||
|
var times = {};
|
||||||
|
var stack = [];
|
||||||
|
g.__bulwarkModuleTimes = times;
|
||||||
|
g.__d = function (factory, moduleId, dependencyMap, verboseName) {
|
||||||
|
var timed = function () {
|
||||||
|
var start = now();
|
||||||
|
stack.push(0);
|
||||||
|
try {
|
||||||
|
return factory.apply(this, arguments);
|
||||||
|
} finally {
|
||||||
|
var total = now() - start;
|
||||||
|
var nested = stack.pop();
|
||||||
|
times[moduleId] = (times[moduleId] || 0) + (total - nested);
|
||||||
|
if (stack.length > 0) stack[stack.length - 1] += total;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return define(timed, moduleId, dependencyMap, verboseName);
|
||||||
|
};
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, parseHoldLimit } from '../jmap-client';
|
||||||
import type { JMAPSession } from '../types';
|
import type { JMAPSession } from '../types';
|
||||||
|
|
||||||
const MOCK_SESSION: JMAPSession = {
|
const MOCK_SESSION: JMAPSession = {
|
||||||
@@ -113,8 +113,10 @@ describe('JMAPClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('connectWithToken', () => {
|
describe('connectWithToken', () => {
|
||||||
|
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
|
||||||
|
|
||||||
it('should use Bearer auth', async () => {
|
it('should use Bearer auth', async () => {
|
||||||
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||||
|
|
||||||
await client.connectWithToken('https://mail.example.com', 'my-token');
|
await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||||
|
|
||||||
@@ -128,6 +130,70 @@ describe('JMAPClient', () => {
|
|||||||
);
|
);
|
||||||
expect(client.isConnected).toBe(true);
|
expect(client.isConnected).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the account after the session and remembers the token', async () => {
|
||||||
|
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
|
||||||
|
|
||||||
|
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
|
||||||
|
|
||||||
|
expect(username).toBe('user@example.com');
|
||||||
|
expect(accountId).toBe('user@example.com@mail.example.com');
|
||||||
|
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
|
||||||
|
expect(key).toContain('user_example.com');
|
||||||
|
expect(JSON.parse(stored)).toMatchObject({
|
||||||
|
serverUrl: 'https://mail.example.com',
|
||||||
|
username: 'user@example.com',
|
||||||
|
accessToken: 'my-token',
|
||||||
|
});
|
||||||
|
expect(client.usesPastedToken).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a token the server rejects', async () => {
|
||||||
|
global.fetch = mockFetch([{ status: 401 }]) as any;
|
||||||
|
|
||||||
|
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
|
||||||
|
expect(client.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a session that does not name the account', async () => {
|
||||||
|
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
|
||||||
|
|
||||||
|
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
|
||||||
|
expect(client.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a download host the server runs on another HTTPS origin', async () => {
|
||||||
|
global.fetch = mockFetch([{
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
...TOKEN_SESSION,
|
||||||
|
apiUrl: 'https://api.fastmail.com/jmap/api/',
|
||||||
|
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
|
||||||
|
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
|
||||||
|
},
|
||||||
|
}]) as any;
|
||||||
|
|
||||||
|
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
|
||||||
|
|
||||||
|
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
|
||||||
|
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still moves self-reported internal hosts onto the server address', async () => {
|
||||||
|
global.fetch = mockFetch([{
|
||||||
|
status: 200,
|
||||||
|
json: {
|
||||||
|
...TOKEN_SESSION,
|
||||||
|
apiUrl: 'http://localhost:8080/jmap/',
|
||||||
|
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
|
||||||
|
},
|
||||||
|
}]) as any;
|
||||||
|
|
||||||
|
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
|
||||||
|
|
||||||
|
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
|
||||||
|
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('request', () => {
|
describe('request', () => {
|
||||||
|
|||||||
+57
-6
@@ -177,6 +177,12 @@ export class JMAPClient {
|
|||||||
return !!this.credentials?.accessToken;
|
return !!this.credentials?.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pasted access token, as opposed to an OAuth bundle (which always names
|
||||||
|
// the token endpoint it renews at).
|
||||||
|
get usesPastedToken(): boolean {
|
||||||
|
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
get isConnected(): boolean {
|
get isConnected(): boolean {
|
||||||
return this.session !== null && this._accountId !== null;
|
return this.session !== null && this._accountId !== null;
|
||||||
}
|
}
|
||||||
@@ -294,15 +300,42 @@ export class JMAPClient {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
|
/**
|
||||||
|
* Access-token login (an API token the user pasted, e.g. from Fastmail).
|
||||||
|
* The token is the whole credential: it names the account, so the username
|
||||||
|
* comes from the session, and with no refresh token it lasts until the
|
||||||
|
* provider expires or revokes it.
|
||||||
|
*/
|
||||||
|
async connectWithToken(
|
||||||
|
serverUrl: string,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
|
||||||
const baseUrl = serverUrl.replace(/\/+$/, '');
|
const baseUrl = serverUrl.replace(/\/+$/, '');
|
||||||
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
|
||||||
|
|
||||||
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
try {
|
||||||
|
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
|
||||||
|
} catch (err) {
|
||||||
|
this.credentials = null;
|
||||||
|
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
|
||||||
|
throw new InvalidTokenError();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
this._accountId = this.resolveAccountId(this.session);
|
this._accountId = this.resolveAccountId(this.session);
|
||||||
this.firstTouchGate.reset();
|
this.firstTouchGate.reset();
|
||||||
|
|
||||||
return this.session;
|
const username = this.session.username;
|
||||||
|
if (!username) {
|
||||||
|
this.reset();
|
||||||
|
throw new Error('The server did not name the account');
|
||||||
|
}
|
||||||
|
this.credentials.username = username;
|
||||||
|
|
||||||
|
const accountId = generateAccountId(username, baseUrl);
|
||||||
|
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||||
|
|
||||||
|
return { session: this.session, username, accountId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
// OAuth login via webmail handoff. The webmail did the OAuth dance against
|
||||||
@@ -612,16 +645,26 @@ export class JMAPClient {
|
|||||||
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
// URL polyfill mutates inputs (appends trailing slashes, normalises
|
||||||
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
|
||||||
// before the caller has a chance to substitute values into them.
|
// before the caller has a chance to substitute values into them.
|
||||||
|
//
|
||||||
|
// A URL on another HTTPS origin than the server's own apiUrl is one it
|
||||||
|
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
|
||||||
|
// domain) and is kept; everything else moves onto the origin we connected to.
|
||||||
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
|
||||||
const serverOrigin = extractOrigin(serverUrl);
|
const serverOrigin = extractOrigin(serverUrl);
|
||||||
|
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
|
||||||
const rewrite = (url: string | undefined): string | undefined =>
|
const rewrite = (url: string | undefined): string | undefined =>
|
||||||
rewriteSessionUrl(url, serverOrigin);
|
rewriteSessionUrl(url, serverOrigin);
|
||||||
|
const rewriteHosted = (url: string | undefined): string | undefined => {
|
||||||
|
const origin = url ? extractOrigin(url) : null;
|
||||||
|
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
|
||||||
|
return hostedElsewhere ? url : rewrite(url);
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
|
||||||
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
|
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
|
||||||
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
|
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
|
||||||
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
|
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,6 +1326,14 @@ export class AuthenticationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server refused a pasted access token (wrong, expired or revoked).
|
||||||
|
export class InvalidTokenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid access token');
|
||||||
|
this.name = 'InvalidTokenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The server wants a second factor before it will hand out a session.
|
// The server wants a second factor before it will hand out a session.
|
||||||
export class TotpRequiredError extends AuthenticationError {
|
export class TotpRequiredError extends AuthenticationError {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useContactsStore } from '../stores/contacts-store';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
|
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
@@ -683,8 +684,13 @@ export default function EmailBodyView({
|
|||||||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Performance harness: how much of opening a message is the WebView.
|
||||||
|
React.useEffect(() => { perfLap('open-message', 'body-mounted'); }, []);
|
||||||
|
|
||||||
const onMessage = (e: WebViewMessageEvent) => {
|
const onMessage = (e: WebViewMessageEvent) => {
|
||||||
const data = e.nativeEvent.data;
|
const data = e.nativeEvent.data;
|
||||||
|
// The body reported its size: it is laid out (performance harness).
|
||||||
|
perfEndAfterPaint('open-message');
|
||||||
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
|
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
|
||||||
if (data.charCodeAt(0) === 123 /* '{' */) {
|
if (data.charCodeAt(0) === 123 /* '{' */) {
|
||||||
try {
|
try {
|
||||||
@@ -788,6 +794,7 @@ export default function EmailBodyView({
|
|||||||
injectedJavaScript={injectedJs}
|
injectedJavaScript={injectedJs}
|
||||||
onMessage={onMessage}
|
onMessage={onMessage}
|
||||||
onLoadEnd={() => {
|
onLoadEnd={() => {
|
||||||
|
perfLap('open-message', 'webview-loaded');
|
||||||
// A (re)load resets the page's zoom state to 1 — drop any host-side
|
// A (re)load resets the page's zoom state to 1 — drop any host-side
|
||||||
// scroll locks so they can't go stale (e.g. cid images arriving
|
// scroll locks so they can't go stale (e.g. cid images arriving
|
||||||
// reload the source mid-zoom).
|
// reload the source mid-zoom).
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Modal,
|
|
||||||
Platform,
|
Platform,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -19,6 +18,7 @@ import Button from './Button';
|
|||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
||||||
import { describeLoginError } from '../lib/login-errors';
|
import { describeLoginError } from '../lib/login-errors';
|
||||||
|
import { SafeAreaModal } from './SafeAreaModal';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface PasteSignInLinkModalProps {
|
interface PasteSignInLinkModalProps {
|
||||||
@@ -81,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
|||||||
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
|
import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||||
import { X } from './icons';
|
import { X } from './icons';
|
||||||
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
|||||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
|
import { SafeAreaModal } from './SafeAreaModal';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
interface QrScanModalProps {
|
interface QrScanModalProps {
|
||||||
@@ -43,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{permission?.granted ? (
|
{permission?.granted ? (
|
||||||
<CameraView
|
<CameraView
|
||||||
@@ -105,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
|
|||||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import type { ThemePalette } from '../theme/tokens';
|
import type { ThemePalette } from '../theme/tokens';
|
||||||
|
import { perfEndAfterPaint } from '../lib/perf-marks';
|
||||||
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
||||||
|
|
||||||
export type RichTextCommand =
|
export type RichTextCommand =
|
||||||
@@ -65,6 +66,8 @@ export interface RichTextEditorHandle {
|
|||||||
*/
|
*/
|
||||||
getHtml(timeoutMs?: number): Promise<string>;
|
getHtml(timeoutMs?: number): Promise<string>;
|
||||||
focus(): void;
|
focus(): void;
|
||||||
|
/** Focus with the caret where new text goes (before signature / quote). */
|
||||||
|
focusEnd(): void;
|
||||||
/** Drop the caret, which closes the keyboard. */
|
/** Drop the caret, which closes the keyboard. */
|
||||||
blur(): void;
|
blur(): void;
|
||||||
}
|
}
|
||||||
@@ -185,6 +188,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
pendingSnapshots.current.set(id, { resolve, reject, timer });
|
pendingSnapshots.current.set(id, { resolve, reject, timer });
|
||||||
call(`window.__rne && window.__rne.getHtml(${id})`);
|
call(`window.__rne && window.__rne.getHtml(${id})`);
|
||||||
}),
|
}),
|
||||||
|
focusEnd: () => {
|
||||||
|
call(`window.__rne && window.__rne.focusEnd()`);
|
||||||
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
call(`window.__rne && window.__rne.focus()`);
|
call(`window.__rne && window.__rne.focus()`);
|
||||||
},
|
},
|
||||||
@@ -202,6 +208,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
}
|
}
|
||||||
if (!readyRef.current) {
|
if (!readyRef.current) {
|
||||||
readyRef.current = true;
|
readyRef.current = true;
|
||||||
|
perfEndAfterPaint('compose-open');
|
||||||
const queued = queuedRef.current;
|
const queued = queuedRef.current;
|
||||||
queuedRef.current = [];
|
queuedRef.current = [];
|
||||||
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, type ModalProps } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Modal with its own safe-area provider. A Modal is a separate native
|
||||||
|
* window, and a SafeAreaView inside a Modal whose content slides in (a
|
||||||
|
* slide-up Modal, or a drawer or sheet the app animates itself) measured no
|
||||||
|
* insets: the view starts off screen. On iPhones with a notch or Dynamic
|
||||||
|
* Island headers (and their close buttons) ended up under the status bar,
|
||||||
|
* where taps don't reach them, and bottom rows on the home indicator. Use
|
||||||
|
* this for any Modal with a SafeAreaView inside.
|
||||||
|
*/
|
||||||
|
export function SafeAreaModal({ children, ...props }: ModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal {...props}>
|
||||||
|
<SafeAreaProvider>{children}</SafeAreaProvider>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import type { RootStackParamList } from '../navigation/types';
|
|||||||
import type { Mailbox } from '../api/types';
|
import type { Mailbox } from '../api/types';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
import { confirmSignOut } from '../lib/sign-out-confirm';
|
import { confirmSignOut } from '../lib/sign-out-confirm';
|
||||||
|
import { SafeAreaModal } from './SafeAreaModal';
|
||||||
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
|
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
|
||||||
import {
|
import {
|
||||||
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
|
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
|
||||||
@@ -1240,8 +1241,11 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Its own safe-area provider: the panel's SafeAreaView starts off screen
|
||||||
|
// (it slides in), and without one it measured no insets on iOS, so the
|
||||||
|
// account header sat under the status bar.
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
@@ -1268,7 +1272,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
onClose={() => setPrompt(null)}
|
onClose={() => setPrompt(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useNavigation } from '@react-navigation/native';
|
|||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
|
import { undoSnackbarColors } from './undo-snackbar-colors';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
import { useEmailStore } from '../stores/email-store';
|
||||||
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
|
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
@@ -158,6 +159,7 @@ export function UndoSnackbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(c: ThemePalette) {
|
function makeStyles(c: ThemePalette) {
|
||||||
|
const colors = undoSnackbarColors(c);
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
wrap: {
|
wrap: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -170,7 +172,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
paddingLeft: spacing.lg,
|
paddingLeft: spacing.lg,
|
||||||
paddingRight: spacing.sm,
|
paddingRight: spacing.sm,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
backgroundColor: c.text,
|
backgroundColor: colors.bar,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
@@ -179,7 +181,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: c.background,
|
color: colors.label,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
undoBtn: {
|
undoBtn: {
|
||||||
@@ -192,7 +194,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
undoText: {
|
undoText: {
|
||||||
...typography.bodySemibold,
|
...typography.bodySemibold,
|
||||||
color: c.primary,
|
color: colors.action,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
sendNowText: {
|
sendNowText: {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// A SafeAreaView inside a react-native Modal whose content starts off screen
|
||||||
|
// measured no insets on iOS: the Modal is its own window, and the view was
|
||||||
|
// measured before it slid in. On iPhones with a notch or Dynamic Island the
|
||||||
|
// header and its close button sat under the status bar, out of reach
|
||||||
|
// (attachment preview, QR scanner, paste sign-in link, filter and event
|
||||||
|
// editors), the drawers' account headers did too, and the bottom sheets'
|
||||||
|
// last rows sat on the home indicator. Such modals use SafeAreaModal, which
|
||||||
|
// gives the window its own SafeAreaProvider. Slid in means animationType
|
||||||
|
// "slide", or "none" with the app's own slide animation (drawers, sheets).
|
||||||
|
|
||||||
|
const SRC = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||||
|
return e.name.endsWith('.tsx') ? [p] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain <Modal> elements: the opening tag and what is between the tags. */
|
||||||
|
function modals(source: string): { tag: string; body: string }[] {
|
||||||
|
return modalTags(source).map((tag) => {
|
||||||
|
const start = source.indexOf(tag) + tag.length;
|
||||||
|
const end = source.indexOf('</Modal>', start);
|
||||||
|
return { tag, body: source.slice(start, end < 0 ? undefined : end) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opening tags of plain <Modal> elements, attributes included. */
|
||||||
|
function modalTags(source: string): string[] {
|
||||||
|
const tags: string[] = [];
|
||||||
|
let at = source.indexOf('<Modal');
|
||||||
|
while (at >= 0) {
|
||||||
|
if (/[\s>]/.test(source[at + 6] ?? '')) {
|
||||||
|
let depth = 0;
|
||||||
|
let i = at;
|
||||||
|
for (; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') depth--;
|
||||||
|
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
|
||||||
|
}
|
||||||
|
tags.push(source.slice(at, i + 1));
|
||||||
|
}
|
||||||
|
at = source.indexOf('<Modal', at + 6);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('modals that slide in with a SafeAreaView', () => {
|
||||||
|
it('use SafeAreaModal so their content clears the status bar and home indicator', () => {
|
||||||
|
const offenders = sourceFiles(SRC)
|
||||||
|
.filter((file) => {
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
if (!source.includes('SafeAreaView')) return false;
|
||||||
|
return modals(source).some(({ tag, body }) =>
|
||||||
|
/animationType="slide"/.test(tag)
|
||||||
|
|| (/animationType="none"/.test(tag) && body.includes('<SafeAreaView')));
|
||||||
|
})
|
||||||
|
.map((file) => path.relative(SRC, file));
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The mail drawer builds its panel (with the SafeAreaView) outside the
|
||||||
|
// modal's tags, so the check above can't see it there.
|
||||||
|
it('include the side drawers', () => {
|
||||||
|
const drawers = sourceFiles(SRC).filter((file) => /SidebarDrawer\.tsx$/.test(file));
|
||||||
|
expect(drawers.length).toBeGreaterThanOrEqual(3);
|
||||||
|
for (const file of drawers) {
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
expect(modalTags(source).filter((tag) => /animationType="none"/.test(tag)), path.relative(SRC, file)).toEqual([]);
|
||||||
|
expect(source).toContain('<SafeAreaModal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the tags it is looking for', () => {
|
||||||
|
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
|
||||||
|
expect(tags).toHaveLength(1);
|
||||||
|
expect(tags[0]).toContain('animationType="slide"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Sheets and menus were built as a tappable backdrop (Pressable or
|
||||||
|
// TouchableWithoutFeedback) wrapping a card that swallows taps. Both are
|
||||||
|
// accessible elements by default, and an accessible element hides its
|
||||||
|
// children from VoiceOver and TalkBack: the compose "Attach" and "Schedule
|
||||||
|
// send" sheets read as one element ("Attach, Photos & Videos, Camera, Files,
|
||||||
|
// Cancel") whose options couldn't be picked one by one. The wrappers that
|
||||||
|
// only catch taps are marked accessible={false}, so the options inside stay
|
||||||
|
// reachable.
|
||||||
|
|
||||||
|
const SRC = path.resolve(__dirname, '../..');
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||||
|
return e.name.endsWith('.tsx') ? [p] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openingTags(source: string, name: string): { tag: string; line: number }[] {
|
||||||
|
const out: { tag: string; line: number }[] = [];
|
||||||
|
const re = new RegExp(`<${name}\\b`, 'g');
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(source))) {
|
||||||
|
let depth = 0;
|
||||||
|
let i = m.index;
|
||||||
|
for (; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') depth--;
|
||||||
|
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
|
||||||
|
}
|
||||||
|
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wrapper that only catches taps and still hides what it wraps. */
|
||||||
|
export function isHidingWrapper(tag: string): boolean {
|
||||||
|
if (/accessible=\{false\}/.test(tag)) return false;
|
||||||
|
if (/\/\s*>$/.test(tag)) return false; // self-closing: wraps nothing
|
||||||
|
if (tag.startsWith('<TouchableWithoutFeedback')) return true;
|
||||||
|
if (/onPress=\{\(\) => \{\}\}/.test(tag)) return true;
|
||||||
|
return /style=\{styles\.\w*(Backdrop|backdrop|Overlay|overlay)\}/.test(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sheet backdrops', () => {
|
||||||
|
it('do not hide the options they wrap from screen readers', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const file of sourceFiles(SRC)) {
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
for (const name of ['Pressable', 'TouchableWithoutFeedback']) {
|
||||||
|
for (const { tag, line } of openingTags(source, name)) {
|
||||||
|
if (isHidingWrapper(tag)) offenders.push(`${path.relative(SRC, file)}:${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises the wrappers it looks for', () => {
|
||||||
|
expect(isHidingWrapper('<Pressable style={styles.modalBackdrop} onPress={onClose}>')).toBe(true);
|
||||||
|
expect(isHidingWrapper('<Pressable style={styles.card} onPress={() => {}}>')).toBe(true);
|
||||||
|
expect(isHidingWrapper('<TouchableWithoutFeedback onPress={onClose}>')).toBe(true);
|
||||||
|
expect(isHidingWrapper('<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>')).toBe(false);
|
||||||
|
expect(isHidingWrapper('<Pressable style={styles.pickerOverlay} onPress={close} />')).toBe(false);
|
||||||
|
expect(isHidingWrapper('<Pressable style={styles.row} onPress={open}>')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DARK_COLORS, LIGHT_COLORS, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
|
||||||
|
import { undoSnackbarColors } from '../undo-snackbar-colors';
|
||||||
|
|
||||||
|
// The undo bar (UndoSnackbar) is drawn in the text colour. Its UNDO button
|
||||||
|
// used the primary colour, which is white in the default dark theme: white
|
||||||
|
// on white, so in dark mode the bar showed "Email archived" and no visible
|
||||||
|
// way to undo. The bound is 3:1, not 4.5:1, because Solarized's own text
|
||||||
|
// and background are a little under 4.5:1.
|
||||||
|
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const v = hex.replace('#', '');
|
||||||
|
const full = v.length === 3 ? v.split('').map((ch) => ch + ch).join('') : v.slice(0, 6);
|
||||||
|
const [r, g, b] = [0, 2, 4].map((i) => {
|
||||||
|
const c = parseInt(full.slice(i, i + 2), 16) / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrast(a: string, b: string): number {
|
||||||
|
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const palettes: Array<[string, ThemePalette]> = [
|
||||||
|
['default light', LIGHT_COLORS],
|
||||||
|
['default dark', DARK_COLORS],
|
||||||
|
...BUILTIN_THEMES.flatMap((theme): Array<[string, ThemePalette]> => [
|
||||||
|
[`${theme.name} light`, { ...LIGHT_COLORS, ...theme.light } as ThemePalette],
|
||||||
|
[`${theme.name} dark`, { ...DARK_COLORS, ...theme.dark } as ThemePalette],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('undo bar', () => {
|
||||||
|
it.each(palettes)('UNDO is readable on the bar (%s)', (_name, palette) => {
|
||||||
|
const { bar, action } = undoSnackbarColors(palette);
|
||||||
|
expect(contrast(action, bar)).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,9 +74,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>
|
||||||
{calendar
|
{calendar
|
||||||
|
|||||||
@@ -160,9 +160,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Users size={18} color={c.textMuted} />
|
<Users size={18} color={c.textMuted} />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Easing,
|
Easing,
|
||||||
Modal,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -24,6 +23,7 @@ import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
|||||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
/** A calendar view listed at the top of the drawer. */
|
/** A calendar view listed at the top of the drawer. */
|
||||||
export interface DrawerViewOption {
|
export interface DrawerViewOption {
|
||||||
@@ -157,7 +157,7 @@ export function CalendarSidebarDrawer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
@@ -271,7 +271,7 @@ export function CalendarSidebarDrawer({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Easing,
|
Easing,
|
||||||
Modal,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -63,6 +62,7 @@ import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrenc
|
|||||||
import type { TranslateFn } from '../../stores/locale-store';
|
import type { TranslateFn } from '../../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ export function EventDetailSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
@@ -464,7 +464,7 @@ export function EventDetailSheet({
|
|||||||
{/* The screen's own toast host sits under this Modal; this one shows
|
{/* The screen's own toast host sits under this Modal; this one shows
|
||||||
the copy confirmation above the sheet. */}
|
the copy confirmation above the sheet. */}
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -69,6 +68,7 @@ import { ParticipantInput } from './ParticipantInput';
|
|||||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
|
||||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
@@ -419,7 +419,7 @@ export function EventModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
presentationStyle="fullScreen"
|
presentationStyle="fullScreen"
|
||||||
@@ -838,7 +838,7 @@ export function EventModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
|
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
|
||||||
@@ -19,6 +19,7 @@ import { useCalendarLocale } from '../../lib/calendar-locale';
|
|||||||
import type { Locale } from 'date-fns';
|
import type { Locale } from 'date-fns';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||||
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
// linked into every build (matches the FilesScreen / ContactImportSheet flow).
|
||||||
@@ -188,7 +189,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -378,7 +379,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
|||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
|
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
// The webmail's refresh intervals and their labels.
|
// The webmail's refresh intervals and their labels.
|
||||||
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
||||||
@@ -135,7 +136,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -313,7 +314,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
|||||||
<View style={{ height: insets.bottom }} />
|
<View style={{ height: insets.bottom }} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Easing,
|
Easing,
|
||||||
Modal,
|
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -45,6 +44,7 @@ import { formatCalendarDate } from '../../lib/date-format';
|
|||||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ export function TasksSheet({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -480,7 +480,7 @@ export function TasksSheet({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
View, Text, StyleSheet, ScrollView, Pressable, Animated, Easing, Alert, ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
@@ -15,6 +15,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
// expo-document-picker is loaded lazily on first use; its native module is not
|
// expo-document-picker is loaded lazily on first use; its native module is not
|
||||||
// linked into every build (matches the FilesScreen upload flow).
|
// linked into every build (matches the FilesScreen upload flow).
|
||||||
@@ -164,7 +165,7 @@ export default function ContactImportSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -304,7 +305,7 @@ export default function ContactImportSheet({
|
|||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, Pressable, Modal, TextInput, FlatList,
|
View, Text, StyleSheet, Pressable, TextInput, FlatList,
|
||||||
Animated, Dimensions, Easing,
|
Animated, Dimensions, Easing,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -18,6 +18,7 @@ import { useColors } from '../../theme/colors';
|
|||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../../lib/use-layout';
|
import { useSheetInsets } from '../../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -98,7 +99,7 @@ export default function ContactPickerSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -179,7 +180,7 @@ export default function ContactPickerSheet({
|
|||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, Pressable, ScrollView, Modal, Animated, Dimensions, Easing, TextInput, Alert,
|
View, Text, StyleSheet, Pressable, ScrollView, Animated, Dimensions, Easing, TextInput, Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
@@ -19,6 +19,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose} onShow={runOpen}>
|
||||||
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
<Animated.View style={[styles.overlay, { opacity: overlay }]}>
|
||||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -310,7 +311,7 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
View, Text, StyleSheet, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -21,6 +21,7 @@ import { formatFullDateTime } from '../../lib/email-date';
|
|||||||
import type { EmlPreview } from './use-body-override';
|
import type { EmlPreview } from './use-body-override';
|
||||||
import { useFilePreview } from './use-file-preview';
|
import { useFilePreview } from './use-file-preview';
|
||||||
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
|
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
export interface PreviewItem {
|
export interface PreviewItem {
|
||||||
@@ -432,9 +433,9 @@ export function AttachmentPreviewModal({
|
|||||||
);
|
);
|
||||||
if (docked) return item || loading ? content : null;
|
if (docked) return item || loading ? content : null;
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||||
{content}
|
{content}
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,9 +160,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Icon size={18} color={c.textMuted} />
|
<Icon size={18} color={c.textMuted} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
|
View, Text, StyleSheet, ScrollView, Pressable, Alert,
|
||||||
KeyboardAvoidingView, Platform,
|
KeyboardAvoidingView, Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
FilterComparator,
|
FilterComparator,
|
||||||
FilterActionType,
|
FilterActionType,
|
||||||
} from '../../lib/sieve/types';
|
} from '../../lib/sieve/types';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
|
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
|
||||||
@@ -227,7 +228,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
|||||||
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
|
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -490,7 +491,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
|
View, Text, StyleSheet, ScrollView, Pressable, TextInput,
|
||||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
|||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { SafeAreaModal } from '../SafeAreaModal';
|
||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
|
|
||||||
interface SieveEditorSheetProps {
|
interface SieveEditorSheetProps {
|
||||||
@@ -63,7 +64,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
|||||||
}, [script, showSaveWarning, onSave]);
|
}, [script, showSaveWarning, onSave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -174,7 +175,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function AccountSettings() {
|
|||||||
const username = account?.username || authUsername || undefined;
|
const username = account?.username || authUsername || undefined;
|
||||||
const serverUrl = account?.serverUrl || authServerUrl || '';
|
const serverUrl = account?.serverUrl || authServerUrl || '';
|
||||||
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
|
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
|
||||||
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
const authMode: 'oauth' | 'token' | 'basic' = jmapClient.usesPastedToken
|
||||||
|
? 'token'
|
||||||
|
: jmapClient.usesBearerAuth ? 'oauth' : 'basic';
|
||||||
|
|
||||||
const [quota, setQuota] = useState<MailQuota | null>(null);
|
const [quota, setQuota] = useState<MailQuota | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,7 +130,9 @@ export function AccountSettings() {
|
|||||||
<Text style={styles.value}>
|
<Text style={styles.value}>
|
||||||
{authMode === 'oauth'
|
{authMode === 'oauth'
|
||||||
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
|
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
|
||||||
: t('settings.account.auth_method_basic', 'Password')}
|
: authMode === 'token'
|
||||||
|
? t('settings.account.auth_method_token', 'Access token')
|
||||||
|
: t('settings.account.auth_method_basic', 'Password')}
|
||||||
</Text>
|
</Text>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ export function ContentSendersSettings() {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setModalOpen(false)}
|
onRequestClose={() => setModalOpen(false)}
|
||||||
>
|
>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
|
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setOpen(false)}
|
onRequestClose={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setOpen(false)}>
|
||||||
<View style={styles.modalContent}>
|
<View style={styles.modalContent}>
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ThemePalette } from '../theme/tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colours of the undo bar. The bar is drawn in the text colour, so what is
|
||||||
|
* on it takes the background colour. The primary colour can't be used for
|
||||||
|
* UNDO: the default dark theme's primary is white, like the bar.
|
||||||
|
*/
|
||||||
|
export function undoSnackbarColors(c: ThemePalette): { bar: string; label: string; action: string } {
|
||||||
|
return { bar: c.text, label: c.background, action: c.background };
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createAfterDismissQueue } from '../after-modal-dismiss';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAfterDismissQueue', () => {
|
||||||
|
it('holds the action on iOS until the modal is dismissed', () => {
|
||||||
|
const queue = createAfterDismissQueue('ios');
|
||||||
|
const action = vi.fn();
|
||||||
|
queue.run(action);
|
||||||
|
expect(action).not.toHaveBeenCalled();
|
||||||
|
queue.flush();
|
||||||
|
expect(action).toHaveBeenCalledTimes(1);
|
||||||
|
queue.flush();
|
||||||
|
expect(action).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the action after the fallback delay when onDismiss never comes', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const queue = createAfterDismissQueue('ios', 600);
|
||||||
|
const action = vi.fn();
|
||||||
|
queue.run(action);
|
||||||
|
vi.advanceTimersByTime(599);
|
||||||
|
expect(action).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(action).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run the action twice when onDismiss and the timer both fire', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const queue = createAfterDismissQueue('ios', 600);
|
||||||
|
const action = vi.fn();
|
||||||
|
queue.run(action);
|
||||||
|
queue.flush();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(action).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the action right away on Android', () => {
|
||||||
|
const queue = createAfterDismissQueue('android');
|
||||||
|
const action = vi.fn();
|
||||||
|
queue.run(action);
|
||||||
|
expect(action).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The compose "Attach" sheet closed its modal and opened the photo picker,
|
||||||
|
// camera or file picker in the same tick. On iOS the picker never showed.
|
||||||
|
describe('compose options sheet', () => {
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||||
|
|
||||||
|
it('opens the chosen option only after the sheet is dismissed', () => {
|
||||||
|
expect(source).not.toMatch(/onClose\(\);\s*opt\.onPress\(\)/);
|
||||||
|
expect(source).toMatch(/onDismiss=\{afterDismiss\.flush\}/);
|
||||||
|
expect(source).toMatch(/afterDismiss\.run\(opt\.onPress\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildEditorHtml, FOCUS_END_SOURCE } from '../editor-html';
|
||||||
|
import { LIGHT_COLORS } from '../../theme/tokens';
|
||||||
|
|
||||||
|
// On the iPad most of the composer's body area is empty space under the
|
||||||
|
// editor; a tap there did nothing and the keyboard kept typing into Subject.
|
||||||
|
// That space now calls the page's focusEnd(), which must put the caret where
|
||||||
|
// the user's own text goes: in front of the signature or the quoted message.
|
||||||
|
|
||||||
|
interface FakeNode { name: string; previousElementSibling?: FakeNode | null }
|
||||||
|
|
||||||
|
function run(stop: FakeNode | null) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const editor = {
|
||||||
|
name: 'editor',
|
||||||
|
focus: () => calls.push('focus'),
|
||||||
|
querySelector: () => stop,
|
||||||
|
};
|
||||||
|
const range = {
|
||||||
|
selectNodeContents: (n: FakeNode) => calls.push(`select ${n.name}`),
|
||||||
|
setStartBefore: (n: FakeNode) => calls.push(`before ${n.name}`),
|
||||||
|
collapse: (toStart: boolean) => calls.push(toStart ? 'collapse start' : 'collapse end'),
|
||||||
|
};
|
||||||
|
const selection = {
|
||||||
|
removeAllRanges: () => calls.push('clear'),
|
||||||
|
addRange: (r: unknown) => calls.push(r === range ? 'add range' : 'add other'),
|
||||||
|
};
|
||||||
|
const fakeDocument = { createRange: () => range };
|
||||||
|
const fakeWindow = { getSelection: () => selection };
|
||||||
|
const focusEnd = new Function('document', 'window', `${FOCUS_END_SOURCE}; return focusEnd;`)(fakeDocument, fakeWindow);
|
||||||
|
focusEnd(editor);
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('focusEnd', () => {
|
||||||
|
it('puts the caret at the end of the paragraph in front of the signature', () => {
|
||||||
|
const mine = { name: 'mine' };
|
||||||
|
expect(run({ name: 'signature', previousElementSibling: mine })).toEqual([
|
||||||
|
'focus', 'select mine', 'collapse end', 'clear', 'add range',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the caret in front of a signature or quote that comes first', () => {
|
||||||
|
expect(run({ name: 'quote', previousElementSibling: null })).toEqual([
|
||||||
|
'focus', 'before quote', 'collapse start', 'clear', 'add range',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the caret at the end without signature or quote', () => {
|
||||||
|
expect(run(null)).toEqual(['focus', 'select editor', 'collapse end', 'clear', 'add range']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is part of the page script and of its bridge', () => {
|
||||||
|
const html = buildEditorHtml({ initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS });
|
||||||
|
expect(html).toContain('function focusEnd(editor)');
|
||||||
|
expect(html).toContain('focusEnd: function () { focusEnd(editor); }');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,14 @@ describe('describeLoginError', () => {
|
|||||||
expect(copy.detail).toMatch(/app password/i);
|
expect(copy.detail).toMatch(/app password/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blames a refused access token on the token, not on a password', () => {
|
||||||
|
for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) {
|
||||||
|
const copy = describeLoginError(err);
|
||||||
|
expect(copy.detail).toMatch(/access token/);
|
||||||
|
expect(copy.detail).not.toMatch(/password/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('explains the auth store\'s "Session expired" notice', () => {
|
it('explains the auth store\'s "Session expired" notice', () => {
|
||||||
expect(describeLoginError('Session expired').title).toBe('Your session has expired. Please sign in again.');
|
expect(describeLoginError('Session expired').title).toBe('Your session has expired. Please sign in again.');
|
||||||
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
|
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// scripts/perf/module-timings.js wraps Metro's module definitions and adds
|
||||||
|
// up each module's own evaluation time, without the time of the modules it
|
||||||
|
// required while it ran.
|
||||||
|
|
||||||
|
function install(clock: { t: number }) {
|
||||||
|
const g: Record<string, unknown> = { nativePerformanceNow: () => clock.t };
|
||||||
|
const factories = new Map<number, () => void>();
|
||||||
|
g.__d = (factory: () => void, id: number) => { factories.set(id, factory); };
|
||||||
|
const src = fs.readFileSync(path.resolve(__dirname, '../../../scripts/perf/module-timings.js'), 'utf8');
|
||||||
|
new Function('globalThis', src)(g);
|
||||||
|
(g.__d as (f: () => void, id: number) => void)(() => { clock.t += 5; }, 2);
|
||||||
|
(g.__d as (f: () => void, id: number) => void)(() => {
|
||||||
|
clock.t += 10;
|
||||||
|
factories.get(2)!();
|
||||||
|
clock.t += 3;
|
||||||
|
}, 1);
|
||||||
|
return { run: (id: number) => factories.get(id)!(), times: () => g.__bulwarkModuleTimes as Record<number, number> };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('module timings polyfill', () => {
|
||||||
|
it('counts a module without the modules it required', () => {
|
||||||
|
const clock = { t: 0 };
|
||||||
|
const m = install(clock);
|
||||||
|
m.run(1);
|
||||||
|
expect(m.times()).toEqual({ 1: 13, 2: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// The module reads its switch once at load, so each case loads it fresh
|
||||||
|
// with its own react-native mock.
|
||||||
|
async function load(os: 'ios' | 'android', setting: unknown) {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('react-native', () => ({
|
||||||
|
Platform: { OS: os },
|
||||||
|
Settings: { get: vi.fn(() => setting) },
|
||||||
|
}));
|
||||||
|
return import('../perf-marks');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('perf marks', () => {
|
||||||
|
let log: ReturnType<typeof vi.spyOn>;
|
||||||
|
beforeEach(() => {
|
||||||
|
log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
log.mockRestore();
|
||||||
|
vi.doUnmock('react-native');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs nothing without the launch argument', async () => {
|
||||||
|
const m = await load('ios', undefined);
|
||||||
|
m.perfMark('bundle-loaded');
|
||||||
|
m.perfStart('open-message');
|
||||||
|
m.perfEnd('open-message');
|
||||||
|
m.perfOnce('inbox-rendered');
|
||||||
|
expect(m.perfMarksEnabled()).toBe(false);
|
||||||
|
expect(log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays off on Android even when the setting is there', async () => {
|
||||||
|
const m = await load('android', 'YES');
|
||||||
|
m.perfMark('bundle-loaded');
|
||||||
|
expect(log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs marks and span durations with -perfMarks YES', async () => {
|
||||||
|
const m = await load('ios', 'YES');
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(1_000);
|
||||||
|
m.perfStart('open-message');
|
||||||
|
vi.setSystemTime(1_250);
|
||||||
|
m.perfEnd('open-message', { id: 'x' });
|
||||||
|
vi.useRealTimers();
|
||||||
|
expect(log).toHaveBeenCalledTimes(1);
|
||||||
|
const line = String(log.mock.calls[0][0]);
|
||||||
|
expect(line.startsWith('[perf] ')).toBe(true);
|
||||||
|
expect(JSON.parse(line.slice(7))).toEqual({ n: 'open-message', t: 1_250, ms: 250, id: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a once-mark only once and ignores an end without a start', async () => {
|
||||||
|
const m = await load('ios', true);
|
||||||
|
m.perfOnce('inbox-rendered', { rows: 3 });
|
||||||
|
m.perfOnce('inbox-rendered', { rows: 9 });
|
||||||
|
m.perfEnd('never-started');
|
||||||
|
expect(log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(log.mock.calls[0][0])).toContain('"rows":3');
|
||||||
|
});
|
||||||
|
it('logs the slowest modules once, with the total', async () => {
|
||||||
|
const m = await load('ios', true);
|
||||||
|
(globalThis as { __bulwarkModuleTimes?: Record<string, number> }).__bulwarkModuleTimes = { 7: 12.34, 3: 50, 9: 1 };
|
||||||
|
try {
|
||||||
|
m.logModuleTimes();
|
||||||
|
m.logModuleTimes();
|
||||||
|
const lines = log.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('module-times'));
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
const mark = JSON.parse(lines[0].slice('[perf] '.length));
|
||||||
|
expect(mark.total).toBe(63);
|
||||||
|
expect(mark.count).toBe(3);
|
||||||
|
expect(mark.top).toBe('3:50,7:12.3,9:1');
|
||||||
|
} finally {
|
||||||
|
delete (globalThis as { __bulwarkModuleTimes?: unknown }).__bulwarkModuleTimes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// iOS can't present a view controller (the photo picker, the camera, the
|
||||||
|
// document picker, a share sheet) while a React Native Modal is still
|
||||||
|
// dismissing: UIKit drops the request without an error and the picker never
|
||||||
|
// shows. A sheet whose option opens a picker has to wait until it is gone.
|
||||||
|
//
|
||||||
|
// run() keeps the action until flush(), which the Modal's onDismiss calls.
|
||||||
|
// A timer flushes too, in case onDismiss doesn't fire. Android has no such
|
||||||
|
// limit, so the action runs right away there.
|
||||||
|
|
||||||
|
export interface AfterDismissQueue {
|
||||||
|
run(action: () => void): void;
|
||||||
|
flush(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAfterDismissQueue(
|
||||||
|
platform: string,
|
||||||
|
fallbackMs = 600,
|
||||||
|
): AfterDismissQueue {
|
||||||
|
let pending: (() => void) | null = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
const action = pending;
|
||||||
|
pending = null;
|
||||||
|
action?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
run(action) {
|
||||||
|
if (platform !== 'ios') {
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
pending = action;
|
||||||
|
timer = setTimeout(flush, fallbackMs);
|
||||||
|
},
|
||||||
|
flush,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -64,6 +64,33 @@ export function shouldBlockEditorRemoteImages(opts: {
|
|||||||
return !senderTrusted;
|
return !senderTrusted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-script function for a tap on the empty space under the editor: focus
|
||||||
|
* it with the caret where the user's own text goes, in front of the
|
||||||
|
* signature or the quoted message, else at the end. A string, embedded in
|
||||||
|
* the page script and run by the unit test against stand-in DOM objects.
|
||||||
|
*/
|
||||||
|
export const FOCUS_END_SOURCE = `function focusEnd(editor) {
|
||||||
|
editor.focus();
|
||||||
|
var sel = window.getSelection();
|
||||||
|
if (!sel) return;
|
||||||
|
var stop = editor.querySelector('[data-signature-block="start"], [data-signature-block="separator"], [data-quoted-html]');
|
||||||
|
var range = document.createRange();
|
||||||
|
var prev = stop ? stop.previousElementSibling : null;
|
||||||
|
if (prev) {
|
||||||
|
range.selectNodeContents(prev);
|
||||||
|
range.collapse(false);
|
||||||
|
} else if (stop) {
|
||||||
|
range.setStartBefore(stop);
|
||||||
|
range.collapse(true);
|
||||||
|
} else {
|
||||||
|
range.selectNodeContents(editor);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}`;
|
||||||
|
|
||||||
export function buildEditorHtml(opts: {
|
export function buildEditorHtml(opts: {
|
||||||
initialHtml: string;
|
initialHtml: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -261,6 +288,8 @@ export function buildEditorHtml(opts: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||||
|
${FOCUS_END_SOURCE}
|
||||||
|
|
||||||
window.__rne = {
|
window.__rne = {
|
||||||
exec: function (command, value) {
|
exec: function (command, value) {
|
||||||
editor.focus();
|
editor.focus();
|
||||||
@@ -363,6 +392,7 @@ export function buildEditorHtml(opts: {
|
|||||||
post('htmlSnapshot', { id: id, html: editor.innerHTML });
|
post('htmlSnapshot', { id: id, html: editor.innerHTML });
|
||||||
},
|
},
|
||||||
focus: function () { editor.focus(); },
|
focus: function () { editor.focus(); },
|
||||||
|
focusEnd: function () { focusEnd(editor); },
|
||||||
};
|
};
|
||||||
|
|
||||||
reportHeight();
|
reportHeight();
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'InvalidTokenError' || lower.includes('invalid access token')) {
|
||||||
|
return {
|
||||||
|
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||||
|
detail: t('login.error.invalid_token', 'The server did not accept this access token. Check that it is complete and has not expired or been revoked.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
|
||||||
return {
|
return {
|
||||||
title: t('login.mobile.err_bad_title', "That didn't work"),
|
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Timing marks for the performance harness (scripts/e2e/perf.mjs).
|
||||||
|
//
|
||||||
|
// Off unless the app was launched with the `-perfMarks YES` launch argument
|
||||||
|
// (iOS puts launch arguments into NSUserDefaults, which React Native's
|
||||||
|
// Settings module reads). When on, every mark is one `[perf]` line on the
|
||||||
|
// console; on iOS that lands in the system log, where the harness reads it
|
||||||
|
// with `log show --info`. Nothing is stored or sent anywhere.
|
||||||
|
|
||||||
|
import { Platform, Settings } from 'react-native';
|
||||||
|
|
||||||
|
function readEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
if (Platform.OS !== 'ios') return false;
|
||||||
|
const value = Settings.get('perfMarks') as unknown;
|
||||||
|
return value === true || value === 'YES' || value === 'true' || value === 1 || value === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = readEnabled();
|
||||||
|
|
||||||
|
export function perfMarksEnabled(): boolean {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log a mark with the wall-clock time (the harness compares across processes). */
|
||||||
|
export function perfMark(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
console.log(`[perf] ${JSON.stringify({ n: name, t: Date.now(), ...detail })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = new Map<string, number>();
|
||||||
|
|
||||||
|
/** Start a timed span; `perfEnd` with the same name logs its duration. */
|
||||||
|
export function perfStart(name: string): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
started.set(name, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function perfEnd(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
const t0 = started.get(name);
|
||||||
|
if (t0 === undefined) return;
|
||||||
|
started.delete(name);
|
||||||
|
perfMark(name, { ms: Date.now() - t0, ...detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log how far into a running span a step got, without ending it. */
|
||||||
|
export function perfLap(name: string, lap: string): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
const t0 = started.get(name);
|
||||||
|
if (t0 === undefined) return;
|
||||||
|
perfMark(`${name}:${lap}`, { ms: Date.now() - t0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a span once the next frames are drawn: two animation frames after the
|
||||||
|
* state change that rendered the result, so the time includes commit and
|
||||||
|
* paint, not just the JS render.
|
||||||
|
*/
|
||||||
|
export function perfEndAfterPaint(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||||
|
if (!enabled || !started.has(name)) return;
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => perfEnd(name, detail)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let onceDone = new Set<string>();
|
||||||
|
/** A mark that is logged once per process (first inbox render, ...). */
|
||||||
|
export function perfOnce(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||||
|
if (!enabled || onceDone.has(name)) return;
|
||||||
|
onceDone.add(name);
|
||||||
|
perfMark(name, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modules whose own evaluation took longest so far, when the bundle was
|
||||||
|
* built with BULWARK_MODULE_TIMINGS (scripts/perf/module-timings.js): the
|
||||||
|
* 60 slowest as "id:ms" pairs, plus the total. Logged once, at first inbox.
|
||||||
|
*/
|
||||||
|
export function logModuleTimes(): void {
|
||||||
|
if (!enabled || onceDone.has('module-times')) return;
|
||||||
|
const times = (globalThis as { __bulwarkModuleTimes?: Record<string, number> }).__bulwarkModuleTimes;
|
||||||
|
if (!times) return;
|
||||||
|
onceDone.add('module-times');
|
||||||
|
const entries = Object.entries(times);
|
||||||
|
const total = entries.reduce((sum, [, ms]) => sum + ms, 0);
|
||||||
|
const top = entries
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 60)
|
||||||
|
.map(([id, ms]) => `${id}:${Math.round(ms * 10) / 10}`)
|
||||||
|
.join(',');
|
||||||
|
perfMark('module-times', { total: Math.round(total), count: entries.length, top });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook. */
|
||||||
|
export function resetPerfMarks(): void {
|
||||||
|
started.clear();
|
||||||
|
onceDone = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame monitor for scrolling: counts animation frames between `start` and
|
||||||
|
* `stop` and how many of them took longer than two vsyncs (a visible hitch
|
||||||
|
* at 60 Hz). JS-thread frames, so it shows JS work blocking during a scroll.
|
||||||
|
*/
|
||||||
|
export function createFrameMonitor(name: string): { start: () => void; stop: () => void } {
|
||||||
|
let raf: number | null = null;
|
||||||
|
let last = 0;
|
||||||
|
let frames = 0;
|
||||||
|
let long = 0;
|
||||||
|
let worst = 0;
|
||||||
|
let t0 = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (last) {
|
||||||
|
const dt = now - last;
|
||||||
|
frames += 1;
|
||||||
|
if (dt > 34) long += 1;
|
||||||
|
if (dt > worst) worst = dt;
|
||||||
|
}
|
||||||
|
last = now;
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (!enabled || raf !== null) return;
|
||||||
|
frames = 0;
|
||||||
|
long = 0;
|
||||||
|
worst = 0;
|
||||||
|
last = 0;
|
||||||
|
t0 = Date.now();
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (raf === null) return;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = null;
|
||||||
|
perfMark(name, { ms: Date.now() - t0, frames, long, worst: Math.round(worst) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { getEmailInitials } from '../lib/avatar-utils';
|
|||||||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
||||||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||||
import { useLayout } from '../lib/use-layout';
|
import { useLayout } from '../lib/use-layout';
|
||||||
|
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useHasContacts } from '../lib/capabilities';
|
import { useHasContacts } from '../lib/capabilities';
|
||||||
@@ -375,16 +376,26 @@ function OptionsSheet({
|
|||||||
}) {
|
}) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
// An option may open a system picker, which iOS only shows once this
|
||||||
|
// modal is gone.
|
||||||
|
const afterDismiss = React.useRef(createAfterDismissQueue(Platform.OS)).current;
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal
|
||||||
<Pressable style={styles.modalBackdrop} onPress={onClose}>
|
supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onDismiss={afterDismiss.flush}
|
||||||
|
>
|
||||||
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
|
||||||
|
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||||
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
|
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={opt.label}
|
key={opt.label}
|
||||||
style={styles.scheduleRow}
|
style={styles.scheduleRow}
|
||||||
onPress={() => { onClose(); opt.onPress(); }}
|
onPress={() => { onClose(); afterDismiss.run(opt.onPress); }}
|
||||||
>
|
>
|
||||||
<Text style={[styles.scheduleRowLabel, opt.destructive && { color: c.error }]}>{opt.label}</Text>
|
<Text style={[styles.scheduleRowLabel, opt.destructive && { color: c.error }]}>{opt.label}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -2484,6 +2495,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
onViewportShift={onEditorViewportShift}
|
onViewportShift={onEditorViewportShift}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* The empty space under the body looks like part of it (on a
|
||||||
|
tablet, most of the panel): a tap there starts typing in it. */}
|
||||||
|
<Pressable
|
||||||
|
style={styles.bodyFiller}
|
||||||
|
accessible={false}
|
||||||
|
onPress={() => {
|
||||||
|
if (plainTextMode) plainBodyRef.current?.focus();
|
||||||
|
else editorRef.current?.focusEnd();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* A phone in landscape with the keyboard up has room for a few lines
|
{/* A phone in landscape with the keyboard up has room for a few lines
|
||||||
@@ -2647,8 +2668,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
{/* Text / background colour palette */}
|
{/* Text / background colour palette */}
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>
|
||||||
{colorTarget === 'background'
|
{colorTarget === 'background'
|
||||||
? t('email_composer.toolbar.background_color', 'Background color')
|
? t('email_composer.toolbar.background_color', 'Background color')
|
||||||
@@ -2716,8 +2737,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setScheduleSheetOpen(false)}
|
onRequestClose={() => setScheduleSheetOpen(false)}
|
||||||
>
|
>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>
|
||||||
{t('email_composer.schedule_send', 'Schedule send')}
|
{t('email_composer.schedule_send', 'Schedule send')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -2750,8 +2771,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
{customStage !== null && Platform.OS === 'ios' && (
|
{customStage !== null && Platform.OS === 'ios' && (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={customDraftRef.current}
|
value={customDraftRef.current}
|
||||||
mode="datetime"
|
mode="datetime"
|
||||||
@@ -2980,6 +3001,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
backgroundColor: c.background,
|
backgroundColor: c.background,
|
||||||
},
|
},
|
||||||
scrollContent: { flexGrow: 1 },
|
scrollContent: { flexGrow: 1 },
|
||||||
|
bodyFiller: { flexGrow: 1, minHeight: 48 },
|
||||||
|
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share,
|
View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Image, Share,
|
||||||
Animated, Dimensions, Easing, Modal,
|
Animated, Dimensions, Easing, } from 'react-native';
|
||||||
} from 'react-native';
|
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@@ -35,6 +34,7 @@ import { useColors } from '../theme/colors';
|
|||||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
import { useSheetInsets } from '../lib/use-layout';
|
import { useSheetInsets } from '../lib/use-layout';
|
||||||
|
import { SafeAreaModal } from '../components/SafeAreaModal';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
|
||||||
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
|
||||||
@@ -814,7 +814,7 @@ function MoreActionsSheet({
|
|||||||
}, [visible, slideY, overlayOpacity]);
|
}, [visible, slideY, overlayOpacity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
|
||||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||||
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -846,7 +846,7 @@ function MoreActionsSheet({
|
|||||||
})}
|
})}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</SafeAreaModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1254,6 +1254,7 @@ export default function ContactFormScreen() {
|
|||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder={t('contacts.form.given_name', 'First name')}
|
placeholder={t('contacts.form.given_name', 'First name')}
|
||||||
ref={givenRef}
|
ref={givenRef}
|
||||||
|
testID="contact-first-name"
|
||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
onSubmitEditing={() => middleRef.current?.focus()}
|
onSubmitEditing={() => middleRef.current?.focus()}
|
||||||
@@ -1280,6 +1281,7 @@ export default function ContactFormScreen() {
|
|||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder={t('contacts.form.surname', 'Last name')}
|
placeholder={t('contacts.form.surname', 'Last name')}
|
||||||
ref={surnameRef}
|
ref={surnameRef}
|
||||||
|
testID="contact-last-name"
|
||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
onSubmitEditing={() => fullRef.current?.focus()}
|
onSubmitEditing={() => fullRef.current?.focus()}
|
||||||
@@ -1347,6 +1349,7 @@ export default function ContactFormScreen() {
|
|||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
|
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
|
||||||
ref={i === 0 ? firstEmailRef : undefined}
|
ref={i === 0 ? firstEmailRef : undefined}
|
||||||
|
testID={`contact-email-${i}`}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
|||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
||||||
import type { Attachment, Email } from '../api/types';
|
import type { Attachment, Email } from '../api/types';
|
||||||
|
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
|
|
||||||
function getSenderName(email: Email, unknownLabel: string): string {
|
function getSenderName(email: Email, unknownLabel: string): string {
|
||||||
@@ -473,6 +474,17 @@ export default function EmailListScreen({
|
|||||||
() => collapseThreads(emails, disableThreading),
|
() => collapseThreads(emails, disableThreading),
|
||||||
[emails, disableThreading],
|
[emails, disableThreading],
|
||||||
);
|
);
|
||||||
|
// Performance harness (off unless launched with -perfMarks YES).
|
||||||
|
const scrollMonitor = React.useMemo(() => createFrameMonitor('list-scroll'), []);
|
||||||
|
React.useEffect(() => { perfOnce('list-mounted'); }, []);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (visibleEmails.length > 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
perfOnce('inbox-rendered', { rows: visibleEmails.length });
|
||||||
|
logModuleTimes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visibleEmails.length]);
|
||||||
const threadGroups = React.useMemo(
|
const threadGroups = React.useMemo(
|
||||||
() => groupByThread(emails, disableThreading),
|
() => groupByThread(emails, disableThreading),
|
||||||
[emails, disableThreading],
|
[emails, disableThreading],
|
||||||
@@ -645,7 +657,10 @@ export default function EmailListScreen({
|
|||||||
const email = emailsRef.current.find((e) => rowKeyOf(e) === id);
|
const email = emailsRef.current.find((e) => rowKeyOf(e) === id);
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email);
|
if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email);
|
||||||
else onEmailPressRef.current?.(email);
|
else {
|
||||||
|
perfStart('open-message');
|
||||||
|
onEmailPressRef.current?.(email);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [toggleSelect]);
|
}, [toggleSelect]);
|
||||||
|
|
||||||
@@ -1563,6 +1578,8 @@ export default function EmailListScreen({
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
|
onScrollBeginDrag={scrollMonitor.start}
|
||||||
|
onMomentumScrollEnd={scrollMonitor.stop}
|
||||||
refreshing={loading}
|
refreshing={loading}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
@@ -1570,7 +1587,10 @@ export default function EmailListScreen({
|
|||||||
|
|
||||||
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onComposePress}
|
onPress={() => {
|
||||||
|
perfStart('compose-open');
|
||||||
|
onComposePress?.();
|
||||||
|
}}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.fab,
|
styles.fab,
|
||||||
// Lifted over the phone's undo bar, which used to cover it.
|
// Lifted over the phone's undo bar, which used to cover it.
|
||||||
@@ -1593,9 +1613,9 @@ export default function EmailListScreen({
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={() => setFilterMenuOpen(false)}
|
onRequestClose={() => setFilterMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<TouchableWithoutFeedback onPress={() => setFilterMenuOpen(false)}>
|
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
|
||||||
<View style={styles.filterBackdrop}>
|
<View style={styles.filterBackdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.filterMenu}>
|
<View style={styles.filterMenu}>
|
||||||
<View style={styles.filterMenuHeader}>
|
<View style={styles.filterMenuHeader}>
|
||||||
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
||||||
|
|||||||
@@ -1179,9 +1179,9 @@ function PromptModal(props: PromptModalProps) {
|
|||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={props.onCancel}
|
onRequestClose={props.onCancel}
|
||||||
>
|
>
|
||||||
<TouchableWithoutFeedback onPress={props.onCancel}>
|
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.dialog}>
|
<View style={styles.dialog}>
|
||||||
<Text style={styles.title}>{props.title}</Text>
|
<Text style={styles.title}>{props.title}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -1246,9 +1246,9 @@ function ActionsSheet({
|
|||||||
const modified = modifiedOf(target);
|
const modified = modifiedOf(target);
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||||
{target.displayName}
|
{target.displayName}
|
||||||
@@ -1370,9 +1370,9 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
|
|||||||
const currentParent = target.parentId ?? null;
|
const currentParent = target.parentId ?? null;
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||||
{t('files.move_to', 'Move to…')} · {target.displayName}
|
{t('files.move_to', 'Move to…')} · {target.displayName}
|
||||||
@@ -1427,9 +1427,9 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
|
|||||||
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
|
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
|
||||||
return (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||||
<TouchableWithoutFeedback>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
|
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
|
||||||
{keys.map((k) => (
|
{keys.map((k) => (
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ function signInLinkConfirmation(
|
|||||||
*/
|
*/
|
||||||
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
|
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
|
const loginWithToken = useAuthStore((state) => state.loginWithToken);
|
||||||
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
|
||||||
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
|
||||||
const clearError = useAuthStore((state) => state.clearError);
|
const clearError = useAuthStore((state) => state.clearError);
|
||||||
@@ -102,6 +103,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
// common no-2FA sign-in stays a two-field form.
|
// common no-2FA sign-in stays a two-field form.
|
||||||
const [totp, setTotp] = React.useState('');
|
const [totp, setTotp] = React.useState('');
|
||||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||||
|
// Access-token (Bearer) sign-in in place of username and password, for
|
||||||
|
// providers that hand out API tokens (Fastmail accepts nothing else).
|
||||||
|
const [tokenMode, setTokenMode] = React.useState(false);
|
||||||
|
const [accessToken, setAccessToken] = React.useState('');
|
||||||
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
|
||||||
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
@@ -261,6 +266,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
setPassword(pending.password);
|
setPassword(pending.password);
|
||||||
setServerUrl(pending.serverUrl);
|
setServerUrl(pending.serverUrl);
|
||||||
setTotpRequired(true);
|
setTotpRequired(true);
|
||||||
|
setTokenMode(false);
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||||
setHistory((prev) => [...prev, step]);
|
setHistory((prev) => [...prev, step]);
|
||||||
@@ -340,6 +346,26 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
setNotice({ title: t('login.mobile.notice_need_server', 'We need a server address first') });
|
setNotice({ title: t('login.mobile.notice_need_server', 'We need a server address first') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tokenMode) {
|
||||||
|
if (!accessToken.trim()) {
|
||||||
|
setNotice({ title: t('login.token_placeholder', 'Paste your API token') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNotice(null);
|
||||||
|
setBusy('connecting');
|
||||||
|
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
|
||||||
|
try {
|
||||||
|
await loginWithToken(target, accessToken, { addAccount: isAddMode });
|
||||||
|
finishIfSignedIn(wasAuthenticated);
|
||||||
|
} catch (err) {
|
||||||
|
setNotice(describeLoginError(err, { serverUrl: target, t }));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!email.trim() || !password) {
|
if (!email.trim() || !password) {
|
||||||
setNotice({ title: t('login.mobile.notice_need_credentials', 'Enter your email and password') });
|
setNotice({ title: t('login.mobile.notice_need_credentials', 'Enter your email and password') });
|
||||||
return;
|
return;
|
||||||
@@ -369,7 +395,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]);
|
}, [accessToken, email, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]);
|
||||||
|
|
||||||
// A sign-in code, however it arrived: scanned, pasted, or a tapped
|
// A sign-in code, however it arrived: scanned, pasted, or a tapped
|
||||||
// `bulwarkmail://` link.
|
// `bulwarkmail://` link.
|
||||||
@@ -517,7 +543,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
<SigningInStep
|
<SigningInStep
|
||||||
phase={busy}
|
phase={busy}
|
||||||
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
|
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
|
||||||
email={pairing ? null : email.trim() || null}
|
email={pairing || tokenMode ? null : email.trim() || null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -655,6 +681,17 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
}}
|
}}
|
||||||
onSubmit={() => void handlePasswordSubmit()}
|
onSubmit={() => void handlePasswordSubmit()}
|
||||||
notice={notice}
|
notice={notice}
|
||||||
|
tokenMode={tokenMode}
|
||||||
|
token={accessToken}
|
||||||
|
onChangeToken={(value) => {
|
||||||
|
setNotice(null);
|
||||||
|
setAccessToken(value);
|
||||||
|
}}
|
||||||
|
onToggleTokenMode={() => {
|
||||||
|
setNotice(null);
|
||||||
|
clearError();
|
||||||
|
setTokenMode((v) => !v);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</LoginShell>
|
</LoginShell>
|
||||||
|
|||||||
@@ -326,8 +326,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
|
|
||||||
{/* Per-message actions */}
|
{/* Per-message actions */}
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||||
<Pressable style={styles.card} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
<Text style={styles.cardTitle} numberOfLines={2}>
|
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||||
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
|
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -349,8 +349,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
|
|
||||||
{/* Reschedule presets */}
|
{/* Reschedule presets */}
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||||
<Pressable style={styles.card} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
||||||
{reschedulePresets.map((preset) => (
|
{reschedulePresets.map((preset) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -379,8 +379,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
|
|
||||||
{customStage !== null && Platform.OS === 'ios' && (
|
{customStage !== null && Platform.OS === 'ios' && (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||||
<Pressable style={styles.card} onPress={() => {}}>
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={customDraftRef.current}
|
value={customDraftRef.current}
|
||||||
mode="datetime"
|
mode="datetime"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, Pressable, StyleSheet, type TextInput } from 'react-native';
|
import { View, Text, Pressable, StyleSheet, type TextInput } from 'react-native';
|
||||||
import { Eye, EyeOff } from '../../components/icons';
|
import { Eye, EyeOff, KeyRound } from '../../components/icons';
|
||||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import { Button, Input } from '../../components';
|
import { Button, Input } from '../../components';
|
||||||
@@ -19,6 +19,11 @@ interface PasswordStepProps {
|
|||||||
onChangeTotp?: (value: string) => void;
|
onChangeTotp?: (value: string) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
notice?: { title: string; detail?: string } | null;
|
notice?: { title: string; detail?: string } | null;
|
||||||
|
/** Access-token sign-in in place of username and password. */
|
||||||
|
tokenMode?: boolean;
|
||||||
|
token?: string;
|
||||||
|
onChangeToken?: (value: string) => void;
|
||||||
|
onToggleTokenMode?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostOf(url: string): string {
|
function hostOf(url: string): string {
|
||||||
@@ -38,6 +43,10 @@ export default function PasswordStep({
|
|||||||
onChangeTotp,
|
onChangeTotp,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
notice,
|
notice,
|
||||||
|
tokenMode = false,
|
||||||
|
token = '',
|
||||||
|
onChangeToken,
|
||||||
|
onToggleTokenMode,
|
||||||
}: PasswordStepProps) {
|
}: PasswordStepProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -52,74 +61,107 @@ export default function PasswordStep({
|
|||||||
<View style={styles.heading}>
|
<View style={styles.heading}>
|
||||||
<Text style={styles.title}>{t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}</Text>
|
<Text style={styles.title}>{t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
{t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
|
{tokenMode
|
||||||
|
? t('login.token_hint', "Create an API token in your mail provider's settings and paste it here.")
|
||||||
|
: t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Input
|
{tokenMode ? (
|
||||||
label={t('login.mobile.username_label', 'Email or username')}
|
|
||||||
placeholder={t('login.username_placeholder', 'user@example.com')}
|
|
||||||
value={email}
|
|
||||||
onChangeText={onChangeEmail}
|
|
||||||
testID="login-username-input"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
autoComplete="email"
|
|
||||||
textContentType="username"
|
|
||||||
keyboardType="email-address"
|
|
||||||
returnKeyType="next"
|
|
||||||
blurOnSubmit={false}
|
|
||||||
onSubmitEditing={() => passwordRef.current?.focus()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label={t('login.password_label', 'Password')}
|
|
||||||
placeholder={t('login.password_placeholder', 'Enter your password')}
|
|
||||||
value={password}
|
|
||||||
onChangeText={onChangePassword}
|
|
||||||
testID="login-password-input"
|
|
||||||
autoFocus={Boolean(email)}
|
|
||||||
secureTextEntry={!showPassword}
|
|
||||||
autoComplete="current-password"
|
|
||||||
textContentType="password"
|
|
||||||
ref={passwordRef}
|
|
||||||
returnKeyType={totpRequired ? 'next' : 'go'}
|
|
||||||
blurOnSubmit={!totpRequired}
|
|
||||||
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
|
|
||||||
rightIcon={
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowPassword((v) => !v)}
|
|
||||||
hitSlop={14}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
|
||||||
</Pressable>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{totpRequired ? (
|
|
||||||
<Input
|
<Input
|
||||||
label={t('login.totp_label', 'Two-factor code')}
|
label={t('login.token_label', 'Access token')}
|
||||||
placeholder="123456"
|
placeholder={t('login.token_placeholder', 'Paste your API token')}
|
||||||
value={totp}
|
value={token}
|
||||||
ref={totpRef}
|
onChangeText={(value) => onChangeToken?.(value)}
|
||||||
onChangeText={(value) => onChangeTotp?.(value)}
|
testID="login-token-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
keyboardType="number-pad"
|
secureTextEntry
|
||||||
autoComplete="one-time-code"
|
autoCapitalize="none"
|
||||||
textContentType="oneTimeCode"
|
autoCorrect={false}
|
||||||
maxLength={8}
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
returnKeyType="go"
|
returnKeyType="go"
|
||||||
onSubmitEditing={onSubmit}
|
onSubmitEditing={onSubmit}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{tokenMode ? null : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t('login.mobile.username_label', 'Email or username')}
|
||||||
|
placeholder={t('login.username_placeholder', 'user@example.com')}
|
||||||
|
value={email}
|
||||||
|
onChangeText={onChangeEmail}
|
||||||
|
testID="login-username-input"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="email"
|
||||||
|
textContentType="username"
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="next"
|
||||||
|
blurOnSubmit={false}
|
||||||
|
onSubmitEditing={() => passwordRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t('login.password_label', 'Password')}
|
||||||
|
placeholder={t('login.password_placeholder', 'Enter your password')}
|
||||||
|
value={password}
|
||||||
|
onChangeText={onChangePassword}
|
||||||
|
testID="login-password-input"
|
||||||
|
autoFocus={Boolean(email)}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoComplete="current-password"
|
||||||
|
textContentType="password"
|
||||||
|
ref={passwordRef}
|
||||||
|
returnKeyType={totpRequired ? 'next' : 'go'}
|
||||||
|
blurOnSubmit={!totpRequired}
|
||||||
|
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
|
||||||
|
rightIcon={
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowPassword((v) => !v)}
|
||||||
|
hitSlop={14}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totpRequired ? (
|
||||||
|
<Input
|
||||||
|
label={t('login.totp_label', 'Two-factor code')}
|
||||||
|
placeholder="123456"
|
||||||
|
value={totp}
|
||||||
|
ref={totpRef}
|
||||||
|
onChangeText={(value) => onChangeTotp?.(value)}
|
||||||
|
autoFocus
|
||||||
|
keyboardType="number-pad"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
textContentType="oneTimeCode"
|
||||||
|
maxLength={8}
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={onSubmit}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
|
||||||
|
|
||||||
<Button variant="default" size="md" onPress={onSubmit}>
|
<Button variant="default" size="md" onPress={onSubmit}>
|
||||||
{t('login.sign_in', 'Sign in')}
|
{t('login.sign_in', 'Sign in')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{onToggleTokenMode ? (
|
||||||
|
<Pressable onPress={onToggleTokenMode} hitSlop={8} style={styles.toggle} accessibilityRole="button" testID="login-token-toggle">
|
||||||
|
<KeyRound size={16} color={c.textLink} />
|
||||||
|
<Text style={styles.link}>
|
||||||
|
{tokenMode ? t('login.password_toggle', 'Sign in with a password') : t('login.token_toggle', 'Sign in with an access token')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,5 +172,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
heading: { gap: spacing.sm },
|
heading: { gap: spacing.sm },
|
||||||
title: { ...typography.h1, color: c.text },
|
title: { ...typography.h1, color: c.text },
|
||||||
subtitle: { ...typography.body, color: c.textSecondary },
|
subtitle: { ...typography.body, color: c.textSecondary },
|
||||||
|
toggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
|
||||||
|
link: { ...typography.body, color: c.textLink },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Access-token sign-in: the pasted token is cleaned up before it is sent, the
|
||||||
|
// account is named after the session, and a refused token leaves nothing
|
||||||
|
// registered.
|
||||||
|
|
||||||
|
const { InvalidTokenError, SESSION, connectWithToken } = vi.hoisted(() => {
|
||||||
|
class InvalidTokenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid access token');
|
||||||
|
this.name = 'InvalidTokenError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const SESSION = { apiUrl: 'https://api.fastmail.com/jmap/api/', username: 'ada@fastmail.com' };
|
||||||
|
const connectWithToken = vi.fn(async (_serverUrl: string, _token: string) => ({
|
||||||
|
session: SESSION,
|
||||||
|
username: 'ada@fastmail.com',
|
||||||
|
accountId: 'ada@fastmail.com@api.fastmail.com',
|
||||||
|
}));
|
||||||
|
return { InvalidTokenError, SESSION, connectWithToken };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
|
jmapClient: {
|
||||||
|
connectWithToken: (serverUrl: string, token: string) => connectWithToken(serverUrl, token),
|
||||||
|
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
|
||||||
|
restoreSnapshot: vi.fn(),
|
||||||
|
onAuthFailure: vi.fn(() => () => undefined),
|
||||||
|
onTokenRefresh: vi.fn(() => () => undefined),
|
||||||
|
hasAccountCapability: vi.fn(() => false),
|
||||||
|
getAccountName: () => undefined,
|
||||||
|
getSharedMailAccounts: () => [],
|
||||||
|
request: vi.fn(async () => ({ methodResponses: [] })),
|
||||||
|
accountId: 'acc-1',
|
||||||
|
isConnected: true,
|
||||||
|
currentSession: SESSION,
|
||||||
|
username: 'ada@fastmail.com',
|
||||||
|
serverUrl: 'https://api.fastmail.com',
|
||||||
|
},
|
||||||
|
AuthenticationError: class AuthenticationError extends Error {},
|
||||||
|
NetworkError: class NetworkError extends Error {},
|
||||||
|
InvalidTokenError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/push-notifications', () => ({
|
||||||
|
teardownPushNotifications: vi.fn(async () => undefined),
|
||||||
|
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useAuthStore } from '../auth-store';
|
||||||
|
import { useAccountStore } from '../account-store';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
connectWithToken.mockClear();
|
||||||
|
useAccountStore.setState({ accounts: [], activeAccountId: null });
|
||||||
|
useAuthStore.setState({ isAuthenticated: false, error: null, activeAccountId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loginWithToken', () => {
|
||||||
|
it('sends the bare token and registers the account the session names', async () => {
|
||||||
|
await useAuthStore.getState().loginWithToken('https://api.fastmail.com/', ' Bearer fmu1-abc ');
|
||||||
|
|
||||||
|
expect(connectWithToken).toHaveBeenCalledWith('https://api.fastmail.com', 'fmu1-abc');
|
||||||
|
const account = useAccountStore.getState().getActiveAccount();
|
||||||
|
expect(account?.id).toBe('ada@fastmail.com@api.fastmail.com');
|
||||||
|
expect(account?.serverUrl).toBe('https://api.fastmail.com');
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
isAuthenticated: true,
|
||||||
|
username: 'ada@fastmail.com',
|
||||||
|
activeAccountId: 'ada@fastmail.com@api.fastmail.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a token with spaces inside without asking the server', async () => {
|
||||||
|
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1 abc'))
|
||||||
|
.rejects.toThrow(InvalidTokenError);
|
||||||
|
expect(connectWithToken).not.toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().error).toBe('Invalid access token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers nothing when the server refuses the token', async () => {
|
||||||
|
connectWithToken.mockRejectedValueOnce(new InvalidTokenError());
|
||||||
|
|
||||||
|
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1-expired'))
|
||||||
|
.rejects.toThrow(InvalidTokenError);
|
||||||
|
expect(useAccountStore.getState().accounts).toHaveLength(0);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -129,6 +129,31 @@ describe('auth-store', () => {
|
|||||||
expect(state.serverUrl).toBeNull();
|
expect(state.serverUrl).toBeNull();
|
||||||
expect(state.username).toBeNull();
|
expect(state.username).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// logout used to read the account list from a snapshot taken before it
|
||||||
|
// removed the account: the removed account still looked like one to
|
||||||
|
// switch to, the "switch" to the active account returned at once, and
|
||||||
|
// signing out of the only account left the app signed in.
|
||||||
|
it('signs out of the only account', async () => {
|
||||||
|
const id = 'me@mail.example.com';
|
||||||
|
useAccountStore.setState({
|
||||||
|
accounts: [{
|
||||||
|
id, serverUrl: 'https://mail.example.com', username: 'me',
|
||||||
|
displayName: 'me', email: 'me', avatarColor: '#000', lastLoginAt: 0,
|
||||||
|
isConnected: true, hasError: false, isDefault: true,
|
||||||
|
}],
|
||||||
|
activeAccountId: id,
|
||||||
|
defaultAccountId: id,
|
||||||
|
});
|
||||||
|
useAuthStore.setState({ isAuthenticated: true, activeAccountId: id, serverUrl: 'x', username: 'y' });
|
||||||
|
|
||||||
|
await useAuthStore.getState().logout();
|
||||||
|
|
||||||
|
expect(useAccountStore.getState().accounts).toEqual([]);
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
expect(state.activeAccountId).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mail the viewer held in memory', () => {
|
describe('mail the viewer held in memory', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { jmapClient, AuthenticationError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
import { jmapClient, AuthenticationError, InvalidTokenError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
|
||||||
import type { JMAPSession } from '../api/types';
|
import type { JMAPSession } from '../api/types';
|
||||||
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
|
||||||
import { useAccountStore } from './account-store';
|
import { useAccountStore } from './account-store';
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
teardownPushNotificationsForAccount,
|
teardownPushNotificationsForAccount,
|
||||||
} from '../lib/push-notifications';
|
} from '../lib/push-notifications';
|
||||||
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
||||||
|
import { perfMark } from '../lib/perf-marks';
|
||||||
|
|
||||||
// Persist middleware hydrates asynchronously on cold start. Without this
|
// Persist middleware hydrates asynchronously on cold start. Without this
|
||||||
// guard, restoreSession() can read the account-store before AsyncStorage has
|
// guard, restoreSession() can read the account-store before AsyncStorage has
|
||||||
@@ -85,6 +86,8 @@ export interface AuthState {
|
|||||||
password: string,
|
password: string,
|
||||||
opts?: { addAccount?: boolean; totp?: string },
|
opts?: { addAccount?: boolean; totp?: string },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
/** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */
|
||||||
|
loginWithToken: (serverUrl: string, token: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
|
||||||
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
|
||||||
@@ -254,11 +257,24 @@ async function syncAccountDisplayName(accountId: string): Promise<void> {
|
|||||||
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
// pairing both end here). Bootstraps a JMAP session from the token bundle,
|
||||||
// registers the account, and flips the store to connected. Throws on failure
|
// registers the account, and flips the store to connected. Throws on failure
|
||||||
// so the caller can surface a flow-specific error.
|
// so the caller can surface a flow-specific error.
|
||||||
async function completeOAuthHandoff(
|
function completeOAuthHandoff(
|
||||||
set: (partial: Partial<AuthState>) => void,
|
set: (partial: Partial<AuthState>) => void,
|
||||||
get: () => AuthState,
|
get: () => AuthState,
|
||||||
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
result: Extract<HandoffResult, { flow: 'oauth' }>,
|
||||||
opts?: { addAccount?: boolean },
|
opts?: { addAccount?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
return completeBearerSignIn(set, get, result.serverUrl, () => jmapClient.connectWithOAuth(result.serverUrl, result.tokens), opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connects with `connect` (an OAuth bundle or a pasted access token, where the
|
||||||
|
// server names the account), registers the account, and flips the store to
|
||||||
|
// connected.
|
||||||
|
async function completeBearerSignIn(
|
||||||
|
set: (partial: Partial<AuthState>) => void,
|
||||||
|
get: () => AuthState,
|
||||||
|
serverUrl: string,
|
||||||
|
connect: () => Promise<{ session: JMAPSession; username: string; accountId: string }>,
|
||||||
|
opts?: { addAccount?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Adding an account must not destroy the live one: the singleton keeps the
|
// Adding an account must not destroy the live one: the singleton keeps the
|
||||||
// previous connection until the new sign-in has actually succeeded, and a
|
// previous connection until the new sign-in has actually succeeded, and a
|
||||||
@@ -267,7 +283,7 @@ async function completeOAuthHandoff(
|
|||||||
|
|
||||||
let connected: { session: JMAPSession; username: string; accountId: string };
|
let connected: { session: JMAPSession; username: string; accountId: string };
|
||||||
try {
|
try {
|
||||||
connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
|
connected = await connect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (previous) jmapClient.restoreSnapshot(previous);
|
if (previous) jmapClient.restoreSnapshot(previous);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -278,7 +294,7 @@ async function completeOAuthHandoff(
|
|||||||
const wasRegistered = !!accountStore.getAccountById(accountId);
|
const wasRegistered = !!accountStore.getAccountById(accountId);
|
||||||
try {
|
try {
|
||||||
accountStore.addAccount({
|
accountStore.addAccount({
|
||||||
serverUrl: result.serverUrl.replace(/\/+$/, ''),
|
serverUrl: serverUrl.replace(/\/+$/, ''),
|
||||||
username,
|
username,
|
||||||
displayName: username,
|
displayName: username,
|
||||||
email: username,
|
email: username,
|
||||||
@@ -299,7 +315,7 @@ async function completeOAuthHandoff(
|
|||||||
accountStore.setActiveAccount(accountId);
|
accountStore.setActiveAccount(accountId);
|
||||||
useEmailStore.getState().setActiveAccount(accountId);
|
useEmailStore.getState().setActiveAccount(accountId);
|
||||||
|
|
||||||
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
|
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
|
||||||
// Start on the folder list now rather than once the mail screen has
|
// Start on the folder list now rather than once the mail screen has
|
||||||
// mounted; the screen joins this load.
|
// mounted; the screen joins this load.
|
||||||
void useEmailStore.getState().fetchMailboxes();
|
void useEmailStore.getState().fetchMailboxes();
|
||||||
@@ -413,6 +429,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loginWithToken: async (serverUrl, typedToken, opts) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
// Pasted tokens often come with the scheme or surrounding whitespace.
|
||||||
|
const token = typedToken.trim().replace(/^Bearer\s+/i, '');
|
||||||
|
const base = serverUrl.replace(/\/+$/, '');
|
||||||
|
try {
|
||||||
|
if (!token || /\s/.test(token)) throw new InvalidTokenError();
|
||||||
|
await completeBearerSignIn(set, get, base, () => jmapClient.connectWithToken(base, token), opts);
|
||||||
|
set({ pendingTotpLogin: null });
|
||||||
|
} catch (err) {
|
||||||
|
set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loginViaWebmail: async (webmailUrl, opts) => {
|
loginViaWebmail: async (webmailUrl, opts) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
refuseAddWhenFull(set, opts);
|
refuseAddWhenFull(set, opts);
|
||||||
@@ -602,10 +633,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
jmapClient.reset();
|
jmapClient.reset();
|
||||||
clearAccountFeatureStores(currentId);
|
clearAccountFeatureStores(currentId);
|
||||||
|
|
||||||
// Switch to next remaining account, if any
|
// Switch to next remaining account, if any. Read the store again:
|
||||||
const remaining = accountStore.accounts;
|
// `accountStore` is the state from before removeAccount.
|
||||||
|
const after = useAccountStore.getState();
|
||||||
|
const remaining = after.accounts.filter((a) => a.id !== currentId);
|
||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
const next = accountStore.getDefaultAccount() ?? remaining[0];
|
const next = after.getDefaultAccount() ?? remaining[0];
|
||||||
try {
|
try {
|
||||||
await get().switchAccount(next.id);
|
await get().switchAccount(next.id);
|
||||||
return;
|
return;
|
||||||
@@ -761,6 +794,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
restoreSession: async () => {
|
restoreSession: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
perfMark('restore-start');
|
||||||
try {
|
try {
|
||||||
// Wait for persisted caches to finish hydrating from AsyncStorage.
|
// Wait for persisted caches to finish hydrating from AsyncStorage.
|
||||||
// Otherwise we read empty defaults and bounce the user back to the
|
// Otherwise we read empty defaults and bounce the user back to the
|
||||||
@@ -773,6 +807,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
waitForHydration(useCalendarStore),
|
waitForHydration(useCalendarStore),
|
||||||
waitForHydration(useContactsStore),
|
waitForHydration(useContactsStore),
|
||||||
]);
|
]);
|
||||||
|
perfMark('restore-hydrated');
|
||||||
const accountStore = useAccountStore.getState();
|
const accountStore = useAccountStore.getState();
|
||||||
|
|
||||||
// Legacy migration: if there are no registered accounts but the old
|
// Legacy migration: if there are no registered accounts but the old
|
||||||
@@ -808,6 +843,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ok = await jmapClient.loadAccount(target.id);
|
const ok = await jmapClient.loadAccount(target.id);
|
||||||
|
perfMark('restore-session');
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
// No stored credentials (or corrupt) — genuine logout.
|
// No stored credentials (or corrupt) — genuine logout.
|
||||||
accountStore.removeAccount(target.id);
|
accountStore.removeAccount(target.id);
|
||||||
@@ -861,6 +897,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
const session = jmapClient.currentSession!;
|
const session = jmapClient.currentSession!;
|
||||||
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
||||||
|
perfMark('restore-done');
|
||||||
// Refresh the cached mailbox list + current folder now that the session
|
// Refresh the cached mailbox list + current folder now that the session
|
||||||
// is live. Feature stores show persisted data immediately; this swaps
|
// is live. Feature stores show persisted data immediately; this swaps
|
||||||
// in fresh data once the network round-trip completes.
|
// in fresh data once the network round-trip completes.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import { AppState } from 'react-native';
|
import { AppState } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { perfMark } from '../lib/perf-marks';
|
||||||
import type { PersistStorage, StorageValue } from 'zustand/middleware';
|
import type { PersistStorage, StorageValue } from 'zustand/middleware';
|
||||||
|
|
||||||
/** How long a store's write waits to take in further changes. */
|
/** How long a store's write waits to take in further changes. */
|
||||||
@@ -93,9 +94,12 @@ export function createPersistStorage<S>(
|
|||||||
return {
|
return {
|
||||||
getItem: async (name) => {
|
getItem: async (name) => {
|
||||||
try {
|
try {
|
||||||
|
const t0 = Date.now();
|
||||||
const raw = await AsyncStorage.getItem(name);
|
const raw = await AsyncStorage.getItem(name);
|
||||||
if (raw === null) return null;
|
if (raw === null) return null;
|
||||||
|
const t1 = Date.now();
|
||||||
const value = JSON.parse(raw) as StorageValue<S>;
|
const value = JSON.parse(raw) as StorageValue<S>;
|
||||||
|
perfMark('hydrate', { store: name, readMs: t1 - t0, parseMs: Date.now() - t1, kb: Math.round(raw.length / 1024) });
|
||||||
slotFor(name).stored = raw;
|
slotFor(name).stored = raw;
|
||||||
return value;
|
return value;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user