Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 |
@@ -0,0 +1,3 @@
|
||||
# patch-package patches must keep the patched files' line endings byte for byte
|
||||
# (react-native-android-widget ships CRLF sources).
|
||||
patches/*.patch -text
|
||||
@@ -1,3 +1,64 @@
|
||||
Bulwark Mobile
|
||||
Copyright (C) 2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only, with the additional permission for app store
|
||||
distribution below. That permission is provisional; see the
|
||||
reservation at its end.
|
||||
|
||||
Additional permission under GNU AGPL version 3 section 7
|
||||
|
||||
The copyright holders of this Program give you additional permission to
|
||||
convey a covered work (this Program, a modified version of it, or a work
|
||||
that contains parts of it) through an app store, even though the app
|
||||
store's terms of service, usage rules or technical measures place
|
||||
restrictions on recipients that section 10 of this License would otherwise
|
||||
not allow, such as a limit on the number of devices a copy may be installed
|
||||
on, or digital rights management applied to the copy.
|
||||
|
||||
This permission applies only if you:
|
||||
|
||||
a) make the Corresponding Source of each version you convey through the
|
||||
app store available, at no charge and under the terms of this
|
||||
License, from a network server that anyone can access;
|
||||
|
||||
b) state, in the app store listing or inside the application, where
|
||||
that Corresponding Source can be obtained; and
|
||||
|
||||
c) do not impose any further restrictions of your own on the exercise
|
||||
of the rights granted under this License.
|
||||
|
||||
"App store" means a digital distribution service for applications that is
|
||||
operated by a third party, such as the Apple App Store and TestFlight,
|
||||
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
|
||||
AppGallery.
|
||||
|
||||
This permission covers only the parts of a covered work that its copyright
|
||||
holders license under this License; it does not extend to parts licensed
|
||||
under other terms. As section 7 of this License allows, you may remove this
|
||||
permission from copies you convey and from your modified versions.
|
||||
|
||||
Reservation
|
||||
|
||||
This additional permission has not been finalized. The contributors of
|
||||
earlier parts of this Program have been asked whether they agree to it:
|
||||
https://github.com/orgs/bulwarkmail/discussions/1113
|
||||
|
||||
Until that request is settled, the permission is granted subject to this
|
||||
reservation:
|
||||
|
||||
- It applies to all contributions made on or after 30 September 2026.
|
||||
These contributions are accepted under this License together with
|
||||
this permission.
|
||||
|
||||
- It applies to parts of this Program written before that date only
|
||||
where their copyright holders have agreed to it. Parts whose
|
||||
copyright holders do not agree will be rewritten or removed.
|
||||
|
||||
This reservation will be removed once every earlier contributor has
|
||||
agreed or their parts have been replaced.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -46,4 +46,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
|
||||
@@ -14,6 +14,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
/**
|
||||
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
|
||||
* task when the app goes to the background. Run from the app itself the
|
||||
* refresh stalled: React Native pauses JS timers while the activity is in
|
||||
* the background, and fetch resolves every response through a timer, so the
|
||||
* first request went out and its answer was never read. A running headless
|
||||
* task keeps timers going, and the service keeps the process from being
|
||||
* frozen before the widgets are redrawn.
|
||||
*/
|
||||
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/** JS side: src/widgets/sync.ts. */
|
||||
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkWidgets"
|
||||
|
||||
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
|
||||
@ReactMethod
|
||||
fun refreshInBackground(promise: Promise) {
|
||||
try {
|
||||
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Background start limits (the app left the foreground too long ago).
|
||||
promise.resolve(false)
|
||||
} catch (e: SecurityException) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<dimen name="widget_radius">22dp</dimen>
|
||||
<dimen name="widget_radius">16dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# Android widgets: verification checklist
|
||||
|
||||
Every tap target of the 32 Android home-screen widgets (`src/widgets/`), how
|
||||
it was tested on the emulator, and what happened. Started for the report
|
||||
"widget actions sometimes don't work" (#52 follow-up).
|
||||
|
||||
## Rig
|
||||
|
||||
- Emulator `Bulwark_Pixel7_API35` (Pixel 7, Android 15, x86_64), Pixel
|
||||
launcher, release APK built with `-PreactNativeArchitectures=x86_64`.
|
||||
- Servers: the standing test servers, Stalwart 0.16 (`stalwart-test`) and
|
||||
1.0 (`stalwart-test-v1`), reached through a logging reverse proxy that
|
||||
records every JMAP method, `notUpdated`/`notCreated` and errors, and can
|
||||
delay or fail chosen methods on request.
|
||||
- Accounts: `usera@example.org` (password) and `userb@example.org` (OAuth
|
||||
through Stalwart's own authorization server) signed in at once. Both are
|
||||
members of the `team@` group; `usera`'s calendar is shared with `userb`.
|
||||
Extra test data: tasks for both, invitations each way through server-side
|
||||
scheduling.
|
||||
- Placed: Triage, Tasks, Invitations (others in phase 3).
|
||||
|
||||
## States
|
||||
|
||||
| Code | State | How |
|
||||
|---|---|---|
|
||||
| FG | App on screen when the link arrives | `am start` of the link while the app is in front |
|
||||
| BG | App backgrounded, JS runtime alive | Home after using the app |
|
||||
| COLD | Process dead | `adb root`, `kill -9 <pid>` (not `am kill`, not `force-stop`) |
|
||||
| OFF | Offline | `cmd connectivity airplane-mode enable` |
|
||||
| ACC2 | Item belongs to the non-active account | the other account active in the app |
|
||||
| SHARED | Item in a shared or group account | `usera`'s calendar seen by `userb` |
|
||||
| DBL | Two taps in quick succession | `input tap` twice, 150 ms and 1–1.5 s apart |
|
||||
| UPD | Tap while a refresh is running | proxy delays `Email/set` by 4 s, second tap meanwhile |
|
||||
|
||||
A widget tap cannot happen with the app in front, so FG applies to deep
|
||||
links only. Links were opened with `am start -a VIEW -f 0x10000000`, which is
|
||||
exactly what the widget provider does (`RNWidgetProvider.openUri`); the
|
||||
widget's own Reply, card and event taps were also tapped on the launcher.
|
||||
|
||||
## Phase 1 results (before fixes, Stalwart 0.16)
|
||||
|
||||
### Background actions
|
||||
|
||||
| Action | Widgets | BG | COLD | OFF | ACC2 | DBL | UPD | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| archive | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | see A1, A2, A3 |
|
||||
| trash | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | same code path as archive |
|
||||
| triageNext | Triage | pass | pass | pass | n/a | pass | pass | local only |
|
||||
| rsvp | Invitations | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5 |
|
||||
| toggleTask | Tasks, Today overview | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5, A6 |
|
||||
| markRead | (no button uses it) | – | – | – | – | – | – | kept for the API |
|
||||
| any button | all | pass* | pass* | – | – | – | – | *on this emulator; A0 |
|
||||
|
||||
### Deep links (`links.*` in `src/widgets/clicks.ts`)
|
||||
|
||||
| Link | Used by | FG | BG | COLD | OFF | ACC2 | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| inbox | Inbox, Unread count, Folder counts, Tag, Attachments, Mail and next, Today overview | pass | pass | pass | pass | n/a | |
|
||||
| message | Inbox/Starred/All accounts rows, Latest message, Triage card, Mail and next, Attachments | pass | pass | pass | **fail** | pass online | L4 |
|
||||
| reply | Triage | **fail** | **fail** | **fail** | **fail** | **fail** | L1 |
|
||||
| draft | Last draft | pass | pass | pass | **fail** | pass online | L4 |
|
||||
| unified (all / starred / role) | All accounts, Starred, Folder counts, Last draft | pass | pass | pass | not run | n/a | |
|
||||
| scheduled | Outbox, Folder counts | pass | pass | pass | not run | n/a | |
|
||||
| search (empty / query) | Inbox, Search mail, Favourite people | pass | pass | pass | not run | n/a | empty search opens the list without focusing the field |
|
||||
| compose (plain) | Inbox, Last draft | pass | pass | pass | pass | n/a | |
|
||||
| compose with to + subject | Birthdays "Send wishes", Up next "Running late" | **fail** | **fail** | **fail** | **fail** | n/a | L2 |
|
||||
| calendar | all calendar and plans widgets | pass | pass | pass | **crash** | n/a | L3 |
|
||||
| event | Week, Month, Today, Next event, Up next, Countdown, Invitations, Mail and next | **fail** | pass | pass | **crash** | **fail** | L3, L5 |
|
||||
| contacts | Shortcuts, Favourite people, Birthdays | pass | pass | pass | not run | n/a | |
|
||||
| files | Shortcuts, Recent files | pass | pass | pass | not run | n/a | |
|
||||
| settings/vacation, /account, /calendar | Vacation, Storage, Free time, Tasks (off) | pass | pass | pass | not run | n/a | |
|
||||
| video URL | Up next "Join" | – | – | – | – | – | external https link, opens the browser |
|
||||
|
||||
### Refresh
|
||||
|
||||
| Case | Result | Notes |
|
||||
|---|---|---|
|
||||
| App goes to the background | **fail** (sometimes) | R1 |
|
||||
| Account switched in the app | **fail** | R1: widgets kept the previous account until the next app start |
|
||||
| Cold periodic update | pass for mail | R2: calendar, tasks, files, scheduled stay as last loaded by the app |
|
||||
| Back online after an offline tap | **fail** | A1: nothing brings the change back until the app or a 30-minute update runs |
|
||||
|
||||
## Causes found
|
||||
|
||||
- **A0. Buttons silently dead on fast phones.** The widget library registers
|
||||
every button's `PendingIntent` with `requestCode = (int)
|
||||
System.currentTimeMillis()` and `FLAG_CANCEL_CURRENT`. All buttons of one
|
||||
widget share the same action and component, so two buttons registered in
|
||||
the same millisecond get the same identity and the second registration
|
||||
cancels the first: that button then does nothing at all. On the emulator
|
||||
each registration takes 3–10 ms (measured from the request codes in
|
||||
`dumpsys activity intents`), so every button survived; a current phone
|
||||
makes the binder call in well under a millisecond, which makes the dead
|
||||
button depend on timing — "sometimes". The cancelled-then-recreated
|
||||
records also pile up (227 for three widgets after an hour of testing).
|
||||
- **A1. Offline or failed calls look like they worked.** The action edits
|
||||
the stored snapshot first and relies on the next refresh to undo a failed
|
||||
call. Offline that refresh fails too and returns the edited snapshot, so
|
||||
an archive that never reached the server shows as done; a cold refresh
|
||||
never reloads calendar or tasks, so a refused RSVP or task change is never
|
||||
undone either. Failures are only `console.warn`ed.
|
||||
- **A2. A fast second tap repeats the first.** Two taps 150 ms apart both
|
||||
archived `d2aaaaa3`: the second tap hits the buttons of the old drawing,
|
||||
which still name the first message. Harmless for archive; for task ticks
|
||||
the code would flip the task back, because `toggleTask` inverts whatever
|
||||
the stored snapshot says instead of carrying the state the user saw.
|
||||
- **A3. A refresh brings back a message just archived.** Each tap is its own
|
||||
headless task in the same runtime; they interleave at every await. With
|
||||
`Email/set` delayed 4 s, tap 1's refresh read the server before tap 2's
|
||||
archive landed, was stored anyway (it started after tap 2's local edit, so
|
||||
`noteLocalChange` did not catch it) and redrew the archived message
|
||||
(`baaaaaai`, log 08:44:19), until tap 2's own refresh removed it again.
|
||||
Separately, load–edit–save of the snapshot is not serialized, so two
|
||||
concurrent edits can drop one, and redraws of different snapshots can land
|
||||
out of order. In one run the second tap archived a message the card was
|
||||
not showing.
|
||||
- **A4. RSVP and task ticks go to the wrong account after a switch.** They
|
||||
pick the client from `snapshot.activeAccountId` and look the item up by id
|
||||
in the current snapshot, not from the tap's own data. JMAP ids are small
|
||||
per-account sequences (`e`, `f`, `h1kbleaaaaaj`), so a tap on a drawing
|
||||
made before an account switch can match another account's task or event
|
||||
with the same id. Not reproduced on the device (the window between the
|
||||
stored refresh and the redraw is short); follows from the code.
|
||||
- **A5. Nothing tells the user.** See A1.
|
||||
- **A6. The task tick target is 22 dp.** A tap on the task's title or a few
|
||||
dp beside the circle does nothing.
|
||||
- **Lead 2 (`target` lacks `widgetId`) is ruled out**: the logged click data
|
||||
of Archive/Delete is `{"id":…,"widgetId":3,"accountId":…}`.
|
||||
- **Lead 4 (credentials in a cold runtime)**: the password account restores
|
||||
and acts from a cold start (archive, task tick). The OAuth account with an
|
||||
access token that expired while the app was dead is checked in phase 2.
|
||||
- **Lead 5 (`progressUpdated`)** is ruled out: the widget's task update has
|
||||
carried no `progressUpdated` since the B12 fix, and both 0.16 and 1.0
|
||||
accept it (`updated`, no `notUpdated`).
|
||||
- **R1. The refresh on going to the background often never runs.** With an
|
||||
account switched in the app and Home pressed, no refresh request reached
|
||||
the proxy and the widgets kept the old account's mail.
|
||||
- **R2. Cold refreshes skip calendar and tasks.** Only mail has JMAP code of
|
||||
its own; the rest needs the app's stores, which only run with the UI.
|
||||
- **L1. Widget Reply never opens the composer.** The reader opens on the
|
||||
message but the composer does not follow, in every state.
|
||||
- **L2. "Send wishes" loses its subject.** `parseDeepLink` takes the
|
||||
subject from the mailto parser, which returns an empty string rather than
|
||||
nothing, so the link's own `subject` is never used.
|
||||
- **L3. Calendar links crash the app offline.** On an offline cold start the
|
||||
Calendar tab mounts `CalendarShareSheet`, whose list of people to share
|
||||
with calls `ownPrincipalId()` while rendering; with no session that throws
|
||||
"Not authenticated - call connect() first" and the app closes.
|
||||
- **L4. Message links for the other account fail offline.** The link
|
||||
switches accounts first, which needs the server; offline the switch fails
|
||||
and the app shows "Couldn't open this".
|
||||
- **L5. Event links carry no account.** With the other account active, an
|
||||
event link opens the Calendar tab and nothing else (or, with an id that
|
||||
exists in both accounts, the wrong event). In the foreground the event
|
||||
link landed on the Inbox.
|
||||
|
||||
## Corrections to phase 1
|
||||
|
||||
- **L1 and L2 were faults of the test rig, not of the app.** `adb shell am
|
||||
start -d <url>` handed the URL to the device shell unquoted, and the shell
|
||||
cut it at the first `&`: Reply lost `&action=reply` (and `&thread=`), and
|
||||
"Send wishes" lost `&subject=`. Quoted, Reply opens the composer over the
|
||||
message and the subject arrives, cold, backgrounded and in the foreground,
|
||||
on 0.16 and 1.0.
|
||||
- **L4 is two app limits, not a widget fault.** A message link offline needs
|
||||
the message on the device: with the offline mail cache off (the default)
|
||||
a message is not kept across app starts and the reader says "Failed to
|
||||
load email". A link for the other account needs the server for the account
|
||||
switch, so offline it says "Couldn't open this".
|
||||
- **R1 had a second half.** Starting the background refresh directly (no
|
||||
timer) was not enough: fetch resolves every response through a zero-delay
|
||||
timer, and with the activity paused the first request went out and its
|
||||
answer was never read. The refresh now runs in a headless task of its own.
|
||||
|
||||
## After the fixes (phase 2)
|
||||
|
||||
Same rig. 1.0 was reached as `http://127.0.0.1:18191` through `adb
|
||||
reverse`, so its accounts (`usera@127.0.0.1`) sit next to the 0.16 ones;
|
||||
that path does not go through the emulator's network, so "offline" on 1.0
|
||||
was simulated with the proxy refusing `Email/set` (503) instead of airplane
|
||||
mode.
|
||||
|
||||
### Background actions
|
||||
|
||||
| Action | BG | COLD | OFF | ACC2 | DBL | UPD | 1.0 | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| archive | pass | pass | pass | n/a | pass | pass | pass | offline: the message comes back on the card with "Couldn't archive. Tap to try again."; the tap archives it once online |
|
||||
| trash | pass | pass | pass | n/a | pass | pass | pass | offline: "Couldn't delete. Tap to try again."; the tap moves it to Trash once online |
|
||||
| triageNext | pass | pass | pass | n/a | pass | pass | pass | local only, no server call |
|
||||
| rsvp | pass | pass | pass | pass (unit test) | not run | not run | call passes, see 1.0 below | offline: the invitation stays with "Couldn't send your answer"; the retry accepts it. DBL and UPD not run on the device: they go through the same serial lane and pending overlay as archive and the tick, and `actions.test.ts` covers both |
|
||||
| toggleTask | pass | pass | pass | pass (unit test) | pass | not run | call passes, see 1.0 below | two taps 150 ms apart send `completed` twice; offline the tick reverts with a notice naming the task, and tapping the notice once online sends one `CalendarEvent/set` and the task shows done. UPD not run on the device (see rsvp) |
|
||||
| any button | pass | pass | – | – | – | – | – | request codes unique (107 click intents, 107 codes) |
|
||||
|
||||
DBL for archive: two taps 150 ms apart still both name the message the
|
||||
drawing showed (the second is a harmless repeat); taps a second apart act on
|
||||
consecutive messages. UPD: with `Email/set` delayed 4 s, two archives 1.5 s
|
||||
apart leave the card on "5 of 5" for the whole run and nothing comes back
|
||||
(before: the first archive's refresh brought the second message back). Trash
|
||||
behaves the same: a double tap names the shown message twice, and two
|
||||
deletes 1.5 s apart with `Email/set` delayed move two different messages
|
||||
and nothing comes back.
|
||||
|
||||
### Deep links
|
||||
|
||||
| Link | FG | BG | COLD | OFF | ACC2 | 1.0 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| inbox, unified, scheduled, search, compose | pass | pass | pass | pass | n/a | pass |
|
||||
| message | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
|
||||
| reply | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
|
||||
| draft | pass | pass | pass | only if opened before (L4) | pass | not run (same route as message) |
|
||||
| compose with to + subject | pass | pass | pass | not run (no server involved) | n/a | pass |
|
||||
| calendar | pass | pass | pass | pass (no crash) | n/a | opens; see 1.0 below |
|
||||
| event | pass | pass | pass | opens the Calendar tab; the event needs the server | pass (switches account) | see 1.0 below |
|
||||
| contacts, settings | pass | pass | pass | pass (Vacation settings say the server does not support it while offline) | n/a | pass |
|
||||
| files | pass | pass | pass | pass (no crash; the screen shows the raw network error) | n/a | pass |
|
||||
|
||||
### Refresh
|
||||
|
||||
| Case | Result |
|
||||
|---|---|
|
||||
| Account switched in the app, then Home | pass: the widgets show the new account within a second, both directions |
|
||||
| App goes to the background | pass |
|
||||
| Cold periodic update | pass: mail, calendar, tasks, files and scheduled (`appDataAt` set) |
|
||||
| OAuth account with an expired access token, cold | pass: userb's token had expired an hour after sign-in; the cold refresh renewed it and loaded userb's inbox preview |
|
||||
|
||||
### Stalwart 1.0
|
||||
|
||||
- Mail actions and links pass: the archive race, a refused write with its
|
||||
notice and retry, trash, triage next, message and reply links cold and
|
||||
backgrounded, and the inbox, unified, scheduled, search, compose,
|
||||
calendar, contacts, files and settings links.
|
||||
- The app on main does not load calendar events or tasks from 1.0: its
|
||||
`CalendarEvent/get` asks for `recurrenceOverrides` together with
|
||||
`utcStart`/`utcEnd`, which 1.0 refuses as a whole (`invalidArguments`).
|
||||
The unmerged `feat/stalwart-1.0` branch fixes that. Until it is merged the
|
||||
calendar and task widgets are empty against 1.0. The widgets' own calls
|
||||
were sent to 1.0 directly: the task update (`progress`,
|
||||
`percentComplete`) and the RSVP patch both come back `updated`.
|
||||
|
||||
### Causes and their fixes
|
||||
|
||||
| Cause | Commit |
|
||||
|---|---|
|
||||
| A0 dead buttons (shared request codes) | `fix: stop widget buttons from going dead when two are drawn in the same millisecond` |
|
||||
| A3 refresh brings an archived message back, lost concurrent edits, out-of-order redraws | `fix: keep a widget archive when a second tap's refresh lands before it` |
|
||||
| A1, A5 failures looked like success, no feedback | the commit above (a failed op is dropped) and `feat: show on the widget when an action could not reach the server` |
|
||||
| A2, A4 tick flips back, item of another account | `fix: send widget RSVPs and task ticks to the item the tap was made on` |
|
||||
| A6 22dp tick | `fix: make widget task ticks easy to hit` |
|
||||
| R1 no refresh on leaving the app | `fix: refresh the widgets when the app goes to the background`, `fix: finish the widget refresh after leaving the app` |
|
||||
| R2 cold refresh skipped calendar and tasks | `fix: load calendar and tasks into the widgets when the app is not running` |
|
||||
| L3 offline crash from calendar and files widgets | `fix: keep the app from closing when a calendar widget opens it offline`, `fix: keep the app from closing when a files widget opens it offline` |
|
||||
| L5 event links without account | `fix: open a widget's event in the account it belongs to` |
|
||||
|
||||
## Phase 3: layout review
|
||||
|
||||
All 32 widgets were drawn through a temporary in-app gallery
|
||||
(`WidgetPreview`, removed again) at their smallest, default and largest
|
||||
(4x5) size with sample data, light and dark, German, very long names and
|
||||
counts (five-digit counts, 50-character names and subjects), empty data,
|
||||
and the loading, signed-out, not-yet-loaded and unsupported states, and
|
||||
with the tap areas highlighted. Fixed:
|
||||
|
||||
- The Inbox header at two cells pushed the compose button off; header
|
||||
titles now give way to the buttons, and the search icon goes at that
|
||||
width.
|
||||
- A long sender pushed the triage card's time and position out.
|
||||
- German labels were cut on the triage buttons ("Archivie", "Lösc"); they
|
||||
drop to icons when they do not fit.
|
||||
- Five-digit counts overflowed Unread count, Tag and Mail and next, and the
|
||||
total wrapped a character per line; counts from 10,000 are shortened.
|
||||
- "ungelesen" was cut in Mail and next; it moves under the number.
|
||||
- Buttons under 48dp (32–36dp) get a transparent 48dp tap area.
|
||||
- The Month arrows and the Vacation switch looked like controls but only
|
||||
opened the app.
|
||||
- Folder counts with no folders showed a bare heading.
|
||||
- A finished task past its due date read "Overdue".
|
||||
- The triage card and Latest message did not use their height for the
|
||||
preview.
|
||||
- A failure notice about a long task title cut off "Tap to try again".
|
||||
- Widget strings were English in every language; German added.
|
||||
- Avatars showed initials only, worked out differently from the webmail.
|
||||
Initials now follow the webmail everywhere in the app (first letters of
|
||||
the first and last name, leading punctuation and emoji skipped), and the
|
||||
widget avatars show the contact photo or the sender's logo first, as the
|
||||
app's list does. Logos come from the same favicon service, are fetched
|
||||
when the snapshot is built and are cached for offline drawing; a logo
|
||||
over 40 KB, or one Android cannot decode, leaves the initials.
|
||||
- The corners were rounder (22dp) than the launcher's own widgets; they are
|
||||
16dp now, previews included.
|
||||
|
||||
Checked and left as they are:
|
||||
|
||||
- Dark theme: no white fallbacks; the primary is white as in the webmail.
|
||||
- Large sizes of the 2x2 widgets are mostly empty space. A resize limit
|
||||
(`maxResizeWidth`) is given in dp, and on launchers with large cells it
|
||||
would pin a widget below two cells, so there is none.
|
||||
- RTL is not supported: the widget library does not mirror layouts, and
|
||||
there are no right-to-left widget strings.
|
||||
- Mail and next's chip for the following event is 24dp tall; the whole
|
||||
column also opens the calendar.
|
||||
|
||||
## Open
|
||||
|
||||
- A0 cannot be shown on this emulator, where each registration takes
|
||||
3–10 ms; the fix is checked by the request codes being unique.
|
||||
- Calendar and task widgets against Stalwart 1.0 wait for
|
||||
`feat/stalwart-1.0` (see above).
|
||||
- Offline message links need the offline cache; links for the other account
|
||||
need the server for the switch (L4).
|
||||
- Widget strings exist in English and German only.
|
||||
- RSVP double tap and update race, and the tick's update race, were not run
|
||||
on the device; unit tests cover them.
|
||||
- The Files screen opened offline shows the raw network error instead of an
|
||||
offline message (app screen, not a widget).
|
||||
@@ -6,6 +6,7 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
|
||||
import { pushBackgroundTask } from './src/lib/push-background-task';
|
||||
import { widgetTaskHandler } from './src/widgets/task-handler';
|
||||
import { refreshWidgetsInBackground } from './src/widgets/sync';
|
||||
import { markUiStarted } from './src/widgets/ui-presence';
|
||||
|
||||
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
||||
// from BulwarkMessagingService on an FCM data message. Must be registered
|
||||
@@ -25,11 +26,14 @@ AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import(
|
||||
|
||||
// Home-screen widgets (Android): every widget event runs this headless task.
|
||||
registerWidgetTaskHandler(widgetTaskHandler);
|
||||
// ...and leaving the app refreshes them in one (BulwarkWidgetRefreshService).
|
||||
AppRegistry.registerHeadlessTask('BulwarkWidgetRefresh', () => refreshWidgetsInBackground);
|
||||
|
||||
// A headless push task evaluates this file too, so App is required when the
|
||||
// UI first renders instead of imported above: importing it here would load
|
||||
// every screen, store and icon before the task could run.
|
||||
function Root() {
|
||||
markUiStarted();
|
||||
const App = (require('./App') as typeof import('./App')).default;
|
||||
return createElement(App);
|
||||
}
|
||||
|
||||
@@ -1169,5 +1169,196 @@
|
||||
"severity_deprecated": "Erforderlich",
|
||||
"severity_security": "Sicherheit",
|
||||
"title": "App-Updates"
|
||||
},
|
||||
"widgets": {
|
||||
"attachments": {
|
||||
"empty": "Keine neuen Anhänge",
|
||||
"from_mail": "aus E-Mail",
|
||||
"title": "Anhänge",
|
||||
"unknown_sender": "Unbekannter Absender"
|
||||
},
|
||||
"birthdays": {
|
||||
"empty": "Keine Geburtstage in Sicht",
|
||||
"empty_body": "Geburtstage kommen aus Ihren Kontakten.",
|
||||
"in_days": "{count, plural, one {in # Tag} other {in # Tagen}}",
|
||||
"send_wishes": "Gratulieren",
|
||||
"title": "Geburtstage",
|
||||
"wishes_subject": "Alles Gute zum Geburtstag!"
|
||||
},
|
||||
"calendar": {
|
||||
"agenda": "Agenda",
|
||||
"agenda_more": "{day}: {events}",
|
||||
"all_day": "Ganztägig",
|
||||
"join": "Teilnehmen",
|
||||
"join_named": "{name} beitreten",
|
||||
"more_this_week": "{count, plural, one {# weiterer diese Woche} other {# weitere diese Woche}}",
|
||||
"no_more_today": "Heute keine Termine mehr",
|
||||
"nothing_planned": "Nichts im Kalender",
|
||||
"now": "Jetzt",
|
||||
"open": "Öffnen",
|
||||
"running_late": "Verspäte mich",
|
||||
"running_late_body": "Ich komme ein paar Minuten später zu {title}.",
|
||||
"running_late_subject": "Verspäte mich: {title}",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"unit_days": "{count, plural, one {Tag} other {Tage}}",
|
||||
"unit_hours": "{count, plural, one {Std.} other {Std.}}",
|
||||
"unit_minutes": "{count, plural, one {Min.} other {Min.}}",
|
||||
"unsupported": "Kalender ist auf diesem Server nicht verfügbar",
|
||||
"untitled": "(ohne Titel)",
|
||||
"video_call": "Videoanruf"
|
||||
},
|
||||
"countdown": {
|
||||
"days": "{count, plural, one {Tag} other {Tage}}",
|
||||
"empty": "Keine anstehenden Termine"
|
||||
},
|
||||
"draft": {
|
||||
"edited_at": "Bearbeitet {when}",
|
||||
"edited_minutes": "{count, plural, one {Vor # Min. bearbeitet} other {Vor # Min. bearbeitet}}",
|
||||
"edited_now": "Gerade bearbeitet",
|
||||
"empty": "Keine Entwürfe",
|
||||
"empty_body": "Nachrichten, die Sie beginnen und nicht senden, warten hier.",
|
||||
"keep_writing": "Weiterschreiben",
|
||||
"title": "Entwürfe"
|
||||
},
|
||||
"files": {
|
||||
"empty": "Noch keine Dateien",
|
||||
"recent": "Zuletzt geändert",
|
||||
"unsupported": "Dateien sind auf diesem Server nicht verfügbar"
|
||||
},
|
||||
"free": {
|
||||
"busy_until": "Belegt bis {time}",
|
||||
"free_rest": "Den Rest des Tages frei",
|
||||
"free_until": "Frei bis {time}",
|
||||
"mail": "Freie Zeiten mailen",
|
||||
"mail_subject": "Meine freien Zeiten diese Woche",
|
||||
"no_calendar": "Kein Kalender in diesem Konto",
|
||||
"then": "danach {title}",
|
||||
"then_free": "danach frei"
|
||||
},
|
||||
"hub": {
|
||||
"all_clear": "Alles erledigt",
|
||||
"mail_unread": "E-Mail · {count} ungelesen",
|
||||
"next": "Als Nächstes",
|
||||
"next_on": "Als Nächstes · {day}",
|
||||
"no_events": "Keine anstehenden Termine",
|
||||
"now": "Jetzt",
|
||||
"tasks_due": "Aufgaben · {count} fällig"
|
||||
},
|
||||
"invites": {
|
||||
"accept": "Zusagen",
|
||||
"decline": "Absagen",
|
||||
"empty": "Keine offenen Einladungen",
|
||||
"label": "Einladung · {name}",
|
||||
"label_plain": "Einladung",
|
||||
"maybe": "Vielleicht",
|
||||
"position": "{index} von {count}"
|
||||
},
|
||||
"mail": {
|
||||
"all_accounts": "Alle Konten",
|
||||
"all_caught_up": "Alles gelesen",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"empty_all_accounts": "Ihre Posteingänge sind leer",
|
||||
"empty_inbox": "Ihr Posteingang ist leer",
|
||||
"folders": "Ordner",
|
||||
"in_outbox": "{count, plural, one {# Änderung wartet} other {# Änderungen warten}}",
|
||||
"in_outbox_short": "{count} warten",
|
||||
"inbox": "Posteingang",
|
||||
"more_unread": "+{count} weitere ungelesen",
|
||||
"no_folders": "Keine Ordner",
|
||||
"no_starred": "Keine markierten E-Mails",
|
||||
"no_starred_body": "Markieren Sie eine Nachricht mit einem Stern, um sie hier zu behalten.",
|
||||
"no_subject": "(kein Betreff)",
|
||||
"no_unread": "Keine ungelesenen Nachrichten im Posteingang.",
|
||||
"position": "{index} von {count}",
|
||||
"reply": "Antworten",
|
||||
"scheduled": "Geplant",
|
||||
"starred": "Markiert",
|
||||
"unread": "ungelesen"
|
||||
},
|
||||
"notice": {
|
||||
"archive": "Archivieren fehlgeschlagen. Tippen zum Wiederholen.",
|
||||
"mark_read": "Als gelesen markieren fehlgeschlagen. Tippen zum Wiederholen.",
|
||||
"rsvp": "Antwort nicht gesendet. Tippen zum Wiederholen.",
|
||||
"task": "„{title}“ nicht aktualisiert. Tippen zum Wiederholen.",
|
||||
"trash": "Löschen fehlgeschlagen. Tippen zum Wiederholen."
|
||||
},
|
||||
"people": {
|
||||
"empty": "Noch keine Favoriten",
|
||||
"empty_body": "Die Personen, von denen Sie am meisten hören, erscheinen hier.",
|
||||
"favourites": "Favoriten"
|
||||
},
|
||||
"relative": {
|
||||
"days": "{count, plural, one {in # Tag} other {in # Tagen}}",
|
||||
"hours": "in {count} Std.",
|
||||
"minutes": "in {count} Min.",
|
||||
"now": "jetzt"
|
||||
},
|
||||
"scheduled": {
|
||||
"day_at": "{day}, {time}",
|
||||
"empty": "Nichts geplant",
|
||||
"empty_body": "Nachrichten, die Sie später senden lassen, erscheinen hier.",
|
||||
"no_recipient": "(kein Empfänger)",
|
||||
"sending": "Wird gesendet",
|
||||
"to": "An: {name}",
|
||||
"today_at": "Heute, {time}",
|
||||
"tomorrow_at": "Morgen, {time}",
|
||||
"waiting": "Wartet auf Netz",
|
||||
"waiting_body": "Offline vorgenommene Änderungen werden gesendet, sobald Sie wieder online sind."
|
||||
},
|
||||
"search": {
|
||||
"no_recent": "Ihre letzten Suchen erscheinen hier.",
|
||||
"placeholder": "E-Mails durchsuchen…",
|
||||
"recent": "Letzte Suchen"
|
||||
},
|
||||
"shortcuts": {
|
||||
"calendar": "Kalender",
|
||||
"compose": "Verfassen",
|
||||
"contacts": "Kontakte",
|
||||
"files": "Dateien"
|
||||
},
|
||||
"state": {
|
||||
"error": "Widget kann nicht angezeigt werden",
|
||||
"loading": "Wird geladen…",
|
||||
"open_app": "Öffnen Sie die App einmal, falls dies leer bleibt.",
|
||||
"open_to_load": "Öffnen Sie Bulwark, um dies zu laden",
|
||||
"signed_out": "Bei Bulwark anmelden"
|
||||
},
|
||||
"storage": {
|
||||
"no_limit": "Kein Speicherlimit",
|
||||
"title": "Speicher",
|
||||
"unavailable": "Keine Angaben zum Speicher",
|
||||
"used_of": "{used} von {total}"
|
||||
},
|
||||
"tag": {
|
||||
"empty": "Noch keine Tags",
|
||||
"empty_body": "Tags, die Sie in der App vergeben, erscheinen hier.",
|
||||
"messages": "{count, plural, one {Nachricht} other {Nachrichten}}",
|
||||
"total": "{count, plural, one {# Nachricht} other {# Nachrichten}}"
|
||||
},
|
||||
"tasks": {
|
||||
"all_done": "Alles erledigt",
|
||||
"empty": "Keine offenen Aufgaben",
|
||||
"mark_complete": "Als erledigt markieren",
|
||||
"mark_incomplete": "Als nicht erledigt markieren",
|
||||
"no_title": "(ohne Titel)",
|
||||
"off": "Aufgaben sind aus",
|
||||
"off_body": "Schalten Sie Aufgaben in den Kalendereinstellungen der App ein",
|
||||
"open_count": "{count} offen",
|
||||
"overdue": "Überfällig",
|
||||
"still_open": "{count} noch offen",
|
||||
"title": "Aufgaben",
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen"
|
||||
},
|
||||
"vacation": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"subject": "Betreff: {subject}",
|
||||
"title": "Abwesenheitsnotiz",
|
||||
"unsupported": "Abwesenheitsnotizen sind nicht verfügbar",
|
||||
"until": "Bis {date}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1282,6 +1282,7 @@
|
||||
"in_outbox_short": "{count} waiting",
|
||||
"inbox": "Inbox",
|
||||
"more_unread": "+{count} more unread",
|
||||
"no_folders": "No folders to show",
|
||||
"no_starred": "No starred mail",
|
||||
"no_starred_body": "Star a message to keep it here.",
|
||||
"no_subject": "(no subject)",
|
||||
@@ -1292,6 +1293,13 @@
|
||||
"starred": "Starred",
|
||||
"unread": "unread"
|
||||
},
|
||||
"notice": {
|
||||
"archive": "Couldn't archive. Tap to try again.",
|
||||
"mark_read": "Couldn't mark as read. Tap to try again.",
|
||||
"rsvp": "Couldn't send your answer. Tap to try again.",
|
||||
"task": "Couldn't update “{title}”. Tap to try again.",
|
||||
"trash": "Couldn't delete. Tap to try again."
|
||||
},
|
||||
"people": {
|
||||
"empty": "No favourites yet",
|
||||
"empty_body": "The people you hear from most show up here.",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.71",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.71",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"patch-package": "8.0.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
@@ -3803,6 +3804,13 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -6181,6 +6189,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/flow-enums-runtime": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
|
||||
@@ -6226,6 +6244,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -7300,6 +7333,33 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -7312,6 +7372,29 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
@@ -7324,6 +7407,16 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -8601,6 +8694,138 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/patch-package/node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -10264,6 +10489,16 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -10392,6 +10627,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.71",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "patch-package",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -56,6 +57,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"patch-package": "8.0.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
index 1c12d1f..60f01e7 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
|
||||
@@ -207,7 +207,7 @@ public class RNWidget {
|
||||
intent.putExtra("clickActionData", Arguments.toBundle(clickableView.getClickActionData()));
|
||||
PendingIntent pendingIntent = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
- (int) System.currentTimeMillis(),
|
||||
+ RNWidgetUtil.nextClickRequestCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
index ed1af56..12c3630 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
|
||||
@@ -162,7 +162,7 @@ class ListRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
|
||||
intent.putExtra("clickActionData", clickableArea.getBundle("clickActionData"));
|
||||
return PendingIntent.getBroadcast(
|
||||
mContext,
|
||||
- (int) System.currentTimeMillis(),
|
||||
+ RNWidgetUtil.nextClickRequestCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
| PendingIntent.FLAG_MUTABLE
|
||||
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
index 2a34305..711503b 100644
|
||||
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
|
||||
@@ -15,8 +15,23 @@ import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
import java.util.List;
|
||||
+import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class RNWidgetUtil {
|
||||
+ private static final AtomicInteger lastRequestCode = new AtomicInteger((int) System.currentTimeMillis() - 1);
|
||||
+
|
||||
+ /**
|
||||
+ * A request code no other click PendingIntent of this app holds. Every click intent has the same
|
||||
+ * action and component, so the request code alone tells them apart: a code shared by two buttons
|
||||
+ * (two registrations within one millisecond with the time as the code) made the second one cancel
|
||||
+ * the first, leaving a button that does nothing. Counting up from the time keeps the codes unique
|
||||
+ * in this process and clear of the ones an earlier process left on the launcher.
|
||||
+ */
|
||||
+ public static int nextClickRequestCode() {
|
||||
+ int now = (int) System.currentTimeMillis();
|
||||
+ return lastRequestCode.updateAndGet(last -> now - last > 0 ? now : last + 1);
|
||||
+ }
|
||||
+
|
||||
public static int getWidgetWidth(Context context, int widgetId) {
|
||||
boolean isPortrait = context.getResources().getConfiguration().orientation == ORIENTATION_PORTRAIT;
|
||||
if (isPortrait) {
|
||||
@@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document';
|
||||
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
|
||||
import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted } from '../lib/trusted-senders';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -527,6 +528,8 @@ export default function EmailBodyView({
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const hasContacts = useHasContacts();
|
||||
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
@@ -540,8 +543,8 @@ export default function EmailBodyView({
|
||||
// entries (synced across devices) feed the external-content trust check. We
|
||||
// do not create the book here — it is created lazily when a sender is trusted.
|
||||
React.useEffect(() => {
|
||||
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
|
||||
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
|
||||
// Most marketing email is authored against a white background, so dark-mode
|
||||
// inversion can wreck logos/banners. With this flag the user opts to render
|
||||
// emails on a light surface even while the rest of the app is dark; the
|
||||
@@ -561,7 +564,7 @@ export default function EmailBodyView({
|
||||
const text = picked.text;
|
||||
const trusted = isSenderContentTrusted(senderEmail, {
|
||||
isLocallyTrusted: isSenderTrusted,
|
||||
syncEnabled: trustedSendersAddressBook,
|
||||
syncEnabled: syncTrustedSenders,
|
||||
trustedBookEmails: trustedSenderEmails,
|
||||
});
|
||||
|
||||
@@ -721,12 +724,11 @@ export default function EmailBodyView({
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect. The server-side "Trusted
|
||||
// Senders" address book (synced across devices) is only written when
|
||||
// the user opted into it - otherwise a book would silently appear in
|
||||
// their contacts (matches the webmail).
|
||||
// Keep the local allow-list for instant effect, and file the sender in
|
||||
// the server-side "Trusted Senders" address book (synced across
|
||||
// devices) unless the user turned that off.
|
||||
addTrustedSender(senderEmail);
|
||||
if (trustedSendersAddressBook) {
|
||||
if (syncTrustedSenders) {
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
}
|
||||
setAllowOnce(true);
|
||||
|
||||
@@ -102,6 +102,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
);
|
||||
|
||||
const candidates = React.useMemo(() => {
|
||||
// The sheet is mounted with the Calendar tab, before a session may exist
|
||||
// (an offline cold start): nobody to list yet, and no own id to read.
|
||||
if (principals.length === 0) return [];
|
||||
const self = ownPrincipalId();
|
||||
const q = search.trim().toLowerCase();
|
||||
return principals
|
||||
|
||||
@@ -98,6 +98,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
||||
);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
// The sheet is mounted with the Files tab, before a session may exist
|
||||
// (an offline cold start): nobody to list yet, and no own id to read.
|
||||
if (principals.length === 0) return [];
|
||||
const self = ownPrincipalId();
|
||||
const q = search.trim().toLowerCase();
|
||||
return principals
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useHasContacts } from '../../lib/capabilities';
|
||||
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
interface TrustedRow {
|
||||
@@ -49,9 +50,8 @@ export function ContentSendersSettings() {
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
// Sync stays off until the user turns it on below (null = never chosen):
|
||||
// opening this pane must not change which senders the viewer trusts.
|
||||
const syncEnabled = !!trustedSendersAddressBook && hasContacts;
|
||||
// On unless the user turned it off below (null = never chosen).
|
||||
const syncEnabled = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalOpen && syncEnabled) void loadTrustedSendersBook();
|
||||
@@ -179,7 +179,7 @@ export function ContentSendersSettings() {
|
||||
description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={!!trustedSendersAddressBook}
|
||||
checked={syncEnabled}
|
||||
onChange={(enabled) => {
|
||||
updateSetting('trustedSendersAddressBook', enabled);
|
||||
if (enabled) void loadTrustedSendersBook();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getContactPhotoForEmail, getContactPhotoIndex } from '../avatar-utils';
|
||||
import { getAccountInitials, getContactPhotoForEmail, getContactPhotoIndex, getEmailInitials } from '../avatar-utils';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
|
||||
function card(id: string, emails: string[], photo?: string, mediaType?: string): ContactCard {
|
||||
@@ -52,3 +52,25 @@ describe('contact photo avatars (#54)', () => {
|
||||
expect(index.has('b@x.org')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initials, as the webmail Avatar works them out', () => {
|
||||
it('skips punctuation and symbols in front of a name part', () => {
|
||||
expect(getEmailInitials('"Sofia" Russo')).toBe('SR');
|
||||
expect(getEmailInitials('(Dr.) Smith')).toBe('DS');
|
||||
expect(getEmailInitials('Sofia - Russo')).toBe('SR');
|
||||
expect(getEmailInitials('@team updates')).toBe('TU');
|
||||
});
|
||||
|
||||
it('takes two letters of a single name and falls back to the address', () => {
|
||||
expect(getEmailInitials('Madonna')).toBe('MA');
|
||||
expect(getEmailInitials('', 'bob@example.org')).toBe('B');
|
||||
expect(getEmailInitials('')).toBe('?');
|
||||
expect(getEmailInitials('!!!')).toBe('!!');
|
||||
});
|
||||
|
||||
it('works the same for account avatars, which the webmail draws with the same Avatar', () => {
|
||||
expect(getAccountInitials('Ada Lovelace', 'ada@example.org')).toBe('AL');
|
||||
expect(getAccountInitials('Ada', 'ada@example.org')).toBe('AD');
|
||||
expect(getAccountInitials('', 'ada@example.org')).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isSenderContentTrusted } from '../trusted-senders';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../trusted-senders';
|
||||
|
||||
const local = (list: string[]) => (email: string) => list.includes(email.toLowerCase());
|
||||
|
||||
describe('isTrustedSendersSyncOn', () => {
|
||||
it('is on unless the user opted out', () => {
|
||||
expect(isTrustedSendersSyncOn(null, true)).toBe(true);
|
||||
expect(isTrustedSendersSyncOn(undefined, true)).toBe(true);
|
||||
expect(isTrustedSendersSyncOn(true, true)).toBe(true);
|
||||
expect(isTrustedSendersSyncOn(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('is off on a server without contacts', () => {
|
||||
for (const setting of [null, true, false]) {
|
||||
expect(isTrustedSendersSyncOn(setting, false)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSenderContentTrusted', () => {
|
||||
it('trusts senders on the local allow-list whatever the sync setting', () => {
|
||||
for (const syncEnabled of [null, false, true]) {
|
||||
for (const syncEnabled of [false, true]) {
|
||||
expect(isSenderContentTrusted('Alice@Example.com', {
|
||||
isLocallyTrusted: local(['alice@example.com']),
|
||||
syncEnabled,
|
||||
@@ -18,8 +33,6 @@ describe('isSenderContentTrusted', () => {
|
||||
const opts = { isLocallyTrusted: local([]), trustedBookEmails: ['bob@example.com'] };
|
||||
expect(isSenderContentTrusted('Bob@Example.com', { ...opts, syncEnabled: true })).toBe(true);
|
||||
expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: false })).toBe(false);
|
||||
// Never chosen counts as off: the default must not widen trust.
|
||||
expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not trust a sender just because some other list knows them', () => {
|
||||
|
||||
@@ -28,16 +28,12 @@ export function generateAvatarColor(email: string): string {
|
||||
return ACCOUNT_AVATAR_PALETTE[Math.abs(hash) % ACCOUNT_AVATAR_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Account avatars. The webmail draws them with the same `Avatar` as senders,
|
||||
* so they get the same initials.
|
||||
*/
|
||||
export function getAccountInitials(name: string, email?: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return parts[0][0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
if (email) return email[0]?.toUpperCase() ?? '?';
|
||||
return '?';
|
||||
return getEmailInitials(name, email);
|
||||
}
|
||||
|
||||
// --- Email-row avatar -----------------------------------------------------
|
||||
@@ -52,12 +48,23 @@ export function generateEmailAvatarColor(name: string, email?: string): string {
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initials as the webmail's `Avatar` (components/ui/avatar.tsx) works them
|
||||
* out: the first letters of the first and last name part, ignoring
|
||||
* punctuation and symbols in front of a part (`"Sofia" Russo` is SR), two
|
||||
* letters of a single part, else the first letter of the address.
|
||||
*/
|
||||
export function getEmailInitials(name: string, email?: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const parts = name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((p) => p.replace(/^[^\p{L}\p{N}]+/u, ''))
|
||||
.filter((p) => p.length > 0);
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
||||
}
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (email) return email[0]?.toUpperCase() ?? '?';
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
PartialDate,
|
||||
Timestamp,
|
||||
} from '../api/types';
|
||||
import { getEmailInitials } from './avatar-utils';
|
||||
|
||||
export function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name) {
|
||||
@@ -122,15 +123,10 @@ export function getCustomFullName(contact: ContactCard, orgName?: string): strin
|
||||
return full;
|
||||
}
|
||||
|
||||
/** The webmail's avatar initials for the contact's name, else its first address. */
|
||||
export function getContactInitials(contact: ContactCard): string {
|
||||
const name = getContactDisplayName(contact).trim();
|
||||
if (!name) return '?';
|
||||
const parts = name.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
const email = Object.values(contact.emails ?? {})[0]?.address;
|
||||
return getEmailInitials(getContactDisplayName(contact).trim(), email);
|
||||
}
|
||||
|
||||
// Some JMAP servers (notably Stalwart, see webmail #307) emit photo data URIs
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Whether "Sync trusted senders to address book" is in effect. Never chosen
|
||||
* (null) counts as on, so trusted senders land in the dedicated "Trusted
|
||||
* Senders" address book out of the box; only the user's opt-out (false), or
|
||||
* a server without contacts, turns it off.
|
||||
*/
|
||||
export function isTrustedSendersSyncOn(setting: boolean | null | undefined, hasContacts: boolean): boolean {
|
||||
return hasContacts && setting !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the viewer may load a sender's remote content (images, tracking
|
||||
* pixels) without asking.
|
||||
@@ -13,8 +23,8 @@ export function isSenderContentTrusted(
|
||||
opts: {
|
||||
/** The settings store's local allow-list check. */
|
||||
isLocallyTrusted: (email: string) => boolean;
|
||||
/** The "Sync trusted senders to address book" setting (null = never chosen, off). */
|
||||
syncEnabled: boolean | null;
|
||||
/** Whether address book sync is in effect (`isTrustedSendersSyncOn`). */
|
||||
syncEnabled: boolean;
|
||||
/** Lowercased addresses filed in the "Trusted Senders" book. */
|
||||
trustedBookEmails: readonly string[];
|
||||
},
|
||||
@@ -22,5 +32,5 @@ export function isSenderContentTrusted(
|
||||
const email = senderEmail?.trim();
|
||||
if (!email) return false;
|
||||
if (opts.isLocallyTrusted(email)) return true;
|
||||
return !!opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
|
||||
return opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
|
||||
}
|
||||
|
||||
@@ -153,6 +153,21 @@ describe('handleDeepLink', () => {
|
||||
expect(usePendingCalendarOpen.getState().consume()).toBeNull();
|
||||
});
|
||||
|
||||
it('switches to the signed-in account a widget event link names first', async () => {
|
||||
const navigation = nav();
|
||||
const url = links.event({ serverId: 'j', accountId: 'usera@example.org@mail', jmapAccountId: 'd' });
|
||||
const link = parseDeepLink(url);
|
||||
expect(link).toEqual({ kind: 'calendar', eventId: 'j', jmapAccountId: 'd', accountId: 'usera@example.org@mail' });
|
||||
const switchAccount = vi.fn(async () => true);
|
||||
await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null, switchAccount });
|
||||
expect(switchAccount).toHaveBeenCalledWith('usera@example.org@mail');
|
||||
expect(usePendingCalendarOpen.getState().consume()).toMatchObject({ serverId: 'j', accountId: 'd' });
|
||||
// An account that is no longer signed in opens nothing.
|
||||
expect(await handleDeepLink(link!, {
|
||||
navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('parks the settings tab and opens the Settings tab', async () => {
|
||||
const navigation = nav();
|
||||
await handleDeepLink(
|
||||
|
||||
@@ -36,8 +36,9 @@ export type DeepLink =
|
||||
| { kind: 'scheduled' }
|
||||
| { kind: 'search'; query: string }
|
||||
// `jmapAccountId`: the JMAP account owning the event (`?account=`), for
|
||||
// one on a calendar shared with the user.
|
||||
| { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string }
|
||||
// one on a calendar shared with the user; `accountId`: the signed-in
|
||||
// account it belongs to (`?appAccount=`, from widgets).
|
||||
| { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string; accountId?: string }
|
||||
| { kind: 'contact'; contactId: string }
|
||||
| { kind: 'contacts' }
|
||||
| { kind: 'files' }
|
||||
@@ -139,9 +140,13 @@ export function parseDeepLink(url: string): DeepLink | null {
|
||||
case 'calendar': {
|
||||
if (kind === 'event' && value) {
|
||||
const jmapAccountId = search.get('account') ?? undefined;
|
||||
return jmapAccountId
|
||||
? { kind: 'calendar', eventId: decodeSegment(value), jmapAccountId }
|
||||
: { kind: 'calendar', eventId: decodeSegment(value) };
|
||||
const appAccountId = search.get('appAccount') ?? undefined;
|
||||
return {
|
||||
kind: 'calendar',
|
||||
eventId: decodeSegment(value),
|
||||
...(jmapAccountId ? { jmapAccountId } : {}),
|
||||
...(appAccountId ? { accountId: appAccountId } : {}),
|
||||
};
|
||||
}
|
||||
const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s));
|
||||
return { kind: 'calendar', date };
|
||||
|
||||
@@ -29,9 +29,12 @@ import RichTextEditor, {
|
||||
} from '../components/RichTextEditor';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||
import { getEmailInitials } from '../lib/avatar-utils';
|
||||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useSendUndoStore } from '../stores/send-undo-store';
|
||||
import { toast } from '../stores/toast-store';
|
||||
@@ -218,11 +221,7 @@ function RecipientChip({
|
||||
}
|
||||
|
||||
function initialsOf(name: string, email: string): string {
|
||||
const source = name.trim() || email;
|
||||
const parts = source.split(/[\s@._-]+/).filter(Boolean);
|
||||
const first = parts[0]?.[0] ?? '?';
|
||||
const second = parts.length > 1 ? parts[1][0] : '';
|
||||
return `${first}${second}`.toUpperCase();
|
||||
return getEmailInitials(name.trim(), email);
|
||||
}
|
||||
|
||||
function SuggestionList({
|
||||
@@ -480,6 +479,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
|
||||
const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const hasContacts = useHasContacts();
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => quoteHeaderLabels(t), [t]);
|
||||
@@ -2133,7 +2133,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const contacts = useContactsStore.getState();
|
||||
for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) {
|
||||
settings.addTrustedSender(r.email);
|
||||
if (trustedSendersAddressBook) {
|
||||
if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
|
||||
contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClipboardPaste, QrCode, Mail, Plus, Server } from 'lucide-react-native'
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import type { AccountEntry } from '../../stores/account-store';
|
||||
import { getAccountInitials } from '../../lib/avatar-utils';
|
||||
import OptionTile from './OptionTile';
|
||||
import LoginNotice from './LoginNotice';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
@@ -33,10 +34,7 @@ function hostOf(url: string): string {
|
||||
}
|
||||
|
||||
function initialsOf(account: AccountEntry): string {
|
||||
const source = account.displayName || account.email || account.username;
|
||||
const parts = source.replace(/@.*$/, '').split(/[.\s_-]+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((part) => part[0]);
|
||||
return (letters.join('') || source[0] || '?').toUpperCase();
|
||||
return getAccountInitials(account.displayName ?? '', account.email || account.username);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { getAccountInitials } from '../../lib/avatar-utils';
|
||||
|
||||
// The white mark disappears on the light palette, so pick per theme.
|
||||
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
|
||||
@@ -23,10 +24,7 @@ function hostOf(url: string | null): string {
|
||||
}
|
||||
|
||||
function initialsOf(email: string): string {
|
||||
const local = email.replace(/@.*$/, '');
|
||||
const parts = local.split(/[.\s_-]+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((part) => part[0]);
|
||||
return (letters.join('') || local[0] || '?').toUpperCase();
|
||||
return getAccountInitials('', email);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,8 +127,8 @@ interface PersistedSettings {
|
||||
// Privacy & content
|
||||
externalContentPolicy: ExternalContentPolicy;
|
||||
trustedSenders: string[];
|
||||
// null = not decided yet and treated as off; only the user's toggle in
|
||||
// Content & Senders turns it on, false = user opted out.
|
||||
// null = not decided yet and treated as on (isTrustedSendersSyncOn);
|
||||
// false = the user turned it off in Content & Senders.
|
||||
trustedSendersAddressBook: boolean | null;
|
||||
senderFavicons: boolean;
|
||||
hideInlineImageAttachments: boolean;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { MailItem, WidgetSnapshot } from '../snapshot';
|
||||
|
||||
// A fake server and a fake refresh: the refresh reads the server when it
|
||||
// starts and stores what it read when `release` lets it finish, which is how
|
||||
// a slow refresh overlaps a second tap on the device.
|
||||
const server = vi.hoisted(() => ({ inbox: [] as string[] }));
|
||||
const calls = vi.hoisted(() => ({ moves: [] as Array<{ id: string; resolve: (ok: boolean) => void }> }));
|
||||
|
||||
vi.mock('../jmap', () => ({
|
||||
openClient: vi.fn(async () => ({})),
|
||||
markRead: vi.fn(async () => true),
|
||||
rsvp: vi.fn(async () => true),
|
||||
setTaskDone: vi.fn(async () => true),
|
||||
moveTo: vi.fn((_client: unknown, id: string) => new Promise<boolean>((resolve) => {
|
||||
calls.moves.push({
|
||||
id,
|
||||
resolve: (ok) => {
|
||||
if (ok) server.inbox = server.inbox.filter((x) => x !== id);
|
||||
resolve(ok);
|
||||
},
|
||||
});
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../render', () => ({ redrawAll: vi.fn(async () => undefined) }));
|
||||
|
||||
vi.mock('../build', async () => {
|
||||
const { storeRefresh } = await import('../state');
|
||||
return {
|
||||
singletonServes: () => false,
|
||||
refreshSnapshot: vi.fn(async () => {
|
||||
const startedAt = Date.now();
|
||||
const read = [...server.inbox];
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await storeRefresh(snapshotOf(read), startedAt);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function mail(id: string): MailItem {
|
||||
return {
|
||||
id, threadId: `t${id}`, accountId: 'acc', fromName: id, fromEmail: `${id}@x`, initials: 'X', color: '#123456',
|
||||
subject: id, preview: '', receivedAt: 0, unread: true, starred: false, hasAttachment: false, threadSize: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotOf(ids: string[]): WidgetSnapshot {
|
||||
return {
|
||||
version: 1, generatedAt: Date.now(), appDataAt: 0, signedIn: true, theme: 'light', locale: 'en', hour12: false,
|
||||
weekStart: 1, accounts: [], activeAccountId: 'acc',
|
||||
mail: {
|
||||
folders: [{ role: 'inbox', name: 'Inbox', unread: ids.length, total: ids.length }],
|
||||
inbox: ids.map(mail), unified: [], starred: [], starredCount: 0, drafts: [], draftCount: 0, scheduled: [],
|
||||
pendingChanges: 0, favourites: [], recentSearches: [], tags: [], attachments: [],
|
||||
},
|
||||
calendar: { supported: true, events: [], invitations: [], birthdays: [] },
|
||||
tasks: { supported: true, items: [] },
|
||||
files: { supported: false, items: [] },
|
||||
vacation: null,
|
||||
quota: null,
|
||||
};
|
||||
}
|
||||
|
||||
const jmap = await import('../jmap');
|
||||
const { handleWidgetAction } = await import('../actions');
|
||||
const { currentView } = await import('../state');
|
||||
const { saveSnapshot } = await import('../snapshot');
|
||||
|
||||
const shown = async () => (await currentView()).mail.inbox.map((m) => m.id);
|
||||
const until = async (check: () => boolean) => {
|
||||
for (let i = 0; i < 200 && !check(); i++) await new Promise((r) => setTimeout(r, 2));
|
||||
expect(check()).toBe(true);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
calls.moves = [];
|
||||
server.inbox = ['m1', 'm2', 'm3'];
|
||||
await saveSnapshot(snapshotOf(server.inbox));
|
||||
});
|
||||
|
||||
describe('widget task ticks and RSVPs', () => {
|
||||
it('go to the account the tapped drawing showed, not the one the widgets show now', async () => {
|
||||
// The widgets moved on to acc2, which has a task with the same JMAP id.
|
||||
const s = snapshotOf([]);
|
||||
s.activeAccountId = 'acc2';
|
||||
s.tasks.items = [{
|
||||
id: 'e', serverId: 'e', accountId: 'acc2', title: 'Theirs', dueHasTime: false, done: false, calendarName: '', color: '#123456',
|
||||
}];
|
||||
await saveSnapshot(s);
|
||||
vi.mocked(jmap.openClient).mockClear();
|
||||
vi.mocked(jmap.setTaskDone).mockClear();
|
||||
await handleWidgetAction('toggleTask', { id: 'e', done: true, serverId: 'e', accountId: 'acc', title: 'Mine' }, 1);
|
||||
expect(jmap.openClient).toHaveBeenCalledWith('acc');
|
||||
expect(jmap.setTaskDone).toHaveBeenCalledWith(expect.anything(), 'e', true, undefined);
|
||||
});
|
||||
|
||||
it('mark a task done on both of two quick taps rather than undoing the first', async () => {
|
||||
vi.mocked(jmap.setTaskDone).mockClear();
|
||||
const tap = { id: 't', done: true, serverId: 't', accountId: 'acc', title: 'Task' };
|
||||
await Promise.all([handleWidgetAction('toggleTask', tap, 1), handleWidgetAction('toggleTask', tap, 1)]);
|
||||
expect(vi.mocked(jmap.setTaskDone).mock.calls.map((c) => c[2])).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('send an RSVP with the ids the tap carries', async () => {
|
||||
vi.mocked(jmap.rsvp).mockClear();
|
||||
await handleWidgetAction('rsvp', {
|
||||
id: 'h1', status: 'accepted', serverId: 'j', participantId: 'a', accountId: 'acc', jmapAccountId: 'd', title: 'Budget',
|
||||
}, 1);
|
||||
expect(jmap.rsvp).toHaveBeenCalledWith(expect.anything(), 'j', 'a', 'accepted', 'd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('widget archive', () => {
|
||||
it('keeps a message archived when a refresh read the server before its archive landed', async () => {
|
||||
const first = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
|
||||
const second = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7);
|
||||
await until(() => calls.moves.length === 2);
|
||||
expect(await shown()).toEqual(['m3']);
|
||||
|
||||
// The first archive lands; its refresh reads a server that still has m2.
|
||||
calls.moves[0].resolve(true);
|
||||
await first;
|
||||
expect(server.inbox).toEqual(['m2', 'm3']);
|
||||
expect(await shown()).toEqual(['m3']);
|
||||
|
||||
calls.moves[1].resolve(true);
|
||||
await second;
|
||||
expect(await shown()).toEqual(['m3']);
|
||||
});
|
||||
|
||||
it('brings a message back when its archive did not reach the server', async () => {
|
||||
const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
|
||||
await until(() => calls.moves.length === 1);
|
||||
expect(await shown()).toEqual(['m2', 'm3']);
|
||||
calls.moves[0].resolve(false);
|
||||
await tap;
|
||||
expect(await shown()).toEqual(['m1', 'm2', 'm3']);
|
||||
});
|
||||
|
||||
it('says so on the widget, with the tap to repeat, and shows the message again', async () => {
|
||||
const tap = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7);
|
||||
await until(() => calls.moves.length === 1);
|
||||
calls.moves[0].resolve(false);
|
||||
await tap;
|
||||
const view = await currentView();
|
||||
expect(view.notice).toMatchObject({ action: 'archive', label: 'm2', retry: { id: 'm2', accountId: 'acc' } });
|
||||
expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' });
|
||||
|
||||
// Tapping the notice repeats the archive and clears it.
|
||||
const retry = handleWidgetAction(view.notice!.action, view.notice!.retry, 7);
|
||||
await until(() => calls.moves.length === 2);
|
||||
expect((await currentView()).notice).toBeNull();
|
||||
calls.moves[1].resolve(true);
|
||||
await retry;
|
||||
expect(await shown()).toEqual(['m1', 'm3']);
|
||||
expect((await currentView()).notice).toBeNull();
|
||||
});
|
||||
|
||||
it('does not lose either of two taps that land together', async () => {
|
||||
const taps = [
|
||||
handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7),
|
||||
handleWidgetAction('trash', { id: 'm3', accountId: 'acc' }, 7),
|
||||
];
|
||||
await until(() => calls.moves.length === 2);
|
||||
expect(await shown()).toEqual(['m2']);
|
||||
for (const move of calls.moves) move.resolve(true);
|
||||
await Promise.all(taps);
|
||||
expect(await shown()).toEqual(['m2']);
|
||||
});
|
||||
|
||||
it('moves the triage card on to the next unread message', async () => {
|
||||
const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
|
||||
await until(() => calls.moves.length === 1);
|
||||
expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' });
|
||||
calls.moves[0].resolve(true);
|
||||
await tap;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import { avatarImageFor, imageType, resolveAvatarImages, toBase64 } from '../avatar-images';
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
|
||||
const ICO = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10, 0, 0]);
|
||||
const PHOTO = 'data:image/jpeg;base64,/9j/AAAA';
|
||||
|
||||
function card(email: string, uri: string): ContactCard {
|
||||
return {
|
||||
id: email,
|
||||
emails: { e1: { address: email } },
|
||||
media: { m1: { kind: 'photo', uri } },
|
||||
} as unknown as ContactCard;
|
||||
}
|
||||
|
||||
function answer(bytes: Uint8Array | null, ok = true) {
|
||||
return {
|
||||
ok,
|
||||
arrayBuffer: async () => (bytes ?? new Uint8Array()).buffer,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('imageType', () => {
|
||||
it('names the types BitmapFactory draws', () => {
|
||||
expect(imageType(PNG)).toBe('image/png');
|
||||
expect(imageType(ICO)).toBe('image/x-icon');
|
||||
expect(imageType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]))).toBe('image/jpeg');
|
||||
expect(imageType(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0]))).toBe('image/gif');
|
||||
expect(imageType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]))).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('refuses SVG, HTML and short answers', () => {
|
||||
expect(imageType(new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>'))).toBeNull();
|
||||
expect(imageType(new TextEncoder().encode('<!doctype html><html>'))).toBeNull();
|
||||
expect(imageType(new Uint8Array([0x89, 0x50]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toBase64', () => {
|
||||
it('matches the standard encoding with padding', () => {
|
||||
for (const text of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
|
||||
expect(toBase64(new TextEncoder().encode(text))).toBe(Buffer.from(text).toString('base64'));
|
||||
}
|
||||
const all = new Uint8Array(256).map((_, i) => i);
|
||||
expect(toBase64(all)).toBe(Buffer.from(all).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAvatarImages', () => {
|
||||
it('prefers a contact photo and does not fetch a logo for that sender', async () => {
|
||||
const images = await resolveAvatarImages(['Ann@Example.com'], {
|
||||
contacts: [card('ann@example.com', PHOTO)],
|
||||
favicons: true,
|
||||
});
|
||||
expect(images.photos['ann@example.com']).toBe(PHOTO);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(avatarImageFor(images, 'ann@example.com')).toEqual({ src: PHOTO, logo: false });
|
||||
});
|
||||
|
||||
it('leaves out hosted contact photos (they need the account to load)', async () => {
|
||||
fetchMock.mockResolvedValue(answer(null, false));
|
||||
const images = await resolveAvatarImages(['ann@example.com'], {
|
||||
contacts: [card('ann@example.com', 'https://mail.example.com/jmap/download/x')],
|
||||
favicons: true,
|
||||
});
|
||||
expect(images.photos).toEqual({});
|
||||
});
|
||||
|
||||
it('fetches a logo per business domain, not for personal providers', async () => {
|
||||
fetchMock.mockResolvedValue(answer(PNG));
|
||||
const images = await resolveAvatarImages(['news@shop.acme.io', 'bob@gmail.com', 'eve@acme.io'], {
|
||||
contacts: [],
|
||||
favicons: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toContain('acme.io');
|
||||
const logo = avatarImageFor(images, 'eve@acme.io');
|
||||
expect(logo).toEqual({ src: `data:image/png;base64,${toBase64(PNG)}`, logo: true });
|
||||
expect(avatarImageFor(images, 'bob@gmail.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches no logos when sender favicons are off', async () => {
|
||||
const images = await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: false });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(images.favicons).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps logos that are not images or too big out', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(answer(new TextEncoder().encode('<!doctype html><html></html>')))
|
||||
.mockResolvedValueOnce(answer(new Uint8Array(50 * 1024).fill(0).map((v, i) => (i < 8 ? PNG[i] : v))));
|
||||
const images = await resolveAvatarImages(['a@one.dev', 'b@two.dev'], { contacts: [], favicons: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(images.favicons).toEqual({});
|
||||
});
|
||||
|
||||
it('asks a domain again only after its cache time', async () => {
|
||||
fetchMock.mockResolvedValue(answer(ICO));
|
||||
const now = Date.UTC(2026, 9, 1);
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 6 * 86400000 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 8 * 86400000 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the cached logo when offline', async () => {
|
||||
const now = Date.UTC(2026, 9, 1);
|
||||
fetchMock.mockResolvedValueOnce(answer(PNG));
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('Network request failed'));
|
||||
const later = await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 30 * 86400000 });
|
||||
expect(avatarImageFor(later, 'eve@acme.io')?.logo).toBe(true);
|
||||
});
|
||||
|
||||
it('remembers a domain without a logo for a day', async () => {
|
||||
const now = Date.UTC(2026, 9, 1);
|
||||
fetchMock.mockResolvedValue(answer(null, false));
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 3600000 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 2 * 86400000 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatarImageFor', () => {
|
||||
it('has nothing without pictures or an address', () => {
|
||||
expect(avatarImageFor(undefined, 'eve@acme.io')).toBeNull();
|
||||
expect(avatarImageFor({ photos: {}, favicons: {} }, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { makeFmt } from '../format';
|
||||
|
||||
describe('count', () => {
|
||||
it('keeps counts as they are below 10,000 and shortens bigger ones', () => {
|
||||
const f = makeFmt('en', false);
|
||||
expect(f.count(0)).toBe('0');
|
||||
expect(f.count(9999)).toBe('9999');
|
||||
expect(f.count(10007)).toBe('10K');
|
||||
expect(f.count(12345)).toBe('12.3K');
|
||||
expect(f.count(234567)).toBe('235K');
|
||||
expect(f.count(2_500_000)).toBe('2.5M');
|
||||
expect(makeFmt('de', false).count(12345)).toBe('12,3K');
|
||||
});
|
||||
});
|
||||
@@ -41,8 +41,25 @@ vi.mock('react-native-android-widget', async () => {
|
||||
if (typeof props.svg !== 'string') throw new Error('widgets draw inline SVG strings only');
|
||||
return { ...convertCommonStyle(props.style ?? {}), ...convertClickAction(props), svgString: props.svg };
|
||||
};
|
||||
// The compiled ImageWidget requires react-native too; the widgets only draw data URIs.
|
||||
function ImageWidget() {
|
||||
return null;
|
||||
}
|
||||
ImageWidget.__name__ = 'ImageWidget';
|
||||
ImageWidget.convertProps = (props: { style?: object; image: unknown; imageWidth: number; imageHeight: number; radius?: number }) => {
|
||||
if (typeof props.image !== 'string' || !props.image.startsWith('data:image')) throw new Error('widgets draw data-URI images only');
|
||||
return {
|
||||
...convertCommonStyle(props.style ?? {}),
|
||||
...convertClickAction(props),
|
||||
imageWidth: props.imageWidth,
|
||||
imageHeight: props.imageHeight,
|
||||
image: { uri: props.image },
|
||||
...(props.radius ? { radius: props.radius } : {}),
|
||||
};
|
||||
};
|
||||
return {
|
||||
...checked(await import('react-native-android-widget/lib/commonjs/widgets/FlexWidget' as string)),
|
||||
...checked({ ImageWidget }),
|
||||
...checked(await import('react-native-android-widget/lib/commonjs/widgets/ListWidget' as string)),
|
||||
...checked(await import('react-native-android-widget/lib/commonjs/widgets/OverlapWidget' as string)),
|
||||
...checked(await import('react-native-android-widget/lib/commonjs/widgets/TextWidget' as string)),
|
||||
@@ -118,6 +135,30 @@ describe('widget layouts', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('builds every action widget with a failure notice at every size', () => {
|
||||
const notices = [
|
||||
['TriageWidget', 'archive'],
|
||||
['TriageWidget', 'trash'],
|
||||
['InvitationsWidget', 'rsvp'],
|
||||
['TasksWidget', 'toggleTask'],
|
||||
['TodayHubWidget', 'toggleTask'],
|
||||
] as const;
|
||||
for (const [name, action] of notices) {
|
||||
const w = catalog.widgets.find((x) => x.name === name)!;
|
||||
const s = { ...sample, notice: { action, label: 'A task with a rather long title', at: PREVIEW_NOW, retry: { id: 'x' } } };
|
||||
for (const [cols, rows] of sizes(w)) build(name, s, cols, rows);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds avatars with contact photos and sender logos', () => {
|
||||
const png = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
const images = {
|
||||
photos: Object.fromEntries(sample.mail.inbox.slice(0, 2).map((m) => [m.fromEmail.toLowerCase(), png])),
|
||||
favicons: Object.fromEntries(sample.mail.inbox.map((m) => [m.fromEmail.split('@')[1], png])),
|
||||
};
|
||||
for (const w of catalog.widgets) build(w.name, { ...sample, images }, w.cols, w.rows);
|
||||
});
|
||||
|
||||
it('builds the triage card on a message that is gone', () => {
|
||||
build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' });
|
||||
build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' });
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyPending,
|
||||
confirmedBy,
|
||||
DONE_TTL_MS,
|
||||
expire,
|
||||
IN_FLIGHT_TTL_MS,
|
||||
NOTICE_TTL_MS,
|
||||
type PendingOp,
|
||||
} from '../pending';
|
||||
import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
|
||||
|
||||
const op = (change: PendingOp['change'], extra: Partial<PendingOp> = {}): PendingOp => ({
|
||||
key: `${change.kind}:${change.id}`,
|
||||
change,
|
||||
at: 1000,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('applyPending', () => {
|
||||
it('takes an archived message out of every list and the inbox counts', () => {
|
||||
const s = sampleSnapshot(PREVIEW_NOW);
|
||||
const target = s.mail.inbox.find((m) => m.unread)!;
|
||||
const inbox = s.mail.folders.find((f) => f.role === 'inbox')!;
|
||||
const shown = applyPending(s, [op({ kind: 'removeMail', id: target.id })]);
|
||||
expect(shown.mail.inbox.some((m) => m.id === target.id)).toBe(false);
|
||||
expect(shown.mail.unified.some((m) => m.id === target.id)).toBe(false);
|
||||
const after = shown.mail.folders.find((f) => f.role === 'inbox')!;
|
||||
expect([after.total, after.unread]).toEqual([inbox.total - 1, inbox.unread - 1]);
|
||||
// The stored snapshot is left as the server described it.
|
||||
expect(s.mail.inbox.some((m) => m.id === target.id)).toBe(true);
|
||||
expect(s.mail.folders.find((f) => f.role === 'inbox')!.total).toBe(inbox.total);
|
||||
});
|
||||
|
||||
it('marks a message read once', () => {
|
||||
const s = sampleSnapshot(PREVIEW_NOW);
|
||||
const target = s.mail.inbox.find((m) => m.unread)!;
|
||||
const before = s.mail.folders.find((f) => f.role === 'inbox')!.unread;
|
||||
const twice = [op({ kind: 'markRead', id: target.id }), op({ kind: 'markRead', id: target.id }, { key: 'b' })];
|
||||
const shown = applyPending(s, twice);
|
||||
expect(shown.mail.inbox.find((m) => m.id === target.id)!.unread).toBe(false);
|
||||
expect(shown.mail.folders.find((f) => f.role === 'inbox')!.unread).toBe(before - 1);
|
||||
});
|
||||
|
||||
it('answers an invitation and ticks a task', () => {
|
||||
const s = sampleSnapshot(PREVIEW_NOW);
|
||||
const inv = s.calendar.invitations[0];
|
||||
const task = s.tasks.items.find((t) => !t.done)!;
|
||||
const shown = applyPending(s, [
|
||||
op({ kind: 'rsvp', id: inv.id, serverId: inv.serverId, status: 'accepted' }),
|
||||
op({ kind: 'task', id: task.id, done: true }),
|
||||
]);
|
||||
expect(shown.calendar.invitations.some((i) => i.serverId === inv.serverId)).toBe(false);
|
||||
expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(true);
|
||||
expect(s.tasks.items.find((t) => t.id === task.id)!.done).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPending across accounts', () => {
|
||||
it('leaves an item of another account with the same id alone', () => {
|
||||
const s = sampleSnapshot(PREVIEW_NOW);
|
||||
const task = s.tasks.items.find((t) => !t.done)!;
|
||||
const shown = applyPending(s, [op({ kind: 'task', id: task.id, done: true, accountId: 'not-this-one' })]);
|
||||
expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(false);
|
||||
const own = applyPending(s, [op({ kind: 'task', id: task.id, done: true, accountId: s.activeAccountId! })]);
|
||||
expect(own.tasks.items.find((t) => t.id === task.id)!.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmedBy', () => {
|
||||
const mail = op({ kind: 'removeMail', id: 'm' }, { doneAt: 2000 });
|
||||
const task = op({ kind: 'task', id: 't', done: true }, { doneAt: 2000 });
|
||||
const inFlight = op({ kind: 'removeMail', id: 'n' });
|
||||
|
||||
it('keeps a change a refresh started before it reached the server', () => {
|
||||
// The refresh read the server at 1500, before the archive landed at 2000:
|
||||
// its data still has the message, so the op must keep hiding it.
|
||||
expect(confirmedBy([mail, inFlight], 1500, true)).toEqual([mail, inFlight]);
|
||||
});
|
||||
|
||||
it('drops a mail change once a later refresh has stored it', () => {
|
||||
expect(confirmedBy([mail, inFlight], 2500, false)).toEqual([inFlight]);
|
||||
});
|
||||
|
||||
it('keeps a task change until a refresh that loaded tasks', () => {
|
||||
expect(confirmedBy([task], 2500, false)).toEqual([task]);
|
||||
expect(confirmedBy([task], 2500, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expire', () => {
|
||||
it('drops stuck and stale ops and an old notice', () => {
|
||||
const now = 10_000_000;
|
||||
const stuck = op({ kind: 'removeMail', id: 'a' }, { at: now - IN_FLIGHT_TTL_MS - 1 });
|
||||
const fresh = op({ kind: 'removeMail', id: 'b' }, { at: now - 1000 });
|
||||
const stale = op({ kind: 'task', id: 'c', done: true }, { at: 0, doneAt: now - DONE_TTL_MS - 1 });
|
||||
const recent = op({ kind: 'task', id: 'd', done: true }, { at: 0, doneAt: now - 1000 });
|
||||
const notice = (at: number) => ({ action: 'archive' as const, label: 'x', at, retry: {} });
|
||||
expect(expire({ ops: [stuck, fresh, stale, recent], notice: notice(now - NOTICE_TTL_MS - 1) }, now))
|
||||
.toEqual({ ops: [fresh, recent], notice: null });
|
||||
expect(expire({ ops: [], notice: notice(now - 1000) }, now).notice).toEqual(notice(now - 1000));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const native = vi.hoisted(() => ({ BulwarkWidgets: undefined as undefined | { refreshInBackground: () => Promise<boolean> } }));
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'android' }, AppState: {}, NativeModules: native }));
|
||||
|
||||
const refreshSnapshot = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock('../build', () => ({ refreshSnapshot, readRegistry: vi.fn() }));
|
||||
vi.mock('../render', () => ({ hasPlacedWidgets: vi.fn(async () => true), redrawAll: vi.fn(async () => undefined) }));
|
||||
vi.mock('../state', () => ({ replaceAll: vi.fn() }));
|
||||
|
||||
const { handleAppState } = await import('../sync');
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
refreshSnapshot.mockClear();
|
||||
});
|
||||
|
||||
describe('widget sync', () => {
|
||||
it('refreshes when the app goes to the background without waiting for a timer', async () => {
|
||||
// React Native pauses JS timers while the activity is in the background,
|
||||
// so not even a zero-delay timer may stand between leaving the app and
|
||||
// the refresh. Fake timers that never advance stand in for that.
|
||||
vi.useFakeTimers();
|
||||
handleAppState('background');
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the refresh to a headless task where the app has one', async () => {
|
||||
vi.useFakeTimers();
|
||||
const refreshInBackground = vi.fn(async () => true);
|
||||
native.BulwarkWidgets = { refreshInBackground };
|
||||
handleAppState('background');
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
expect(refreshInBackground).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
// Android refused to start the service: refresh in place after all.
|
||||
refreshInBackground.mockResolvedValueOnce(false);
|
||||
handleAppState('background');
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
|
||||
native.BulwarkWidgets = undefined;
|
||||
});
|
||||
|
||||
it('leaves the app coming back to the scheduled refreshes', async () => {
|
||||
vi.useFakeTimers();
|
||||
handleAppState('active');
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
expect(refreshSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,21 @@
|
||||
// Buttons that act without opening the app: archive / delete / next on the
|
||||
// triage card, RSVP on the invitation card, ticking a task. Each one updates
|
||||
// the stored snapshot first so every widget redraws at once, then makes the
|
||||
// JMAP call, then refreshes from the server; a failed call is undone by that
|
||||
// refresh.
|
||||
// triage card, RSVP on the invitation card, ticking a task. Each one lays its
|
||||
// change over the stored data at once (./state.ts) so every widget redraws
|
||||
// without waiting, then makes the JMAP call. A change the server took stays
|
||||
// laid over the data until a refresh has caught up with it; one that failed
|
||||
// is dropped again, which shows the data as the server last described it,
|
||||
// and the widget says so with a notice that repeats the tap.
|
||||
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import type { JMAPClient } from '../api/jmap-client';
|
||||
import { noteLocalChange, refreshSnapshot, singletonServes } from './build';
|
||||
import { refreshSnapshot, singletonServes } from './build';
|
||||
import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap';
|
||||
import { loadLocal, saveLocal } from './local-state';
|
||||
import { updateAllWidgets } from './render';
|
||||
import { emptySnapshot, loadSnapshot, saveSnapshot, type MailItem, type WidgetSnapshot } from './snapshot';
|
||||
import type { PendingChange } from './pending';
|
||||
import type { ActionNotice } from './snapshot';
|
||||
import { redrawAll } from './render';
|
||||
import { serial } from './serial';
|
||||
import { currentView, settle, track } from './state';
|
||||
|
||||
async function clientFor(registryAccountId: string | null | undefined): Promise<JMAPClient | null> {
|
||||
if (!registryAccountId) return null;
|
||||
@@ -18,52 +23,69 @@ async function clientFor(registryAccountId: string | null | undefined): Promise<
|
||||
return openClient(registryAccountId);
|
||||
}
|
||||
|
||||
async function applyLocally(mutate: (s: WidgetSnapshot) => void): Promise<WidgetSnapshot> {
|
||||
noteLocalChange();
|
||||
const s = (await loadSnapshot()) ?? emptySnapshot();
|
||||
mutate(s);
|
||||
await saveSnapshot(s);
|
||||
await updateAllWidgets(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function withoutMessage(s: WidgetSnapshot, id: string): void {
|
||||
const removed = s.mail.inbox.find((m) => m.id === id);
|
||||
const drop = (list: MailItem[]) => list.filter((m) => m.id !== id);
|
||||
s.mail.inbox = drop(s.mail.inbox);
|
||||
s.mail.unified = drop(s.mail.unified);
|
||||
s.mail.starred = drop(s.mail.starred);
|
||||
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
|
||||
if (inbox && removed) {
|
||||
inbox.total = Math.max(0, inbox.total - 1);
|
||||
if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function afterServerCall(ok: boolean): Promise<void> {
|
||||
if (!ok) console.warn('[widgets] action was not applied on the server');
|
||||
const fresh = await refreshSnapshot({ after: 'change' });
|
||||
await updateAllWidgets(fresh);
|
||||
}
|
||||
|
||||
type Data = Record<string, unknown>;
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
||||
|
||||
/**
|
||||
* Lay `change` over the data, run `call`, and keep or drop the change by its
|
||||
* answer. On failure the widget shows a notice about `label` that repeats
|
||||
* the tap (`name` with `data`).
|
||||
*/
|
||||
async function apply(
|
||||
name: ActionNotice['action'],
|
||||
data: Data,
|
||||
label: string,
|
||||
change: PendingChange,
|
||||
call: () => Promise<boolean>,
|
||||
onFailed?: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
const key = await track(change);
|
||||
await redrawAll();
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await call();
|
||||
} catch (err) {
|
||||
console.warn(`[widgets] ${name} failed`, err);
|
||||
}
|
||||
if (!ok) console.warn(`[widgets] ${name} was not applied on the server`);
|
||||
await settle(key, ok, { action: name, label, at: Date.now(), retry: data });
|
||||
if (!ok) await onFailed?.();
|
||||
await redrawAll();
|
||||
// Catch up with the server: confirms a change that went through and picks
|
||||
// up whatever else changed. Offline this fails quickly and keeps the data.
|
||||
await refreshSnapshot({ after: 'change' });
|
||||
await redrawAll();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** The triage card's message after `id`, among what the widget shows now. */
|
||||
async function moveTriagePointer(widgetId: number, id: string): Promise<void> {
|
||||
const view = await currentView();
|
||||
const unread = view.mail.inbox.filter((m) => m.unread);
|
||||
const index = unread.findIndex((m) => m.id === id);
|
||||
const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined;
|
||||
await pointTriage(widgetId, next?.id ?? null);
|
||||
}
|
||||
|
||||
function pointTriage(widgetId: number, triageId: string | null): Promise<void> {
|
||||
return serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId }));
|
||||
}
|
||||
|
||||
export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise<void> {
|
||||
switch (name) {
|
||||
case 'refresh':
|
||||
await afterServerCall(true);
|
||||
await refreshSnapshot({ after: 'change' });
|
||||
await redrawAll();
|
||||
return;
|
||||
|
||||
case 'triageNext': {
|
||||
const s = (await loadSnapshot()) ?? emptySnapshot();
|
||||
const unread = s.mail.inbox.filter((m) => m.unread);
|
||||
const view = await currentView();
|
||||
const unread = view.mail.inbox.filter((m) => m.unread);
|
||||
if (unread.length === 0) return;
|
||||
const index = unread.findIndex((m) => m.id === data.id);
|
||||
const next = unread[(index + 1) % unread.length];
|
||||
const local = await loadLocal(widgetId);
|
||||
await saveLocal(widgetId, { ...local, triageId: next.id });
|
||||
await updateAllWidgets(s);
|
||||
await pointTriage(widgetId, next.id);
|
||||
await redrawAll();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,39 +94,23 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num
|
||||
case 'markRead': {
|
||||
const id = str(data.id);
|
||||
if (!id) return;
|
||||
if (name === 'markRead') {
|
||||
await applyLocally((s) => {
|
||||
const hit = s.mail.inbox.find((m) => m.id === id);
|
||||
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
|
||||
if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1);
|
||||
for (const list of [s.mail.inbox, s.mail.unified, s.mail.starred]) {
|
||||
for (const m of list) if (m.id === id) m.unread = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// The triage card moves on to the next unread message, not back to
|
||||
// the first one.
|
||||
const before = (await loadSnapshot()) ?? emptySnapshot();
|
||||
const unread = before.mail.inbox.filter((m) => m.unread);
|
||||
const index = unread.findIndex((m) => m.id === id);
|
||||
const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined;
|
||||
if (typeof data.widgetId === 'number') {
|
||||
await saveLocal(data.widgetId, { ...(await loadLocal(data.widgetId)), triageId: next?.id ?? null });
|
||||
}
|
||||
await applyLocally((s) => withoutMessage(s, id));
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
const client = await clientFor(str(data.accountId));
|
||||
if (client) {
|
||||
ok = name === 'markRead'
|
||||
? await markRead(client, id, str(data.jmapAccountId))
|
||||
: await moveTo(client, id, name, str(data.jmapAccountId));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[widgets] ${name} failed`, err);
|
||||
}
|
||||
await afterServerCall(ok);
|
||||
const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? '';
|
||||
// The triage card moves on to the next unread message, not back to
|
||||
// the first one.
|
||||
if (name !== 'markRead') await moveTriagePointer(widgetId, id);
|
||||
const accountId = str(data.accountId);
|
||||
const jmapAccountId = str(data.jmapAccountId);
|
||||
const change: PendingChange = name === 'markRead' ? { kind: 'markRead', id } : { kind: 'removeMail', id };
|
||||
await apply(name, data, subject, change, async () => {
|
||||
const client = await clientFor(accountId);
|
||||
if (!client) return false;
|
||||
return name === 'markRead'
|
||||
? markRead(client, id, jmapAccountId)
|
||||
: moveTo(client, id, name, jmapAccountId);
|
||||
}, async () => {
|
||||
// The card goes back to the message, next to the notice about it.
|
||||
if (name !== 'markRead') await pointTriage(widgetId, id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,48 +118,53 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num
|
||||
const id = str(data.id);
|
||||
const status = data.status;
|
||||
if (!id || (status !== 'accepted' && status !== 'tentative' && status !== 'declined')) return;
|
||||
const before = (await loadSnapshot()) ?? emptySnapshot();
|
||||
const invitation = before.calendar.invitations.find((i) => i.id === id);
|
||||
if (!invitation) return;
|
||||
await applyLocally((s) => {
|
||||
s.calendar.invitations = s.calendar.invitations.filter((i) => i.id !== id);
|
||||
for (const e of s.calendar.events) if (e.serverId === invitation.serverId) e.myStatus = status;
|
||||
});
|
||||
let ok = false;
|
||||
try {
|
||||
const client = await clientFor(before.activeAccountId);
|
||||
if (client) ok = await rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId);
|
||||
} catch (err) {
|
||||
console.warn('[widgets] rsvp failed', err);
|
||||
// The tap carries the invitation (clicks.ts); a drawing made before it
|
||||
// did is looked up in what the widgets show now.
|
||||
const view = await currentView();
|
||||
const shown = view.calendar.invitations.find((i) => i.id === id);
|
||||
const accountId = str(data.accountId) ?? shown?.accountId ?? view.activeAccountId;
|
||||
const serverId = str(data.serverId) ?? shown?.serverId;
|
||||
const participantId = str(data.participantId) ?? shown?.participantId;
|
||||
const jmapAccountId = str(data.jmapAccountId) ?? (str(data.serverId) ? undefined : shown?.jmapAccountId);
|
||||
if (!serverId || !participantId) {
|
||||
await redrawAll();
|
||||
return;
|
||||
}
|
||||
await afterServerCall(ok);
|
||||
const title = str(data.title) ?? shown?.title ?? '';
|
||||
await apply('rsvp', data, title, { kind: 'rsvp', id, serverId, status, accountId: accountId ?? undefined }, async () => {
|
||||
const client = await clientFor(accountId);
|
||||
return !!client && rsvp(client, serverId, participantId, status, jmapAccountId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case 'toggleTask': {
|
||||
const id = str(data.id);
|
||||
if (!id) return;
|
||||
const before = (await loadSnapshot()) ?? emptySnapshot();
|
||||
const task = before.tasks.items.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
const done = !task.done;
|
||||
await applyLocally((s) => {
|
||||
for (const t of s.tasks.items) if (t.id === id) t.done = done;
|
||||
});
|
||||
let ok = false;
|
||||
try {
|
||||
const client = await clientFor(before.activeAccountId);
|
||||
if (client) ok = await setTaskDone(client, task.serverId, done, task.jmapAccountId);
|
||||
// In the live app the snapshot's tasks come from the calendar store,
|
||||
// which did not see this change; reload it so the refresh keeps it.
|
||||
if (ok && before.activeAccountId && singletonServes(before.activeAccountId)) {
|
||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
|
||||
await useCalendarStore.getState().fetchTasks();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[widgets] task toggle failed', err);
|
||||
const view = await currentView();
|
||||
const shown = view.tasks.items.find((t) => t.id === id);
|
||||
const accountId = str(data.accountId) ?? shown?.accountId ?? view.activeAccountId;
|
||||
const serverId = str(data.serverId) ?? shown?.serverId;
|
||||
const jmapAccountId = str(data.jmapAccountId) ?? (str(data.serverId) ? undefined : shown?.jmapAccountId);
|
||||
// The state the user asked for; older drawings only named the task.
|
||||
const done = typeof data.done === 'boolean' ? data.done : shown ? !shown.done : undefined;
|
||||
if (!serverId || done === undefined) {
|
||||
await redrawAll();
|
||||
return;
|
||||
}
|
||||
await afterServerCall(ok);
|
||||
const title = str(data.title) ?? shown?.title ?? '';
|
||||
await apply('toggleTask', data, title, { kind: 'task', id, done, accountId: accountId ?? undefined }, async () => {
|
||||
const client = await clientFor(accountId);
|
||||
if (!client || !(await setTaskDone(client, serverId, done, jmapAccountId))) return false;
|
||||
// In the live app the snapshot's tasks come from the calendar store,
|
||||
// which did not see this change; reload it before the refresh that
|
||||
// follows reads it, or that refresh would undo the tick.
|
||||
if (client === jmapClient) {
|
||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
|
||||
await useCalendarStore.getState().fetchTasks().catch(() => undefined);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Pictures for the widgets' avatars, in the webmail Avatar's order: the
|
||||
// sender's contact photo, then the logo of the sender's domain (when "sender
|
||||
// favicons" is on and the domain is not a personal mail provider), else the
|
||||
// initials. A widget draws from the stored snapshot and cannot fetch while it
|
||||
// draws (offline it would come out blank), so the pictures are fetched when
|
||||
// the snapshot is built and kept in it as small data URIs.
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { ContactCard } from '../api/types';
|
||||
import { getContactPhotoForEmail, getFaviconDomain, getFaviconUrl } from '../lib/avatar-utils';
|
||||
import type { AvatarImages } from './snapshot';
|
||||
|
||||
const CACHE_KEY = 'widgets:favicons:v1';
|
||||
/** A logo is fetched again after a week, a domain without one after a day. */
|
||||
const HIT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MISS_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/** Pictures bigger than this stay out of the snapshot (it is read on every draw). */
|
||||
const MAX_BYTES = 40 * 1024;
|
||||
const MAX_DOMAINS = 24;
|
||||
const FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
type FaviconCache = Record<string, { data: string | null; at: number }>;
|
||||
|
||||
async function loadCache(): Promise<FaviconCache> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CACHE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
return parsed && typeof parsed === 'object' ? (parsed as FaviconCache) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The image type of `bytes` when BitmapFactory can draw it, from its first bytes. */
|
||||
export function imageType(bytes: Uint8Array): string | null {
|
||||
const b = (i: number) => bytes[i];
|
||||
if (bytes.length < 8) return null;
|
||||
if (b(0) === 0x89 && b(1) === 0x50 && b(2) === 0x4e && b(3) === 0x47) return 'image/png';
|
||||
if (b(0) === 0x00 && b(1) === 0x00 && b(2) === 0x01 && b(3) === 0x00) return 'image/x-icon';
|
||||
if (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff) return 'image/jpeg';
|
||||
if (b(0) === 0x47 && b(1) === 0x49 && b(2) === 0x46) return 'image/gif';
|
||||
if (b(0) === 0x52 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x46 && b(8) === 0x57) return 'image/webp';
|
||||
return null;
|
||||
}
|
||||
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
export function toBase64(bytes: Uint8Array): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
||||
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
|
||||
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=';
|
||||
out += i + 2 < bytes.length ? B64[n & 63] : '=';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The domain's logo as a data URI; null when there is none; undefined when it could not be asked (offline). */
|
||||
async function fetchFavicon(domain: string): Promise<string | null | undefined> {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = setTimeout(() => controller?.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(getFaviconUrl(domain), controller ? { signal: controller.signal } : undefined);
|
||||
if (!res.ok) return null;
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const type = imageType(bytes);
|
||||
if (!type || bytes.length > MAX_BYTES) return null;
|
||||
return `data:${type};base64,${toBase64(bytes)}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Contact photos small enough to carry, embedded ones only (a hosted photo needs the account's credentials). */
|
||||
function contactPhoto(contacts: readonly ContactCard[], email: string): string | null {
|
||||
const uri = getContactPhotoForEmail(contacts, email);
|
||||
if (!uri || !uri.startsWith('data:image') || uri.length > (MAX_BYTES * 4) / 3 + 64) return null;
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pictures for `emails`: contact photos by address, logos by domain. Logos
|
||||
* come from a cache of their own, fetched for at most a few domains a run.
|
||||
*/
|
||||
export async function resolveAvatarImages(
|
||||
emails: readonly string[],
|
||||
opts: { contacts: readonly ContactCard[]; favicons: boolean; now?: number },
|
||||
): Promise<AvatarImages> {
|
||||
const now = opts.now ?? Date.now();
|
||||
const photos: Record<string, string> = {};
|
||||
const domains = new Set<string>();
|
||||
for (const raw of emails) {
|
||||
const email = raw.trim().toLowerCase();
|
||||
if (!email || photos[email]) continue;
|
||||
const photo = contactPhoto(opts.contacts, email);
|
||||
if (photo) {
|
||||
photos[email] = photo;
|
||||
continue;
|
||||
}
|
||||
const domain = opts.favicons ? getFaviconDomain(email) : null;
|
||||
if (domain) domains.add(domain);
|
||||
}
|
||||
|
||||
const favicons: Record<string, string> = {};
|
||||
if (domains.size > 0) {
|
||||
const cache = await loadCache();
|
||||
let changed = false;
|
||||
const wanted = [...domains].slice(0, MAX_DOMAINS);
|
||||
const stale = wanted.filter((d) => {
|
||||
const hit = cache[d];
|
||||
return !hit || now - hit.at > (hit.data ? HIT_TTL_MS : MISS_TTL_MS);
|
||||
});
|
||||
for (let i = 0; i < stale.length; i += 4) {
|
||||
await Promise.all(stale.slice(i, i + 4).map(async (domain) => {
|
||||
const data = await fetchFavicon(domain);
|
||||
if (data === undefined) return; // offline: keep what we had
|
||||
cache[domain] = { data, at: now };
|
||||
changed = true;
|
||||
}));
|
||||
}
|
||||
for (const domain of wanted) {
|
||||
const data = cache[domain]?.data;
|
||||
if (data) favicons[domain] = data;
|
||||
}
|
||||
if (changed) {
|
||||
try {
|
||||
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
// the next refresh asks again
|
||||
}
|
||||
}
|
||||
}
|
||||
return { photos, favicons };
|
||||
}
|
||||
|
||||
/** The picture for `email` in `images`, and whether it is a logo (drawn on white). */
|
||||
export function avatarImageFor(
|
||||
images: AvatarImages | undefined,
|
||||
email: string | undefined,
|
||||
): { src: string; logo: boolean } | null {
|
||||
if (!images || !email) return null;
|
||||
const key = email.trim().toLowerCase();
|
||||
const photo = images.photos[key];
|
||||
if (photo) return { src: photo, logo: false };
|
||||
const domain = getFaviconDomain(key);
|
||||
const logo = domain ? images.favicons[domain] : undefined;
|
||||
return logo ? { src: logo, logo: true } : null;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
// Builds the widget snapshot. Mail comes from JMAP on a client of the widgets'
|
||||
// own (./jmap.ts) and works from a cold headless start. Calendar, tasks,
|
||||
// birthdays, files and scheduled sends reuse the app's helpers, which are
|
||||
// bound to the `jmapClient` singleton; they only run when that singleton is
|
||||
// already connected to the active account (the app's JS runtime is alive).
|
||||
// Otherwise those parts carry over from the previous snapshot, and the
|
||||
// time-dependent views are still recomputed when a widget draws.
|
||||
// own (./jmap.ts). Calendar, tasks, birthdays, files and scheduled sends reuse
|
||||
// the app's helpers, which are bound to the `jmapClient` singleton: they run
|
||||
// when that singleton is connected to the active account, either because the
|
||||
// app is running or because no UI has started in this runtime and the refresh
|
||||
// signed it in itself (see borrowSingleton). Otherwise (the app is up but
|
||||
// signed in elsewhere, or offline) those parts carry over from the previous
|
||||
// snapshot; the time-dependent views are still recomputed when a widget draws.
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
@@ -22,10 +23,12 @@ import { addDays, startOfDay } from './format';
|
||||
import { startOfWeek } from './derive';
|
||||
import { fetchInboxPreview, fetchMailSection, openClient, type MailSection } from './jmap';
|
||||
import { normalizeHex } from './theme';
|
||||
import { resolveAvatarImages } from './avatar-images';
|
||||
import { storeRefresh } from './state';
|
||||
import { uiStarted } from './ui-presence';
|
||||
import {
|
||||
emptySnapshot,
|
||||
loadSnapshot,
|
||||
saveSnapshot,
|
||||
type AccountSummary,
|
||||
type Birthday,
|
||||
type EventItem,
|
||||
@@ -120,6 +123,7 @@ function toEventItem(
|
||||
event: CalendarEvent,
|
||||
calendarsById: Map<string, { name: string; color?: string }>,
|
||||
selfEmails: string[],
|
||||
accountId: string,
|
||||
): EventItem {
|
||||
const calId = Object.keys(event.calendarIds ?? {}).find((id) => event.calendarIds[id]);
|
||||
const cal = calId ? calendarsById.get(calId) : undefined;
|
||||
@@ -155,6 +159,7 @@ function toEventItem(
|
||||
return {
|
||||
id: event.id,
|
||||
serverId: event.originalId || event.id,
|
||||
accountId,
|
||||
...(event.accountId ? { jmapAccountId: event.accountId } : {}),
|
||||
title: event.title || '',
|
||||
start,
|
||||
@@ -172,7 +177,12 @@ function toEventItem(
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCalendar(now: number, selfEmails: string[], previous: WidgetSnapshot): Promise<WidgetSnapshot['calendar']> {
|
||||
async function buildCalendar(
|
||||
now: number,
|
||||
selfEmails: string[],
|
||||
previous: WidgetSnapshot,
|
||||
accountId: string,
|
||||
): Promise<WidgetSnapshot['calendar']> {
|
||||
const calendarsSupported = jmapClient.hasAccountCapability(CAPABILITIES.CALENDARS);
|
||||
if (!calendarsSupported) return { supported: false, events: [], invitations: [], birthdays: await buildBirthdays(now) };
|
||||
const { useCalendarStore, loadEventsInRange } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
|
||||
@@ -203,7 +213,7 @@ async function buildCalendar(now: number, selfEmails: string[], previous: Widget
|
||||
}
|
||||
const live = raw.filter((e) => e.status !== 'cancelled');
|
||||
const events = live
|
||||
.map((e) => toEventItem(e, byId, selfEmails))
|
||||
.map((e) => toEventItem(e, byId, selfEmails, accountId))
|
||||
.filter((e) => e.end >= rangeStart && e.start < rangeEnd)
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
@@ -215,7 +225,7 @@ async function buildCalendar(now: number, selfEmails: string[], previous: Widget
|
||||
const participantId = getUserParticipantId(e, selfEmails);
|
||||
const key = e.originalId || e.id;
|
||||
if (!participantId || seen.has(key)) continue;
|
||||
const item = toEventItem(e, byId, selfEmails);
|
||||
const item = toEventItem(e, byId, selfEmails, accountId);
|
||||
if (item.end < now) continue;
|
||||
seen.add(key);
|
||||
invitations.push({ ...item, participantId });
|
||||
@@ -262,12 +272,12 @@ async function buildBirthdays(now: number): Promise<Birthday[]> {
|
||||
return out.sort((a, b) => a.date - b.date).slice(0, 20);
|
||||
}
|
||||
|
||||
async function buildTasks(previous: WidgetSnapshot): Promise<WidgetSnapshot['tasks']> {
|
||||
async function buildTasks(previous: WidgetSnapshot, accountId: string, reload: boolean): Promise<WidgetSnapshot['tasks']> {
|
||||
if (!useSettingsStore.getState().enableCalendarTasks) return { supported: false, items: [] };
|
||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
|
||||
try {
|
||||
const store = useCalendarStore.getState();
|
||||
if (store.tasks.length === 0) await store.fetchTasks();
|
||||
if (reload || store.tasks.length === 0) await store.fetchTasks();
|
||||
const { tasks, calendars } = useCalendarStore.getState();
|
||||
const byId = new Map(calendars.map((c) => [c.id, c]));
|
||||
const items: TaskItem[] = tasks
|
||||
@@ -280,6 +290,7 @@ async function buildTasks(previous: WidgetSnapshot): Promise<WidgetSnapshot['tas
|
||||
return {
|
||||
id: t.id,
|
||||
serverId: t.originalId || t.id,
|
||||
accountId,
|
||||
...(t.accountId ? { jmapAccountId: t.accountId } : {}),
|
||||
title: t.title || '',
|
||||
...(Number.isFinite(due) ? { due } : {}),
|
||||
@@ -350,7 +361,11 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot>
|
||||
if (!keywordsState.hydrated) await keywordsState.hydrate();
|
||||
const { theme, timeFormat, calendarFirstDayOfWeek } = useSettingsStore.getState();
|
||||
|
||||
const live = singletonServes(activeAccountId);
|
||||
// With no UI in this runtime (a widget or push task started it), sign the
|
||||
// app's client in to the active account, so calendar and tasks load as
|
||||
// they do in the app instead of staying as the app last left them.
|
||||
const borrowed = !singletonServes(activeAccountId) && !uiStarted() && (await borrowSingleton(activeAccountId));
|
||||
const live = borrowed || singletonServes(activeAccountId);
|
||||
const client = live ? jmapClient : await openClient(activeAccountId);
|
||||
const next: WidgetSnapshot = {
|
||||
...previous,
|
||||
@@ -372,6 +387,7 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot>
|
||||
next.files = blank.files;
|
||||
next.vacation = null;
|
||||
next.quota = null;
|
||||
next.images = undefined;
|
||||
next.appDataAt = 0;
|
||||
}
|
||||
if (!client) return next;
|
||||
@@ -424,8 +440,8 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot>
|
||||
|
||||
if (live) {
|
||||
const [calendar, tasks, files, scheduled] = await Promise.all([
|
||||
buildCalendar(now, section.selfEmails, next),
|
||||
buildTasks(next),
|
||||
buildCalendar(now, section.selfEmails, next, activeAccountId),
|
||||
buildTasks(next, activeAccountId, borrowed),
|
||||
buildFiles(next),
|
||||
buildScheduled(next),
|
||||
]);
|
||||
@@ -435,25 +451,51 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise<WidgetSnapshot>
|
||||
next.mail.scheduled = scheduled;
|
||||
next.appDataAt = now;
|
||||
}
|
||||
next.images = await avatarImages(next, useSettingsStore.getState().senderFavicons);
|
||||
return next;
|
||||
}
|
||||
|
||||
let inflight: Promise<WidgetSnapshot> | null = null;
|
||||
let localChanges = 0;
|
||||
|
||||
/**
|
||||
* A widget button just edited the stored snapshot. A refresh that started
|
||||
* before it read the server before the change, so its result is dropped
|
||||
* rather than saved over the edit.
|
||||
*/
|
||||
export function noteLocalChange(): void {
|
||||
localChanges++;
|
||||
/** Pictures for every avatar the widgets can show (see ./avatar-images.ts). */
|
||||
async function avatarImages(s: WidgetSnapshot, favicons: boolean): Promise<WidgetSnapshot['images']> {
|
||||
const emails = [
|
||||
...s.mail.inbox.map((m) => m.fromEmail),
|
||||
...s.mail.unified.map((m) => m.fromEmail),
|
||||
...s.mail.starred.map((m) => m.fromEmail),
|
||||
...s.mail.favourites.map((p) => p.email),
|
||||
...s.calendar.events.flatMap((e) => e.participants.map((x) => x.email)),
|
||||
...s.calendar.birthdays.map((b) => b.email ?? ''),
|
||||
].filter(Boolean);
|
||||
try {
|
||||
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
|
||||
const contacts = useContactsStore.getState();
|
||||
if (!contacts.hydrated) await contacts.hydrate();
|
||||
return await resolveAvatarImages(emails, { contacts: useContactsStore.getState().contacts ?? [], favicons });
|
||||
} catch (err) {
|
||||
console.warn('[widgets] avatar pictures failed', err);
|
||||
return s.images;
|
||||
}
|
||||
}
|
||||
|
||||
async function borrowSingleton(registryAccountId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!(await jmapClient.loadAccount(registryAccountId))) return false;
|
||||
} catch {
|
||||
// Offline, or credentials the app will deal with when it opens.
|
||||
return false;
|
||||
}
|
||||
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
|
||||
const { useContactsStore } = require('../stores/contacts-store') as typeof import('../stores/contacts-store');
|
||||
await Promise.all([useCalendarStore.getState().hydrate(), useContactsStore.getState().hydrate()]).catch(() => undefined);
|
||||
return jmapClient.isConnected;
|
||||
}
|
||||
|
||||
let inflight: Promise<WidgetSnapshot> | null = null;
|
||||
|
||||
/**
|
||||
* Fetch fresh data and store it. Concurrent callers share one run: every
|
||||
* placed widget gets its own update broadcast, and they would otherwise each
|
||||
* start a refresh. On failure the previous snapshot is returned unchanged.
|
||||
* Changes widget buttons made meanwhile are kept over the result (./state.ts).
|
||||
*
|
||||
* `after` is for callers that just changed something on the server (a widget
|
||||
* button): a run already in flight may have read the server before that
|
||||
@@ -466,12 +508,11 @@ export async function refreshSnapshot(opts?: { after?: 'change' }): Promise<Widg
|
||||
}
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
const changesAtStart = localChanges;
|
||||
const startedAt = Date.now();
|
||||
const previous = (await loadSnapshot()) ?? emptySnapshot();
|
||||
try {
|
||||
const next = await buildSnapshot(previous);
|
||||
if (localChanges !== changesAtStart) return (await loadSnapshot()) ?? next;
|
||||
await saveSnapshot(next);
|
||||
await storeRefresh(next, startedAt);
|
||||
return next;
|
||||
} catch (err) {
|
||||
console.warn('[widgets] refresh failed', err);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// names an action the widget task performs in the background (./actions.ts).
|
||||
|
||||
import { APP_SCHEME } from '../navigation/linking';
|
||||
import type { EventItem, MailItem } from './snapshot';
|
||||
import type { EventItem, Invitation, MailItem, TaskItem } from './snapshot';
|
||||
|
||||
export interface WidgetClick {
|
||||
clickAction?: string;
|
||||
@@ -23,6 +23,36 @@ export function action(name: WidgetActionName, data: Record<string, unknown> = {
|
||||
return { clickAction: name, clickActionData: data };
|
||||
}
|
||||
|
||||
/**
|
||||
* A task tick. It names the state the user asked for, not "the opposite of
|
||||
* now": a second tap on a drawing that has not caught up yet would otherwise
|
||||
* undo the first. It carries the task's account and server id so it acts on
|
||||
* that task even when the widgets have moved on to another account.
|
||||
*/
|
||||
export function toggleTask(task: TaskItem): WidgetClick {
|
||||
return action('toggleTask', {
|
||||
id: task.id,
|
||||
done: !task.done,
|
||||
serverId: task.serverId,
|
||||
title: task.title,
|
||||
...(task.accountId ? { accountId: task.accountId } : {}),
|
||||
...(task.jmapAccountId ? { jmapAccountId: task.jmapAccountId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** An RSVP; like {@link toggleTask} it carries everything the call needs. */
|
||||
export function rsvp(inv: Invitation, status: 'accepted' | 'tentative' | 'declined'): WidgetClick {
|
||||
return action('rsvp', {
|
||||
id: inv.id,
|
||||
status,
|
||||
serverId: inv.serverId,
|
||||
participantId: inv.participantId,
|
||||
title: inv.title,
|
||||
...(inv.accountId ? { accountId: inv.accountId } : {}),
|
||||
...(inv.jmapAccountId ? { jmapAccountId: inv.jmapAccountId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function open(uri: string): WidgetClick {
|
||||
return { clickAction: 'OPEN_URI', clickActionData: { uri } };
|
||||
}
|
||||
@@ -56,8 +86,10 @@ export const links = {
|
||||
compose: (params: { to?: string; subject?: string; body?: string } = {}) =>
|
||||
`${base}compose${query({ to: params.to, subject: params.subject, body: params.body })}`,
|
||||
calendar: () => `${base}calendar`,
|
||||
event: (e: Pick<EventItem, 'serverId' | 'jmapAccountId'>) =>
|
||||
`${base}calendar/event/${enc(e.serverId)}${query({ account: e.jmapAccountId })}`,
|
||||
// `account` is the JMAP account of a shared calendar (as in webmail
|
||||
// permalinks); `appAccount` the signed-in account the event was shown for.
|
||||
event: (e: Pick<EventItem, 'serverId' | 'jmapAccountId' | 'accountId'>) =>
|
||||
`${base}calendar/event/${enc(e.serverId)}${query({ account: e.jmapAccountId, appAccount: e.accountId })}`,
|
||||
contacts: () => `${base}contacts`,
|
||||
files: () => `${base}files`,
|
||||
settings: (tab?: string) => `${base}settings${tab ? `/${enc(tab)}` : ''}`,
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Fmt {
|
||||
/** "in 25 min", "in 2 h", "now". */
|
||||
relative: (ms: number, now: number) => string;
|
||||
bytes: (n: number) => string;
|
||||
/** A count for a big number: as is below 10,000, then compact ("12K"). */
|
||||
count: (n: number) => string;
|
||||
}
|
||||
|
||||
function safeFormat(locale: string, options: Intl.DateTimeFormatOptions): (ms: number) => string {
|
||||
@@ -128,5 +130,19 @@ export function makeFmt(locale: string, hour12: boolean): Fmt {
|
||||
}
|
||||
return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||
},
|
||||
count: (n) => {
|
||||
if (n < 10000) return String(n);
|
||||
// Not Intl's compact notation: German leaves thousands unshortened
|
||||
// ("10.007"), which is what has to fit.
|
||||
const [value, unit] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : [n / 1000, 'K'];
|
||||
const digits = value < 100 ? 1 : 0;
|
||||
let text: string;
|
||||
try {
|
||||
text = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
|
||||
} catch {
|
||||
text = value.toFixed(digits).replace(/\.0$/, '');
|
||||
}
|
||||
return `${text}${unit}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import { addDays, sameDay, startOfDay, type Fmt } from '../format';
|
||||
import { ring, type IconName } from '../icons';
|
||||
import { AGENDA_ROW_HEIGHT, AgendaRow, DAY_BAND_HEIGHT, DayBand } from '../parts';
|
||||
import { Avatar, Button, Dot, EventChip, Fab, Header, Icon, Placeholder, Spacer, Surface, Txt } from '../primitives';
|
||||
import type { EventItem, WidgetSnapshot } from '../snapshot';
|
||||
import { avatarImageFor } from '../avatar-images';
|
||||
import type { AvatarImages, EventItem, WidgetSnapshot } from '../snapshot';
|
||||
import { chipBackground, normalizeHex, type WidgetPalette } from '../theme';
|
||||
import type { Layout } from './types';
|
||||
|
||||
@@ -192,7 +193,7 @@ export const AgendaLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
iconName="calendar"
|
||||
title={f.t('widgets.calendar.agenda', 'Agenda')}
|
||||
click={open(links.calendar())}
|
||||
trailing={<Fab p={p} iconName="plus" size={compact ? 28 : 32} click={open(links.calendar())} />}
|
||||
trailing={<Fab p={p} iconName="plus" size={compact ? 28 : 32} slop={compact ? 5 : 6} click={open(links.calendar())} />}
|
||||
/>
|
||||
);
|
||||
const days = agendaDays(s.calendar.events, now, 14);
|
||||
@@ -500,17 +501,6 @@ const MONTH_CELL_MAX = 48;
|
||||
const MONTH_CHIPS_TOP = 13;
|
||||
const MONTH_CHIPS_BOTTOM = 8;
|
||||
|
||||
function NavButton({ p, iconName }: { p: WidgetPalette; iconName: IconName }) {
|
||||
return (
|
||||
<FlexWidget
|
||||
{...open(links.calendar())}
|
||||
style={{ width: 32, height: 32, borderRadius: 6, justifyContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
<Icon name={iconName} color={p.fg} size={16} />
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthDay({
|
||||
p,
|
||||
cell,
|
||||
@@ -604,12 +594,11 @@ export const MonthLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
paddingRight: 8,
|
||||
}}
|
||||
>
|
||||
{/* No month arrows: a widget cannot page, and arrows that only open
|
||||
the app promise what they do not do. */}
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<Txt text={monthYear(f, now)} color={p.fg} size={16} weight="600" />
|
||||
</FlexWidget>
|
||||
<NavButton p={p} iconName="chevronLeft" />
|
||||
<Spacer size={4} horizontal />
|
||||
<NavButton p={p} iconName="chevronRight" />
|
||||
</FlexWidget>
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent', height: MONTH_LABEL_HEIGHT, paddingHorizontal: MONTH_PAD }}>
|
||||
{weeks[0].map((cell) => (
|
||||
@@ -968,13 +957,13 @@ function countdown(f: Fmt, e: EventItem, now: number, running: boolean): Countdo
|
||||
};
|
||||
}
|
||||
|
||||
function People({ p, e }: { p: WidgetPalette; e: EventItem }) {
|
||||
function People({ p, e, images }: { p: WidgetPalette; e: EventItem; images?: AvatarImages }) {
|
||||
const faces = e.participants.slice(0, 3);
|
||||
return (
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{faces.map((person, i) => (
|
||||
<FlexWidget key={`${person.email}-${i}`} style={{ marginLeft: i === 0 ? 0 : -7 }}>
|
||||
<Avatar initials={person.initials} color={person.color} size={22} ring={p.bg} />
|
||||
<Avatar initials={person.initials} color={person.color} size={22} ring={p.bg} image={avatarImageFor(images, person.email)} />
|
||||
</FlexWidget>
|
||||
))}
|
||||
<Spacer size={6} horizontal />
|
||||
@@ -1008,7 +997,8 @@ export const UpNextLayout: Layout = ({ s, p, f, now }) => {
|
||||
: f.t('widgets.calendar.join', 'Join');
|
||||
|
||||
return (
|
||||
<Surface p={p} style={{ padding: 14 }}>
|
||||
// 36dp buttons in a 48dp tap area (slop 6), which takes 6dp of the bottom padding.
|
||||
<Surface p={p} style={{ padding: 14, paddingBottom: 8 }}>
|
||||
<FlexWidget {...open(links.event(e))} style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
|
||||
<OverlapWidget style={{ width: 64, height: 64 }}>
|
||||
<SvgWidget svg={ring(left.fraction, p.border, p.primary, 5)} style={{ width: 64, height: 64 }} />
|
||||
@@ -1034,15 +1024,15 @@ export const UpNextLayout: Layout = ({ s, p, f, now }) => {
|
||||
<Txt text={meta} color={p.muted} size={13} />
|
||||
</FlexWidget>
|
||||
{e.participants.length > 0 ? <Spacer size={6} /> : null}
|
||||
{e.participants.length > 0 ? <People p={p} e={e} /> : null}
|
||||
{e.participants.length > 0 ? <People p={p} e={e} images={s.images} /> : null}
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
<Spacer />
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
{e.videoUrl ? (
|
||||
<Button p={p} variant="primary" iconName="video" label={joinLabel} height={36} flex={1} click={open(e.videoUrl)} />
|
||||
<Button p={p} variant="primary" iconName="video" label={joinLabel} height={36} flex={1} slop={6} click={open(e.videoUrl)} />
|
||||
) : (
|
||||
<Button p={p} iconName="calendar" label={f.t('widgets.calendar.open', 'Open')} height={36} flex={1} click={open(links.event(e))} />
|
||||
<Button p={p} iconName="calendar" label={f.t('widgets.calendar.open', 'Open')} height={36} flex={1} slop={6} click={open(links.event(e))} />
|
||||
)}
|
||||
<Spacer size={8} horizontal />
|
||||
<Button
|
||||
@@ -1051,6 +1041,7 @@ export const UpNextLayout: Layout = ({ s, p, f, now }) => {
|
||||
label={f.t('widgets.calendar.running_late', 'Running late')}
|
||||
height={36}
|
||||
flex={1}
|
||||
slop={6}
|
||||
click={open(late)}
|
||||
/>
|
||||
</FlexWidget>
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { FlexWidget, SvgWidget, TextWidget } from 'react-native-android-widget';
|
||||
import { action, links, open, type WidgetClick } from '../clicks';
|
||||
import { links, open, toggleTask } from '../clicks';
|
||||
import { dueKind, nextEvent, sortTasks, tasksDueNow, upcomingTimed } from '../derive';
|
||||
import { addDays, sameDay, startOfDay, type Fmt } from '../format';
|
||||
import { ring, type IconName } from '../icons';
|
||||
import { AGENDA_ROW_HEIGHT, AgendaRow, MAIL_ROW_HEIGHT, MailRow } from '../parts';
|
||||
import { AGENDA_ROW_HEIGHT, AgendaRow, MAIL_ROW_HEIGHT, MailRow, NOTICE_HEIGHT, NoticeBar } from '../parts';
|
||||
import { noticeFor } from '../pending';
|
||||
import {
|
||||
Divider,
|
||||
EventChip,
|
||||
@@ -92,7 +93,7 @@ function startLabel(f: Fmt, e: EventItem, now: number): string {
|
||||
const HUB_HEADER_HEIGHT = 44;
|
||||
/** SectionLabel: 11sp line plus 8 + 4 padding. */
|
||||
const SECTION_LABEL_HEIGHT = 28;
|
||||
const HUB_TASK_HEIGHT = 32;
|
||||
const HUB_TASK_HEIGHT = 48;
|
||||
|
||||
/**
|
||||
* Tasks for the hub: open tasks that are overdue or due today (sorted like the
|
||||
@@ -128,15 +129,13 @@ function HubTaskLine({ p, f, task, now }: { p: WidgetPalette; f: Fmt; task: Task
|
||||
alignItems: 'center',
|
||||
width: 'match_parent',
|
||||
height: HUB_TASK_HEIGHT,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 14,
|
||||
}}
|
||||
>
|
||||
{/* 32dp touch target around the 20dp box, which sits at the 14dp inset. */}
|
||||
{/* 48dp touch target around the 20dp box, which sits at the 14dp inset. */}
|
||||
<FlexWidget
|
||||
{...action('toggleTask', { id: task.id })}
|
||||
{...toggleTask(task)}
|
||||
accessibilityLabel={task.done ? f.t('widgets.tasks.mark_incomplete', 'Mark as not done') : f.t('widgets.tasks.mark_complete', 'Mark as done')}
|
||||
style={{ width: 32, height: 32, justifyContent: 'center', alignItems: 'center' }}
|
||||
style={{ width: 48, height: 48, justifyContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
<FlexWidget
|
||||
style={{
|
||||
@@ -153,12 +152,16 @@ function HubTaskLine({ p, f, task, now }: { p: WidgetPalette; f: Fmt; task: Task
|
||||
{task.done ? <Icon name="check" color="#ffffff" size={12} /> : null}
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
<Spacer size={6} horizontal />
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<Txt text={task.title || f.t('widgets.tasks.no_title', '(no title)')} color={task.done ? p.muted : p.fg} size={14} weight="500" />
|
||||
<FlexWidget
|
||||
{...open(links.calendar())}
|
||||
style={{ flex: 1, height: 'match_parent', flexDirection: 'row', alignItems: 'center', paddingRight: 14 }}
|
||||
>
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<Txt text={task.title || f.t('widgets.tasks.no_title', '(no title)')} color={task.done ? p.muted : p.fg} size={14} weight="500" />
|
||||
</FlexWidget>
|
||||
{dueLabel ? <Spacer size={8} horizontal /> : null}
|
||||
{dueLabel ? <Txt text={dueLabel} color={dueColor} size={12} /> : null}
|
||||
</FlexWidget>
|
||||
{dueLabel ? <Spacer size={8} horizontal /> : null}
|
||||
{dueLabel ? <Txt text={dueLabel} color={dueColor} size={12} /> : null}
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
@@ -171,7 +174,8 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
|
||||
// Each section first gets its label and one row, in order; what is left
|
||||
// then goes to a second message and more tasks in turn.
|
||||
let budget = height - 2 - HUB_HEADER_HEIGHT;
|
||||
const notice = noticeFor(s.notice, 'toggleTask');
|
||||
let budget = height - 2 - HUB_HEADER_HEIGHT - (notice ? NOTICE_HEIGHT + 12 : 0);
|
||||
const nextCost = SECTION_LABEL_HEIGHT + AGENDA_ROW_HEIGHT;
|
||||
const showNext = !!next && budget >= nextCost;
|
||||
if (showNext) budget -= nextCost;
|
||||
@@ -240,6 +244,11 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
</FlexWidget>
|
||||
<Divider p={p} />
|
||||
</FlexWidget>
|
||||
{notice ? (
|
||||
<FlexWidget style={{ width: 'match_parent', paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }}>
|
||||
<NoticeBar p={p} f={f} notice={notice} />
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
{empty ? (
|
||||
<Placeholder p={p} iconName="check" title={f.t('widgets.hub.all_clear', "You're all caught up")} click={open(links.calendar())} />
|
||||
) : null}
|
||||
@@ -278,14 +287,18 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
/* Mail and next event */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
export const MailAndNextLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
const tight = height < 140;
|
||||
const pad = tight ? 10 : 14;
|
||||
const unreadMail = s.mail.inbox.filter((m) => m.unread);
|
||||
const unread = Math.max(inboxUnread(s), unreadMail.length);
|
||||
const latest = unreadMail[0];
|
||||
const digits = String(unread).length;
|
||||
const digits = f.count(unread).length;
|
||||
const big = (tight ? 28 : 36) - (digits >= 4 ? 8 : 0);
|
||||
// "unread" sits beside the number where it fits (a half-width column),
|
||||
// under it otherwise (German, "ungelesen").
|
||||
const unreadLabel = f.t('widgets.mail.unread', 'unread');
|
||||
const labelBeside = digits * big * 0.6 + 4 + textWidth(unreadLabel, 13) <= (width - 2) / 2 - 2 * pad;
|
||||
|
||||
const upcoming = s.calendar.supported ? upcomingTimed(s.calendar.events, now) : [];
|
||||
const event = upcoming[0];
|
||||
@@ -316,16 +329,14 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
<Txt text={f.t('widgets.mail.inbox', 'Inbox')} color={p.fg} size={14} weight="600" />
|
||||
</FlexWidget>
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end', marginTop: 4 }}>
|
||||
<TextWidget text={String(unread)} maxLines={1} style={{ color: p.fg, fontSize: big, fontWeight: '700' }} />
|
||||
<Spacer size={4} horizontal />
|
||||
<TextWidget text={f.count(unread)} maxLines={1} style={{ color: p.fg, fontSize: big, fontWeight: '700' }} />
|
||||
{labelBeside ? <Spacer size={4} horizontal /> : null}
|
||||
{/* Lines the small label up with the big number's baseline. */}
|
||||
<Txt
|
||||
text={f.t('widgets.mail.unread', 'unread')}
|
||||
color={p.muted}
|
||||
size={13}
|
||||
style={{ paddingBottom: Math.round((big - 13) * 0.27) }}
|
||||
/>
|
||||
{labelBeside ? (
|
||||
<Txt text={unreadLabel} color={p.muted} size={13} style={{ paddingBottom: Math.round((big - 13) * 0.27) }} />
|
||||
) : null}
|
||||
</FlexWidget>
|
||||
{labelBeside ? null : <Txt text={unreadLabel} color={p.muted} size={13} />}
|
||||
<Spacer />
|
||||
{latest ? (
|
||||
<FlexWidget {...open(links.message(latest))} style={{ width: 'match_parent' }}>
|
||||
@@ -421,7 +432,7 @@ export const RecentFilesLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
title={f.t('widgets.shortcuts.files', 'Files')}
|
||||
count={f.t('widgets.files.recent', 'Recently changed')}
|
||||
click={open(links.files())}
|
||||
trailing={<GhostIcon p={p} iconName="upload" click={open(links.files())} />}
|
||||
trailing={<GhostIcon p={p} iconName="upload" size={40} click={open(links.files())} />}
|
||||
/>
|
||||
);
|
||||
const items = s.files.items;
|
||||
@@ -670,27 +681,6 @@ export const AttachmentsLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/** The settings switch (`h-[22px] w-10 rounded-full`), drawn only; taps open settings. */
|
||||
function Switch({ p, on, label, click }: { p: WidgetPalette; on: boolean; label: string; click: WidgetClick }) {
|
||||
return (
|
||||
<FlexWidget
|
||||
{...click}
|
||||
accessibilityLabel={label}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: on ? 'flex-end' : 'flex-start',
|
||||
alignItems: 'center',
|
||||
width: 40,
|
||||
height: 22,
|
||||
padding: 2,
|
||||
borderRadius: 11,
|
||||
backgroundColor: on ? p.primary : p.mutedBg,
|
||||
}}
|
||||
>
|
||||
<FlexWidget style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: on ? p.primaryFg : p.bg }} />
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
||||
export const VacationLayout: Layout = ({ s, p, f, width, height }) => {
|
||||
const click = open(links.settings('vacation'));
|
||||
const v = s.vacation;
|
||||
@@ -709,7 +699,7 @@ export const VacationLayout: Layout = ({ s, p, f, width, height }) => {
|
||||
const pad = 14;
|
||||
const avail = height - 2 - 2 * pad;
|
||||
const titleLines = textWidth(title, 14, 0.58) > width - 2 - 2 * pad ? 2 : 1;
|
||||
// Switch row, title (~17dp a line plus font padding), status pill.
|
||||
// Icon row, title (~17dp a line plus font padding), status pill.
|
||||
const base = 22 + titleLines * 17 + 2 + 19;
|
||||
let gapTop = 12;
|
||||
let gapPill = 6;
|
||||
@@ -728,7 +718,9 @@ export const VacationLayout: Layout = ({ s, p, f, width, height }) => {
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
|
||||
<Icon name="beach" color={p.fg} size={20} />
|
||||
<Spacer />
|
||||
<Switch p={p} on={v.enabled} label={title} click={click} />
|
||||
{/* Not a switch: the widget cannot turn replies on or off, it opens
|
||||
the settings that do. The pill below says which state it is in. */}
|
||||
<Icon name="chevronRight" color={p.muted} size={16} />
|
||||
</FlexWidget>
|
||||
<Spacer size={gapTop} />
|
||||
<Txt text={title} color={p.fg} size={14} weight="600" lines={titleLines} />
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import React from 'react';
|
||||
import { FlexWidget, TextWidget } from 'react-native-android-widget';
|
||||
import { action, links, open } from '../clicks';
|
||||
import { MAIL_ROW_HEIGHT, MailRow } from '../parts';
|
||||
import { MAIL_ROW_HEIGHT, MailRow, NoticeBar } from '../parts';
|
||||
import { noticeFor } from '../pending';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
Surface,
|
||||
Txt,
|
||||
} from '../primitives';
|
||||
import type { Fmt } from '../format';
|
||||
import { avatarImageFor } from '../avatar-images';
|
||||
import type { FolderCount } from '../snapshot';
|
||||
import type { IconName } from '../icons';
|
||||
import type { WidgetPalette } from '../theme';
|
||||
@@ -27,17 +30,19 @@ function inboxCounts(s: Parameters<Layout>[0]['s']) {
|
||||
return { unread: inbox?.unread ?? 0, total: inbox?.total ?? 0 };
|
||||
}
|
||||
|
||||
function CountText({ p, unread, total }: { p: WidgetPalette; unread: number; total: number }) {
|
||||
function CountText({ p, f, unread, total, short }: { p: WidgetPalette; f: Fmt; unread: number; total: number; short?: boolean }) {
|
||||
return (
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Txt text={String(unread)} color={p.fg} size={12} weight="600" />
|
||||
<Txt text={` / ${total}`} color={p.muted} size={12} />
|
||||
<Txt text={f.count(unread)} color={p.fg} size={12} weight="600" />
|
||||
{short ? null : <Txt text={` / ${f.count(total)}`} color={p.muted} size={12} />}
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
||||
export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
export const InboxLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
const compact = height < 190;
|
||||
// At two cells wide the title, count, search and compose do not fit.
|
||||
const narrow = width < 240;
|
||||
const headerHeight = compact ? 38 : 44;
|
||||
const rowHeight = compact ? MAIL_ROW_HEIGHT.compact : MAIL_ROW_HEIGHT.full;
|
||||
const rows = Math.max(1, Math.floor((height - headerHeight) / rowHeight));
|
||||
@@ -54,10 +59,11 @@ export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
click={open(links.inbox())}
|
||||
trailing={(
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<CountText p={p} unread={unread} total={total} />
|
||||
{compact ? null : <GhostIcon p={p} iconName="search" click={open(links.search())} />}
|
||||
<Spacer size={8} horizontal />
|
||||
<CountText p={p} f={f} unread={unread} total={total} short={narrow} />
|
||||
{compact || narrow ? null : <GhostIcon p={p} iconName="search" size={headerHeight} click={open(links.search())} />}
|
||||
<Spacer size={6} horizontal />
|
||||
<Fab p={p} iconName="edit" size={compact ? 28 : 32} click={open(links.compose())} />
|
||||
<Fab p={p} iconName="edit" size={compact ? 28 : 32} slop={compact ? 5 : 6} click={open(links.compose())} />
|
||||
</FlexWidget>
|
||||
)}
|
||||
/>
|
||||
@@ -65,14 +71,21 @@ export const InboxLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
<Placeholder p={p} iconName="inbox" title={f.t('widgets.mail.empty_inbox', 'Your inbox is empty')} click={open(links.inbox())} />
|
||||
) : (
|
||||
items.map((m, i) => (
|
||||
<MailRow key={m.id} p={p} f={f} m={m} now={now} compact={compact} last={i === items.length - 1} />
|
||||
<MailRow key={m.id} p={p} f={f} m={m} now={now} compact={compact} last={i === items.length - 1} images={s.images} />
|
||||
))
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
|
||||
/**
|
||||
* What a labelled button needs: icon, gap, label and its border. A glyph of
|
||||
* the 13sp label averages a little under half its size; measured against the
|
||||
* emulator, English fits at three cells and German does not.
|
||||
*/
|
||||
const buttonWidth = (label: string) => 15 + 6 + Math.ceil(label.length * 13 * 0.47) + 2;
|
||||
|
||||
export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, local }) => {
|
||||
const candidates = s.mail.inbox.filter((m) => m.unread);
|
||||
const current = candidates.find((m) => m.id === local.triageId) ?? candidates[0];
|
||||
const { unread } = inboxCounts(s);
|
||||
@@ -91,16 +104,32 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
|
||||
}
|
||||
const position = candidates.indexOf(current) + 1;
|
||||
const target = { id: current.id, accountId: current.accountId, jmapAccountId: current.jmapAccountId, widgetId };
|
||||
const notice = noticeFor(s.notice, 'archive', 'trash');
|
||||
// Long labels (German at three cells) drop to icons instead of being cut:
|
||||
// first Archive and Delete, then Reply too.
|
||||
const labels = {
|
||||
archive: f.t('widgets.mail.archive', 'Archive'),
|
||||
trash: f.t('widgets.mail.delete', 'Delete'),
|
||||
reply: f.t('widgets.mail.reply', 'Reply'),
|
||||
};
|
||||
const room = width - 2 - 24 - 32 - 18;
|
||||
const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
|
||||
const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room;
|
||||
return (
|
||||
<Surface p={p} style={{ padding: 12 }}>
|
||||
// The buttons draw 32dp tall in a 48dp tap area (slop 8), which takes the
|
||||
// gap above them and 8dp of the bottom padding.
|
||||
<Surface p={p} style={{ padding: 12, paddingBottom: 4 }}>
|
||||
<FlexWidget {...open(links.message(current))} style={{ width: 'match_parent', flex: 1 }}>
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent' }}>
|
||||
<Avatar initials={current.initials} color={current.color} size={32} />
|
||||
<Avatar initials={current.initials} color={current.color} size={32} image={avatarImageFor(s.images, current.fromEmail)} />
|
||||
<Spacer size={10} horizontal />
|
||||
<Txt text={current.fromName || current.fromEmail} color={p.fg} size={14} weight="700" />
|
||||
{/* A long name is cut, not the time and position after it. */}
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<Txt text={current.fromName || current.fromEmail} color={p.fg} size={14} weight="700" />
|
||||
</FlexWidget>
|
||||
<Spacer size={6} horizontal />
|
||||
<Txt text={f.listDate(current.receivedAt, now)} color={p.fg} size={12} weight="600" />
|
||||
<Spacer />
|
||||
<Spacer size={8} horizontal />
|
||||
<Txt
|
||||
text={f.t('widgets.mail.position', '{index} of {count}', { index: position, count: Math.max(unread, candidates.length) })}
|
||||
color={p.muted}
|
||||
@@ -109,24 +138,32 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
|
||||
</FlexWidget>
|
||||
<Spacer size={6} />
|
||||
<Txt text={current.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.fg} size={14} weight="600" />
|
||||
{current.preview ? <Txt text={current.preview} color={p.muted} size={13} lines={2} /> : null}
|
||||
{/* The notice takes the preview's place, so the card fits at its smallest. */}
|
||||
{current.preview && !notice ? (
|
||||
// Two lines at the default size; a taller card shows more of the message.
|
||||
<Txt text={current.preview} color={p.muted} size={13} lines={Math.max(2, Math.floor((height - 125) / 18))} />
|
||||
) : null}
|
||||
</FlexWidget>
|
||||
<Spacer size={8} />
|
||||
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<Button p={p} label={f.t('widgets.mail.archive', 'Archive')} iconName="archive" flex={1} click={action('archive', target)} />
|
||||
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} slop={8} click={action('archive', target)} />
|
||||
<Spacer size={6} horizontal />
|
||||
<Button p={p} label={f.t('widgets.mail.delete', 'Delete')} iconName="trash" flex={1} click={action('trash', target)} />
|
||||
<Button p={p} label={allFit ? labels.trash : undefined} iconName="trash" flex={1} slop={8} click={action('trash', target)} />
|
||||
<Spacer size={6} horizontal />
|
||||
<Button p={p} label={f.t('widgets.mail.reply', 'Reply')} iconName="reply" variant="primary" flex={1} click={open(links.reply(current))} />
|
||||
<Button p={p} label={allFit || replyFits ? labels.reply : undefined} iconName="reply" variant="primary" flex={1} slop={8} click={open(links.reply(current))} />
|
||||
<Spacer size={6} horizontal />
|
||||
<Button p={p} iconName="chevronRight" click={action('triageNext', { widgetId, id: current.id })} />
|
||||
<Button p={p} iconName="chevronRight" slop={8} click={action('triageNext', { widgetId, id: current.id })} />
|
||||
</FlexWidget>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
export const UnreadCountLayout: Layout = ({ s, p, f }) => {
|
||||
export const UnreadCountLayout: Layout = ({ s, p, f, width }) => {
|
||||
const { unread, total } = inboxCounts(s);
|
||||
const big = f.count(unread);
|
||||
const bigSize = big.length <= 3 ? 44 : big.length === 4 ? 38 : 32;
|
||||
// The total goes when the two do not fit on one line next to each other.
|
||||
const showTotal = big.length * bigSize * 0.6 + (f.count(total).length + 2) * 15 * 0.55 + 4 <= width - 30;
|
||||
const senders = s.mail.inbox.filter((m) => m.unread);
|
||||
const faces = senders.slice(0, 3);
|
||||
const more = Math.max(0, unread - faces.length);
|
||||
@@ -139,16 +176,18 @@ export const UnreadCountLayout: Layout = ({ s, p, f }) => {
|
||||
</FlexWidget>
|
||||
<FlexWidget>
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
|
||||
<TextWidget text={String(unread)} style={{ color: p.fg, fontSize: 44, fontWeight: '700' }} />
|
||||
<Spacer size={4} horizontal />
|
||||
<TextWidget text={`/ ${total}`} style={{ color: p.muted, fontSize: 15, paddingBottom: 8 }} />
|
||||
<TextWidget text={big} maxLines={1} style={{ color: p.fg, fontSize: bigSize, fontWeight: '700' }} />
|
||||
{showTotal ? <Spacer size={4} horizontal /> : null}
|
||||
{showTotal ? (
|
||||
<TextWidget text={`/ ${f.count(total)}`} maxLines={1} style={{ color: p.muted, fontSize: 15, paddingBottom: 8 }} />
|
||||
) : null}
|
||||
</FlexWidget>
|
||||
<Txt text={f.t('widgets.mail.unread', 'unread')} color={p.muted} size={13} />
|
||||
</FlexWidget>
|
||||
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{faces.map((m, i) => (
|
||||
<FlexWidget key={m.id} style={{ marginLeft: i === 0 ? 0 : -8 }}>
|
||||
<Avatar initials={m.initials} color={m.color} size={24} ring={p.bg} />
|
||||
<Avatar initials={m.initials} color={m.color} size={24} ring={p.bg} image={avatarImageFor(s.images, m.fromEmail)} />
|
||||
</FlexWidget>
|
||||
))}
|
||||
{more > 0 ? <Spacer size={6} horizontal /> : null}
|
||||
@@ -158,7 +197,7 @@ export const UnreadCountLayout: Layout = ({ s, p, f }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const LatestMessageLayout: Layout = ({ s, p, f, now }) => {
|
||||
export const LatestMessageLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
const latest = s.mail.inbox.find((m) => m.unread);
|
||||
const { unread } = inboxCounts(s);
|
||||
if (!latest) {
|
||||
@@ -171,13 +210,18 @@ export const LatestMessageLayout: Layout = ({ s, p, f, now }) => {
|
||||
return (
|
||||
<Surface p={p} style={{ padding: 14, backgroundColor: p.unreadRow }} click={open(links.message(latest))}>
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<Avatar initials={latest.initials} color={latest.color} size={40} />
|
||||
<Avatar initials={latest.initials} color={latest.color} size={40} image={avatarImageFor(s.images, latest.fromEmail)} />
|
||||
<Spacer />
|
||||
<Txt text={f.listDate(latest.receivedAt, now)} color={p.fg} size={12} weight="600" />
|
||||
</FlexWidget>
|
||||
<Spacer size={6} />
|
||||
<Txt text={latest.fromName || latest.fromEmail} color={p.fg} size={14} weight="700" />
|
||||
<Txt text={latest.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.fg} size={14} weight="600" lines={2} />
|
||||
{/* Resized taller, the card has room for the start of the message. */}
|
||||
{latest.preview && height >= 260 ? <Spacer size={4} /> : null}
|
||||
{latest.preview && height >= 260 ? (
|
||||
<Txt text={latest.preview} color={p.muted} size={13} lines={Math.floor((height - 190) / 18)} />
|
||||
) : null}
|
||||
<Spacer />
|
||||
{unread > 1 ? (
|
||||
<Txt text={f.t('widgets.mail.more_unread', '+{count} more unread', { count: unread - 1 })} color={p.muted} size={12} />
|
||||
@@ -215,6 +259,13 @@ export const FolderCountsLayout: Layout = ({ s, p, f, height }) => {
|
||||
const count = role === 'drafts' ? folder.total : folder.unread;
|
||||
rows.push({ role, name: folder.name, count, strong: role !== 'drafts' && folder.unread > 0 });
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Surface p={p}>
|
||||
<Placeholder p={p} iconName="inbox" title={f.t('widgets.mail.no_folders', 'No folders to show')} click={open(links.inbox())} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
const fit = Math.max(1, Math.floor((height - 40) / 31));
|
||||
const colors: Record<FolderCount['role'], string> = {
|
||||
inbox: p.folder.inbox,
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
Surface,
|
||||
Txt,
|
||||
} from '../primitives';
|
||||
import type { AccountSummary, Person, ScheduledItem } from '../snapshot';
|
||||
import { avatarImageFor } from '../avatar-images';
|
||||
import type { AccountSummary, AvatarImages, Person, ScheduledItem } from '../snapshot';
|
||||
import { normalizeHex, type WidgetPalette } from '../theme';
|
||||
import type { Layout } from './types';
|
||||
|
||||
@@ -191,6 +192,7 @@ export const AllAccountsLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
showAccountDot
|
||||
accountColor={colors.get(m.accountId)}
|
||||
last={i === items.length - 1}
|
||||
images={s.images}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -237,6 +239,7 @@ export const StarredLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
now={now}
|
||||
compact={compact}
|
||||
last={i === items.length - 1}
|
||||
images={s.images}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -285,6 +288,7 @@ function PersonTile({
|
||||
avatar,
|
||||
showTime,
|
||||
now,
|
||||
images,
|
||||
}: {
|
||||
p: WidgetPalette;
|
||||
f: Fmt;
|
||||
@@ -292,6 +296,7 @@ function PersonTile({
|
||||
avatar: number;
|
||||
showTime: boolean;
|
||||
now: number;
|
||||
images?: AvatarImages;
|
||||
}) {
|
||||
// The badge sits 3dp above and 5dp right of the avatar, as in the list's
|
||||
// avatar badges; the box leaves 5dp on both sides so the avatar stays centred.
|
||||
@@ -303,7 +308,7 @@ function PersonTile({
|
||||
>
|
||||
<Overlay style={{ width: avatar + 10, height: avatar + 3 }}>
|
||||
<FlexWidget style={{ marginLeft: 5, marginTop: 3 }}>
|
||||
<Avatar initials={person.initials} color={person.color} size={avatar} />
|
||||
<Avatar initials={person.initials} color={person.color} size={avatar} image={avatarImageFor(images, person.email)} />
|
||||
</FlexWidget>
|
||||
{unread ? <UnreadBadge p={p} count={person.unread} left={avatar + 10} /> : null}
|
||||
</Overlay>
|
||||
@@ -373,6 +378,7 @@ export const FavouritePeopleLayout: Layout = ({ s, p, f, now, width, height }) =
|
||||
avatar={avatar}
|
||||
showTime={showTime}
|
||||
now={now}
|
||||
images={s.images}
|
||||
/>
|
||||
) : (
|
||||
<FlexWidget key={`person-${i}`} style={{ flex: 1, width: 0 }} />
|
||||
@@ -671,7 +677,7 @@ export const TagLayout: Layout = ({ s, p, f, now }) => {
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
<FlexWidget>
|
||||
<TextWidget text={String(count)} style={{ color: p.fg, fontSize: 44, fontWeight: '700' }} />
|
||||
<TextWidget text={f.count(count)} maxLines={1} style={{ color: p.fg, fontSize: f.count(count).length <= 4 ? 44 : 34, fontWeight: '700' }} />
|
||||
<Txt text={label} color={p.muted} size={13} />
|
||||
</FlexWidget>
|
||||
<Txt text={latest} color={p.muted} size={12} />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { FlexWidget, OverlapWidget, SvgWidget } from 'react-native-android-widget';
|
||||
import { action, links, open } from '../clicks';
|
||||
import { links, open, rsvp } from '../clicks';
|
||||
import {
|
||||
busyBlocks,
|
||||
countdownTarget,
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
} from '../derive';
|
||||
import { addDays, sameDay, startOfDay, type Fmt } from '../format';
|
||||
import { ring, type IconName } from '../icons';
|
||||
import { TASK_ROW_HEIGHT, TaskRow } from '../parts';
|
||||
import { NOTICE_HEIGHT, NoticeBar, TASK_ROW_HEIGHT, TaskRow } from '../parts';
|
||||
import { noticeFor } from '../pending';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
Surface,
|
||||
Txt,
|
||||
} from '../primitives';
|
||||
import { avatarImageFor } from '../avatar-images';
|
||||
import type { EventItem, TaskItem } from '../snapshot';
|
||||
import { normalizeHex, type WidgetPalette } from '../theme';
|
||||
import type { Layout } from './types';
|
||||
@@ -97,6 +99,8 @@ export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
|
||||
const pad = height < 140 ? 10 : 14;
|
||||
const showPeople = inv.participants.length > 0 && height >= 132;
|
||||
const buttonHeight = height < 116 ? 28 : 32;
|
||||
// Tap area of 48dp around the buttons; it takes the bottom padding.
|
||||
const slop = Math.min(pad, Math.round((48 - buttonHeight) / 2));
|
||||
|
||||
const label = (
|
||||
inv.organizerName
|
||||
@@ -106,10 +110,11 @@ export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
|
||||
const names = inv.participants.slice(0, 3).map((x) => x.name);
|
||||
const extra = inv.participants.length - names.length;
|
||||
const people = `${names.join(', ')}${extra > 0 ? ` +${extra}` : ''}`;
|
||||
const reply = (status: 'accepted' | 'tentative' | 'declined') => action('rsvp', { id: inv.id, status });
|
||||
const reply = (status: 'accepted' | 'tentative' | 'declined') => rsvp(inv, status);
|
||||
const notice = noticeFor(s.notice, 'rsvp');
|
||||
|
||||
return (
|
||||
<Surface p={p} style={{ padding: pad }}>
|
||||
<Surface p={p} style={{ padding: pad, paddingBottom: pad - slop }}>
|
||||
<FlexWidget {...open(links.event(inv))} style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<FlexWidget
|
||||
style={{
|
||||
@@ -140,11 +145,13 @@ export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
|
||||
</FlexWidget>
|
||||
<Txt text={untitled(f, inv.title)} color={p.fg} size={15} weight="600" />
|
||||
<MetaLine p={p} iconName="clock" text={eventWhen(f, inv)} />
|
||||
{showPeople ? <MetaLine p={p} iconName="users" text={people} /> : null}
|
||||
{showPeople && !notice ? <MetaLine p={p} iconName="users" text={people} /> : null}
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
<Spacer size={8} />
|
||||
<Spacer />
|
||||
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
|
||||
{notice ? <Spacer size={6} /> : null}
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<Button
|
||||
p={p}
|
||||
@@ -152,12 +159,13 @@ export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
|
||||
label={f.t('widgets.invites.accept', 'Accept')}
|
||||
flex={1}
|
||||
height={buttonHeight}
|
||||
slop={slop}
|
||||
click={reply('accepted')}
|
||||
/>
|
||||
<Spacer size={6} horizontal />
|
||||
<Button p={p} label={f.t('widgets.invites.maybe', 'Maybe')} flex={1} height={buttonHeight} click={reply('tentative')} />
|
||||
<Button p={p} label={f.t('widgets.invites.maybe', 'Maybe')} flex={1} height={buttonHeight} slop={slop} click={reply('tentative')} />
|
||||
<Spacer size={6} horizontal />
|
||||
<Button p={p} label={f.t('widgets.invites.decline', 'Decline')} flex={1} height={buttonHeight} click={reply('declined')} />
|
||||
<Button p={p} label={f.t('widgets.invites.decline', 'Decline')} flex={1} height={buttonHeight} slop={slop} click={reply('declined')} />
|
||||
</FlexWidget>
|
||||
</Surface>
|
||||
);
|
||||
@@ -356,7 +364,9 @@ export const FreeTimeLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface p={p} style={{ paddingHorizontal: 14, paddingVertical: tier.pad }}>
|
||||
// The full-width button draws tier.button tall; its tap area also takes
|
||||
// the bottom padding (slop).
|
||||
<Surface p={p} style={{ paddingHorizontal: 14, paddingTop: tier.pad, paddingBottom: tier.button ? 0 : tier.pad }}>
|
||||
<FlexWidget
|
||||
{...open(links.calendar())}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', width: 'match_parent', height: FREE_HEADLINE_HEIGHT }}
|
||||
@@ -423,7 +433,6 @@ export const FreeTimeLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
</FlexWidget>
|
||||
{tier.button ? <Spacer size={tier.gap} /> : null}
|
||||
<Spacer />
|
||||
{tier.button ? (
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
@@ -433,6 +442,8 @@ export const FreeTimeLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
label={f.t('widgets.free.mail', 'Mail my free times')}
|
||||
flex={1}
|
||||
height={tier.button}
|
||||
// Above, the tap area takes the gap and any height to spare; below, the padding.
|
||||
slop={{ top: Math.min(tier.pad, tier.gap + Math.max(0, height - freeTierHeight(tier))), bottom: tier.pad }}
|
||||
click={mail}
|
||||
/>
|
||||
</FlexWidget>
|
||||
@@ -464,7 +475,9 @@ export const TasksLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
// Open tasks first; sortTasks puts the completed ones last, so they only fill leftover rows.
|
||||
const sorted = sortTasks(s.tasks.items, now);
|
||||
const openCount = sorted.filter((t) => !t.done).length;
|
||||
const fit = Math.max(1, Math.floor((height - 2 - headerHeight - 1) / TASK_ROW_HEIGHT));
|
||||
const notice = noticeFor(s.notice, 'toggleTask');
|
||||
const noticeSpace = notice ? NOTICE_HEIGHT + 12 : 0;
|
||||
const fit = Math.max(1, Math.floor((height - 2 - headerHeight - 1 - noticeSpace) / TASK_ROW_HEIGHT));
|
||||
const rows = sorted.slice(0, fit);
|
||||
return (
|
||||
<Surface p={p}>
|
||||
@@ -475,8 +488,13 @@ export const TasksLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
title={f.t('widgets.tasks.title', 'Tasks')}
|
||||
count={f.t('widgets.tasks.open_count', '{count} open', { count: openCount })}
|
||||
click={open(links.calendar())}
|
||||
trailing={<Fab p={p} iconName="plus" size={compact ? 28 : 32} click={open(links.calendar())} />}
|
||||
trailing={<Fab p={p} iconName="plus" size={compact ? 28 : 32} slop={compact ? 4 : 6} click={open(links.calendar())} />}
|
||||
/>
|
||||
{notice ? (
|
||||
<FlexWidget style={{ width: 'match_parent', paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }}>
|
||||
<NoticeBar p={p} f={f} notice={notice} />
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
{openCount === 0 ? (
|
||||
<Placeholder p={p} iconName="check" title={f.t('widgets.tasks.empty', 'No open tasks')} click={open(links.calendar())} />
|
||||
) : (
|
||||
@@ -641,7 +659,7 @@ export const BirthdaysLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
borderBottomColor: p.border,
|
||||
}}
|
||||
>
|
||||
<Avatar initials={b.initials} color={b.color} size={32} />
|
||||
<Avatar initials={b.initials} color={b.color} size={32} image={avatarImageFor(s.images, b.email)} />
|
||||
<Spacer size={10} horizontal />
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<Txt text={b.name || b.email || ''} color={p.fg} size={14} weight="600" />
|
||||
@@ -654,6 +672,8 @@ export const BirthdaysLayout: Layout = ({ s, p, f, now, width, height }) => {
|
||||
variant="primary"
|
||||
iconName="send"
|
||||
label={narrow ? undefined : f.t('widgets.birthdays.send_wishes', 'Send wishes')}
|
||||
// The row is 50dp; the tap area takes its full height.
|
||||
slop={Math.floor((BIRTHDAY_ROW_HEIGHT - 32) / 2)}
|
||||
click={open(links.compose({ to: wish, subject: f.t('widgets.birthdays.wishes_subject', 'Happy birthday!') }))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { FlexWidget, TextWidget } from 'react-native-android-widget';
|
||||
import { action, links, open, type WidgetClick } from './clicks';
|
||||
import { avatarImageFor } from './avatar-images';
|
||||
import { action, links, open, toggleTask, type WidgetClick } from './clicks';
|
||||
import { dueKind } from './derive';
|
||||
import type { Fmt } from './format';
|
||||
import { Avatar, ColorBar, Dot, FilledIcon, Icon, Spacer, Txt } from './primitives';
|
||||
import type { EventItem, MailItem, TaskItem } from './snapshot';
|
||||
import type { ActionNotice, AvatarImages, EventItem, MailItem, TaskItem } from './snapshot';
|
||||
import type { WidgetPalette } from './theme';
|
||||
|
||||
/** `bg-primary` pill with the message icon and the thread size, as in the list. */
|
||||
@@ -44,6 +45,7 @@ export function MailRow({
|
||||
showAccountDot,
|
||||
accountColor,
|
||||
last,
|
||||
images,
|
||||
}: {
|
||||
p: WidgetPalette;
|
||||
f: Fmt;
|
||||
@@ -53,6 +55,8 @@ export function MailRow({
|
||||
showAccountDot?: boolean;
|
||||
accountColor?: string;
|
||||
last?: boolean;
|
||||
/** The snapshot's avatar pictures; without them the avatar shows the initials. */
|
||||
images?: AvatarImages;
|
||||
}) {
|
||||
const click = open(links.message(m));
|
||||
const senderColor = m.unread ? p.fg : p.muted;
|
||||
@@ -98,7 +102,7 @@ export function MailRow({
|
||||
<FlexWidget style={{ width: 12, paddingTop: 5, alignItems: 'center' }}>
|
||||
{m.unread ? <Dot color={p.unreadDot} size={7} /> : null}
|
||||
</FlexWidget>
|
||||
{!compact ? <Avatar initials={m.initials} color={m.color} size={32} /> : null}
|
||||
{!compact ? <Avatar initials={m.initials} color={m.color} size={32} image={avatarImageFor(images, m.fromEmail)} /> : null}
|
||||
{!compact ? <Spacer size={10} horizontal /> : null}
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
{header}
|
||||
@@ -206,7 +210,9 @@ export function TaskRow({
|
||||
now: number;
|
||||
last?: boolean;
|
||||
}) {
|
||||
const kind = dueKind(task, now);
|
||||
// A finished task is not overdue: it shows its date.
|
||||
const due = dueKind(task, now);
|
||||
const kind = task.done && due === 'overdue' ? 'later' : due;
|
||||
const dueColor = task.done
|
||||
? p.muted
|
||||
: kind === 'overdue'
|
||||
@@ -223,36 +229,42 @@ export function TaskRow({
|
||||
: kind === 'tomorrow'
|
||||
? f.t('widgets.tasks.tomorrow', 'Tomorrow')
|
||||
: f.weekdayDayMonth(task.due);
|
||||
const toggle: WidgetClick = action('toggleTask', { id: task.id });
|
||||
const toggle: WidgetClick = toggleTask(task);
|
||||
return (
|
||||
<FlexWidget
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
width: 'match_parent',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
height: TASK_ROW_HEIGHT,
|
||||
borderBottomWidth: last ? 0 : 1,
|
||||
borderBottomColor: p.border,
|
||||
}}
|
||||
>
|
||||
{/* The tick takes the row's full height and 50dp around the 22dp box. */}
|
||||
<FlexWidget
|
||||
{...toggle}
|
||||
accessibilityLabel={task.done ? f.t('widgets.tasks.mark_incomplete', 'Mark as not done') : f.t('widgets.tasks.mark_complete', 'Mark as done')}
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
borderColor: task.done ? p.success : (`${p.muted}66` as `#${string}`),
|
||||
backgroundColor: task.done ? p.success : p.bg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
style={{ width: 50, height: 'match_parent', justifyContent: 'center', alignItems: 'center' }}
|
||||
>
|
||||
{task.done ? <Icon name="check" color="#ffffff" size={13} /> : null}
|
||||
<FlexWidget
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
borderColor: task.done ? p.success : (`${p.muted}66` as `#${string}`),
|
||||
backgroundColor: task.done ? p.success : p.bg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{task.done ? <Icon name="check" color="#ffffff" size={13} /> : null}
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
<Spacer size={12} horizontal />
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<FlexWidget
|
||||
{...open(links.calendar())}
|
||||
style={{ flex: 1, height: 'match_parent', justifyContent: 'center', paddingRight: 14 }}
|
||||
>
|
||||
<TextWidget
|
||||
text={task.title || f.t('widgets.tasks.no_title', '(no title)')}
|
||||
maxLines={1}
|
||||
@@ -273,4 +285,46 @@ export function TaskRow({
|
||||
);
|
||||
}
|
||||
|
||||
export const TASK_ROW_HEIGHT = 55;
|
||||
export const TASK_ROW_HEIGHT = 56;
|
||||
|
||||
export const NOTICE_HEIGHT = 44;
|
||||
|
||||
/**
|
||||
* A button's change that did not reach the server: says so in the
|
||||
* destructive tint (webmail `bg-destructive/10 text-destructive`) and repeats
|
||||
* the tap when tapped.
|
||||
*/
|
||||
export function NoticeBar({ p, f, notice }: { p: WidgetPalette; f: Fmt; notice: ActionNotice }) {
|
||||
const text = notice.action === 'archive'
|
||||
? f.t('widgets.notice.archive', "Couldn't archive. Tap to try again.")
|
||||
: notice.action === 'trash'
|
||||
? f.t('widgets.notice.trash', "Couldn't delete. Tap to try again.")
|
||||
: notice.action === 'markRead'
|
||||
? f.t('widgets.notice.mark_read', "Couldn't mark as read. Tap to try again.")
|
||||
: notice.action === 'rsvp'
|
||||
? f.t('widgets.notice.rsvp', "Couldn't send your answer. Tap to try again.")
|
||||
: f.t('widgets.notice.task', "Couldn't update “{title}”. Tap to try again.", {
|
||||
title: notice.label || f.t('widgets.tasks.no_title', '(no title)'),
|
||||
});
|
||||
return (
|
||||
<FlexWidget
|
||||
{...action(notice.action, notice.retry)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: 'match_parent',
|
||||
height: NOTICE_HEIGHT,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 6,
|
||||
backgroundColor: p.destructiveBg,
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" color={p.destructive as string} size={14} />
|
||||
<Spacer size={8} horizontal />
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
{/* Two lines: a long task title must not cut off "Tap to try again". */}
|
||||
<Txt text={text} color={p.destructive} size={12} weight="500" lines={2} />
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Changes a widget button made that the stored snapshot does not show yet.
|
||||
// The snapshot holds what the server last said; these are laid over it when
|
||||
// a widget draws, so a refresh that read the server before the change landed
|
||||
// cannot bring an archived message back, and a change the server refused is
|
||||
// undone by dropping it rather than by hoping the next refresh gets through.
|
||||
//
|
||||
// An op is pending until the server answers, then done: it stays until a
|
||||
// refresh that started after the server took it has replaced the data it
|
||||
// covers (mail, or the calendar/tasks part that only some refreshes load).
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { WidgetActionName } from './clicks';
|
||||
import type { ActionNotice, WidgetSnapshot } from './snapshot';
|
||||
|
||||
export const PENDING_KEY = 'widgets:pending:v1';
|
||||
|
||||
export type PendingChange =
|
||||
| { kind: 'removeMail'; id: string }
|
||||
| { kind: 'markRead'; id: string }
|
||||
// `accountId`: the signed-in account the item belongs to. JMAP ids are
|
||||
// only unique within an account, so the change must not touch another
|
||||
// account's item with the same id once the widgets show that account.
|
||||
| { kind: 'rsvp'; id: string; serverId: string; status: 'accepted' | 'tentative' | 'declined'; accountId?: string }
|
||||
| { kind: 'task'; id: string; done: boolean; accountId?: string };
|
||||
|
||||
export interface PendingOp {
|
||||
/** Unique per tap. */
|
||||
key: string;
|
||||
change: PendingChange;
|
||||
/** When the tap was made. */
|
||||
at: number;
|
||||
/** When the server took the change; absent while it is in flight. */
|
||||
doneAt?: number;
|
||||
}
|
||||
|
||||
export interface PendingState {
|
||||
ops: PendingOp[];
|
||||
/** The last button change that did not reach the server, until it is retried or goes stale. */
|
||||
notice?: ActionNotice | null;
|
||||
}
|
||||
|
||||
/** A done op the next refreshes never confirmed is dropped after this long. */
|
||||
export const DONE_TTL_MS = 30 * 60 * 1000;
|
||||
/** An op still in flight after this long belongs to a task that died. */
|
||||
export const IN_FLIGHT_TTL_MS = 2 * 60 * 1000;
|
||||
/** How long a failure notice stays on the widget. */
|
||||
export const NOTICE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export function emptyPending(): PendingState {
|
||||
return { ops: [] };
|
||||
}
|
||||
|
||||
export async function loadPending(): Promise<PendingState> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PENDING_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<PendingState>) : null;
|
||||
return {
|
||||
ops: Array.isArray(parsed?.ops) ? parsed!.ops : [],
|
||||
notice: parsed?.notice && typeof parsed.notice === 'object' ? parsed.notice : null,
|
||||
};
|
||||
} catch {
|
||||
return emptyPending();
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePending(state: PendingState): Promise<void> {
|
||||
await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
const isMail = (c: PendingChange) => c.kind === 'removeMail' || c.kind === 'markRead';
|
||||
|
||||
/** Drop what has expired: in-flight ops of a task that died, done ops no refresh confirmed, an old notice. */
|
||||
export function expire(state: PendingState, now: number): PendingState {
|
||||
return {
|
||||
ops: state.ops.filter((o) => (o.doneAt ? now - o.doneAt < DONE_TTL_MS : now - o.at < IN_FLIGHT_TTL_MS)),
|
||||
notice: state.notice && now - state.notice.at < NOTICE_TTL_MS ? state.notice : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which widget buttons a notice belongs to. */
|
||||
export function noticeFor(notice: ActionNotice | null | undefined, ...actions: WidgetActionName[]): ActionNotice | null {
|
||||
return notice && actions.includes(notice.action) ? notice : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a refresh that started at `startedAt` was stored: the done ops it
|
||||
* covers are now part of the snapshot. Mail is always refreshed; calendar and
|
||||
* tasks only when `appData` is set.
|
||||
*/
|
||||
export function confirmedBy(ops: PendingOp[], startedAt: number, appData: boolean): PendingOp[] {
|
||||
return ops.filter((o) => !(o.doneAt !== undefined && o.doneAt < startedAt && (appData || isMail(o.change))));
|
||||
}
|
||||
|
||||
/** The snapshot as the widgets should show it: the stored one with every op applied. */
|
||||
export function applyPending(s: WidgetSnapshot, ops: PendingOp[]): WidgetSnapshot {
|
||||
if (ops.length === 0) return s;
|
||||
const next: WidgetSnapshot = {
|
||||
...s,
|
||||
mail: {
|
||||
...s.mail,
|
||||
folders: s.mail.folders.map((f) => ({ ...f })),
|
||||
inbox: [...s.mail.inbox],
|
||||
unified: [...s.mail.unified],
|
||||
starred: [...s.mail.starred],
|
||||
},
|
||||
calendar: { ...s.calendar, events: [...s.calendar.events], invitations: [...s.calendar.invitations] },
|
||||
tasks: { ...s.tasks, items: [...s.tasks.items] },
|
||||
};
|
||||
for (const { change } of ops) {
|
||||
switch (change.kind) {
|
||||
case 'removeMail': {
|
||||
const removed = next.mail.inbox.find((m) => m.id === change.id);
|
||||
const drop = <T extends { id: string }>(list: T[]) => list.filter((m) => m.id !== change.id);
|
||||
next.mail.inbox = drop(next.mail.inbox);
|
||||
next.mail.unified = drop(next.mail.unified);
|
||||
next.mail.starred = drop(next.mail.starred);
|
||||
const inbox = next.mail.folders.find((f) => f.role === 'inbox');
|
||||
if (inbox && removed) {
|
||||
inbox.total = Math.max(0, inbox.total - 1);
|
||||
if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'markRead': {
|
||||
const hit = next.mail.inbox.find((m) => m.id === change.id);
|
||||
const inbox = next.mail.folders.find((f) => f.role === 'inbox');
|
||||
if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1);
|
||||
const read = <T extends { id: string; unread: boolean }>(list: T[]) =>
|
||||
list.map((m) => (m.id === change.id && m.unread ? { ...m, unread: false } : m));
|
||||
next.mail.inbox = read(next.mail.inbox);
|
||||
next.mail.unified = read(next.mail.unified);
|
||||
next.mail.starred = read(next.mail.starred);
|
||||
break;
|
||||
}
|
||||
case 'rsvp':
|
||||
if (change.accountId && change.accountId !== s.activeAccountId) break;
|
||||
next.calendar.invitations = next.calendar.invitations.filter(
|
||||
(i) => i.id !== change.id && i.serverId !== change.serverId,
|
||||
);
|
||||
next.calendar.events = next.calendar.events.map((e) =>
|
||||
(e.serverId === change.serverId && e.myStatus ? { ...e, myStatus: change.status } : e));
|
||||
break;
|
||||
case 'task':
|
||||
if (change.accountId && change.accountId !== s.activeAccountId) break;
|
||||
next.tasks.items = next.tasks.items.map((t) => (t.id === change.id ? { ...t, done: change.done } : t));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |